Skip to navigation

How To Use a Custom View to Output Different Content Types in Concrete5.7, Part 2

Written by and published on

The final step is to create a custom View, which is going to be the most work. It’s going to be reusable, so that work needs to be done only once, though. Here’s what it looks like:

<?php
namespace Application\Src\View;

use Concrete\Core\View\AbstractView;
use Environment;
use Events;
use Page;

/**
 * An unopinionated view for rendering other than HTML content.
 */
class RawView extends AbstractView
{

  /**
   * Constructor. Called from the __construct() method of the parent.
   *
   * @param  Controller  $controller
   */
  protected function constructView( $controller = false ) {
    $this->setController( $controller );
  }

  /**
   * A shortcut method for the url() method. Should be implemented if
   * you want to generate post back URLs. Skipped here because this
   * view is not intended to be used for rendering HTML.
   *
   * @see    src\View\View.php for an example.
   *
   * @param  string  $action  The name of the action method.
   */
  public function action( $action ){}

  /**
   * Sets the inner content file. Run automatically in the beginning
   * of the rendering process.
   *
   * @param  string  $state  The name of the content file without extension
   */
  public function start( $state = false ) {
    if ( $state ) {
      $this->setContentFile( $state );
    }
  }

  /**
   * Sets the file name without extension that should be used for
   * rendering the content.
   *
   * @param  string  $contentFile  The name of the content file.
   */
  public function setContentFile( $contentFile ) {
    $this->contentFile = $contentFile;
  }

  /**
   * Sets the full path to the file that should be used for rendering
   * the content. Used mainly internally.
   *
   * @param  string  $innerContentFile
   */
  public function setInnerContentFile( $innerContentFile ) {
    $this->innerContentFile = $innerContentFile;
  }

  /**
   * Prepares the view for rendering.
   */
  public function setupRender() {
    if ( $this->contentFile ) {
      $this->setupContentFile();
    }

    if ( !$this->templateFile ) {
      $this->templateFile = $this->controller->getThemeViewTemplate();
    }

    if ( $this->templateFile ) {
      $this->setupTemplateFile();
    }
  }

  /**
   * Finds and assigns the template for inner content.
   */
  protected function setupContentFile() {
    $env = Environment::get();
    // Just in case the filename was given with extension. Fails,
    // of course, if there is ".php" anywhere else in the filename.
    $contentFile = str_replace( '.php', '', $this->contentFile ) . '.php';
    $pagePackage = Page::getCurrentPage()->getPackageHandle();
    $contentPath = $env->getPath( DIRNAME_VIEWS . '/' . $contentFile, $pagePackage );
    if ( is_readable( $contentPath ) ) {
      $this->setInnerContentFile( $contentPath );
    }
  }

  /**
   * Finds and assigns the template for the final output.
   */
  protected function setupTemplateFile() {
    $env = Environment::get();
    $templateFile = str_replace( '.php', '', $this->templateFile ) . '.php';
    // By design, every page has a Theme, so this should always return
    // a themeObject
    $themeObject = Page::getCurrentPage()->getCollectionThemeObject();
    // Likewise, each theme has a handle
    $themeHandle = $themeObject->getThemeHandle();
    // A theme may not have a package, but it doesn't matter here because
    // we use it only as an argument.
    $themePackage = $themeObject->getPackageHandle();
    $templatePath = $env->getPath(
                      DIRNAME_THEMES . '/' . $themeHandle . '/'
                      . $templateFile, $themePackage );
    if ( is_readable( $templatePath ) ) {
      $this->setViewTemplate( $templatePath );
    }
  }

  /**
   * Runs before render starts. Triggers the 'on_start' event.
   */
  public function startRender() {
    $event = new \Symfony\Component\EventDispatcher\GenericEvent();
    $event->setArgument( 'view', $this );
    Events::dispatch( 'on_start', $event );
    parent::startRender();
  }

  /**
   * Render the template files that have been set for the view.
   *
   * @param   array   $scopeItems  Variables to be used in the templates
   * @return  string               The rendered contents
   */
  public function renderViewContents( $scopeItems ) {
    extract( $scopeItems );
    if ( $this->innerContentFile ) {
      ob_start();
      include $this->innerContentFile;
      $innerContent = ob_get_contents();
      ob_end_clean();
    }

    if ( file_exists( $this->template ) ) {
      ob_start();
      $this->onBeforeGetContents();
      include $this->template;
      $contents = ob_get_contents();
      $this->onAfterGetContents();
      ob_end_clean();
      return $contents;
    } else {
      return $innerContent;
    }
  }

  /**
   * Runs before the theme template is rendered. Triggers 'on_before_render'
   * event.
   */
  protected function onBeforeGetContents() {
    $event = new \Symfony\Component\EventDispatcher\GenericEvent();
    $event->setArgument( 'view', $this );
    Events::dispatch( 'on_before_render', $event );
  }

  /**
   * Runs after the theme template is rendered. Triggers 'on_after_render'
   * event.
   */
  protected function onAfterGetContents() {
    $event = new \Symfony\Component\EventDispatcher\GenericEvent();
    $event->setArgument( 'view', $this );
    Events::dispatch( 'on_after_render', $event );
  }

  /**
   * Post-process the view content after it has been rendered, ie.
   * after all the templates have been applied.
   *
   * @param   string $contents
   * @return  string
   */
  public function postProcessViewContents( $contents ) {
    return $contents;
  }

  /**
   * Runs after rendering has finished and right before the rendered content
   * is returned from the render() method. Triggers 'on_render_complete'
   * event.
   *
   * @param   type $contents
   * @return  type
   */
  public function finishRender( $contents ) {
    $event = new \Symfony\Component\EventDispatcher\GenericEvent();
    $event->setArgument( 'view', $this );
    Events::dispatch( 'on_render_complete', $event );
    return $contents;
  }
}

After you save that file everything should be set up and our “RSS feed” should be displayed correctly and interpreted by the browser as such.

Some details

Most of the methods are called from the render() method of the abstract parent class, and individually they are pretty simple. How they all fit together may not be that obvious, so let’s take a look at the rendering process in more detail.

The rendering process

The rendering process starts with a call to the AbstractView::render( $state ) method, where – at least in our case – the $state is the name of the inner content file. Here’s what the method looks like:

<?php
// src/View/AbstractView.php
public function render($state = false)
{
    if ($this instanceof View) {
        $this->setRequestInstance($this);
    }
    $this->start($state);
    $this->setupRender();
    $this->startRender();
    $scopeItems = $this->getScopeItems();
    $contents = $this->renderViewContents($scopeItems);
    $contents = $this->postProcessViewContents($contents);
    $response = $this->finishRender($contents);
    if ($this instanceof View) {
        $this->revertRequestInstance();
    }

    return $response;
}

Let’s go through those lines one by one.

  1. setRequestInstance: Adds this view to the stack of current request (read: view) instances. As far as I can tell this list is not actually used for anything, but you could use it to check which view is currently being rendered, if you have a need for that sort of a thing.
  2. start( $state ): The first actual rendering method. This is implemented only in a couple of places in the C5 core files, and in those the $state refers to the view file used for rendering. See src/Attribute/View.php or src/Block/View/BlockView.php for an example. We implement it too so we can use the render() method to set the view file and save a whole line of code.
  3. setupRender(): Used to setup the view and associated resources so that everything is ready for rendering. We use it to find the correct template files.
  4. startRender(): We use it only for triggering the on_start event.
  5. getScopeItems(): Get the variables set in the controller (known as the sets).
  6. renderViewContents( $scopeItems ): The actual rendering method, in which the variables ($scopeItems) are extracted to the current scope and the inner content and the theme template files are included and captured using the traditional output buffering. During the theme template rendering, two additional methods are called:
    1. onBeforeGetContents(): Triggers the on\_before\_render event before the theme template is rendered (= included).
    2. onAfterGetContents(): Triggers the on\_after\_render event after the theme template has been rendered (= included).
  7. postProcessViewContents( $contents ): Can be used for additional post-processing. In the core View this method is used to add the scripts, CSS files etc. to the rendered HTML page.
  8. finishRender( $contents ): Triggers the on\_render\_complete event.
  9. revertRequestInstance(): Removes this view from the stack of request instances.

Once those have been run through, the render() method returns the finished content and the control moves back to the Page Controller.

setupRender(), or How Concrete5.7 finds files

In the setupRender() method we use the Environment::getPath() method to find the correct files. That method first compares a given file against a cached list containing all the application overrides. If the file is not found in the overrides and a package handle is given, it then checks inside the packages directory. If that fails, it checks if the file is in the core files. Failing that too, it finally returns null.

DIRNAME_VIEWS is in most installations simply “views”, and DIRNAME_THEMES is “themes”. So in our case DIRNAME_VIEWS . '/' . $contentFile is simply views/rss.php and DIRNAME_THEMES . '/' . $themeHandle . '/' . $templateFile equals to themes/[current theme]/feed.php.

Note In our case the setup method is quite simple because we make a lot of assumptions where the files might be. The core View class does a lot more work, because it needs to take into account all the possible variations where templates can reside. If you want a more robust setup method, you might want to take a look at the `setupRender()` method in `src/View/View.php`.

What about PageController::render()?

In some examples custom templates are rendered using the controller’s render() method, like so:

<?php
public function view_album($albumID = null)
{
  $selectedAlbum = \Foo\Media\Album::getByID($albumID);
  $this->set('albumID', $albumID);
  $this->set('album', $selectedAlbum);
  $this->render('/media/albums');
}

What this actually does is it calls the view’s renderSinglePageByFilename() method. That method, despite its name, doesn’t actually render anything. It only sets the inner content file (/media/albums in the example) and the template file to the view. The actual rendering is done later after the controller’s action method is executed, so using the controller’s render() method won’t yield any actual content.

What about View::render()?

The problem with View::render() and PageView::render() is that they both expect to be working with an HTML page or similar. This means that they will register assets and postprocess the content, neither of which is needed. While the asset registering and postprocessing won’t change the way our output is rendered, I feel it’s still unnecessary overhead.

These methods do trigger the appropriate events, like on_before_render and on_after_render, which is nice, but adding those to a custom view is no big deal.

Comments

Commenting has been disabled until I get a proper spam protection working. =(

External Links

Back to beginning