Experimenting With Dynamic Template Rendering In Angular 2 RC 1
Before we dive in, I just want to clearly state that this is an experiment. I am still very much learning about how view containers and templates works. And, I have to give a huge Thank You to Pawel Kozlowski who painstakingly helped me wrap my head around the hierarchy of view containers in an Angular 2 application. It may be "simple"; but, it's not necessarily straightforward, especially coming from an Angular 1.x transclusion context.
That said, one thing I really wanted to be able to do in Angular 2 was pass-in a dynamic template - or a "Renderer" if you will - into another component. So, if you had a component that, among other things, rendered an "ngFor" internally, the calling context would be able to provide the implementation details for the body of said ngFor repeater. After my conversation with Pawel, and much trial and error, I think I have a proof-of-concept working.
NOTE: The ngFor directive can actually accept an external TemplateRef; but, that wasn't the point of this experiment. This was about template rendering mechanics - not an exploration of the ngFor directive.
Run this demo in my JavaScript Demos project on GitHub.
To explore this concept, I created a DynamicRepeater component. This component has a header, a footer, and a body. The header and footer are static; but, the body is composed of an ngFor directive that iterates over the component's [items] property. The primary template for the ngFor is provided by the DynamicRepeater; but, the internal content of the ngFor needs to be provided by the calling context in the form of a "<template>" tag.
To see how this is used, let's take a look at the AppComponent. In the following code, notice that the we're nesting a template element inside of the "dynamic-repeater" element. I am "tagging" this template with an "#itemRenderer" handle so that the DynamicRepeaterComponent can query for it as a ContentChild.
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { DynamicRepeaterComponent } from "./dynamic-repeater.component";
@Component({
selector: "my-app",
directives: [ DynamicRepeaterComponent ],
// In this view, we're passing a dynamic TemplateRef to the DynamicRepeater
// component. We're not passing it in like a property; rather, we're "tagging" it
// with the "#itemRenderer" handle. Then, the DynamicRepeater is going to query its
// content (via ContentChild) for the template reference. When this TemplateRef is
// "stamped out", it will make several local view variables available:
// --
// * index
// * item
// --
// Here, you can see that the template is hooking into those variables using the
// "let" syntax, ex. "let-color=item".
template:
`
<dynamic-repeater [items]="colors">
<template #itemRenderer let-color="item" let-index="index">
<div title="Item {{ index }}" class="swatch" [style.backgroundColor]="color.hex">
<br />
</div>
<div class="name">
{{ color.name }}
</div>
</template>
</dynamic-repeater>
`
})
export class AppComponent {
// I hold the collection of colors that will be rendered by the DynamicRepeater.
public colors: any[];
// I initialize the component.
constructor() {
this.colors = [
{
hex: "#E50000",
name: "Red"
},
{
hex: "#FF028D",
name: "Hot Pink"
},
{
hex: "#FF81C0",
name: "Pink"
},
{
hex: "#FFD1DF",
name: "Light Pink"
},
{
hex: "#FFB07C",
name: "Peach"
},
{
hex: "#FF796C",
name: "Salmon"
}
];
}
}
When the DynamicRepeater clones the template inside the internal ngFor loop, it will make several local view variables available: index and item. Our #itemRenderer template can then leverage those view-local variables by using the "let" syntax. In this case, you can see that we're mapping "item" to "color" and then referencing "color" within our template body.
Internally, the DynamicRepeater queries for the ContentChild, "#itemRenderer" and then passes it into a "template renderer" inside of its own ngFor loop. This "template renderer" is a custom directive that accepts both a TemplateRef and a context and then clones the given TemplateRef into the current ViewContainerRef.
In the following DynamicRepeater component, you'll notice that I'm actually using a function - createTemplateRenderer() - to provide the template renderer directive to the DynamicRepeater view. This function dynamically generates a template renderer that exposes the given properties as sub-properties off of the "context". So, while the template renderer can accept a plain [context] object input, this factory function allows you to create inputs that are more intuitively name-spaced, like [context.item] and [context.index].
// Import the core angular services.
import { Component } from "@angular/core";
import { ContentChild } from "@angular/core";
import { TemplateRef } from "@angular/core";
// Import the application components and services.
import { createTemplateRenderer } from "./template-renderer.directive";
@Component({
selector: "dynamic-repeater",
inputs: [ "items" ],
// Here, we are querying for the <template> tags in the content.
queries: {
itemTemplateRef: new ContentChild( "itemRenderer" )
},
// We're going to provide a dynamically-generated directive that exposes custom
// inputs that we want to pass to our item renderer. In this case, we want to
// expose "context.item" and "context.index". This will return a directive with
// the selector, "template[render]", which are using in our view.
directives: [
createTemplateRenderer( "item", "index" )
],
template:
`
<header>
<h2>
Dynamic Repeater View
</h2>
</header>
<dynamic-repeater-body>
<dynamic-repeater-item *ngFor="let item of items; let index = index ;">
<template
[render]="itemTemplateRef"
[context.item]="item"
[context.index]="index">
</template>
</dynamic-repeater-item>
</dynamic-repeater-body>
<footer>
<p>
You have {{ items?.length }} item(s) being rendered.
</p>
</footer>
`
})
export class DynamicRepeaterComponent {
// I hold the items to render in our repeater.
// --
// NOTE: Injected property.
public items: any[];
// I hold the template used to render the item.
// --
// NOTE: Injected query.
public itemTemplateRef: TemplateRef<any>;
// I initialize the component.
constructor() {
this.items = [];
this.itemTemplateRef = null;
}
}
Now, let's look at the dynamically generated template renderer. Once you get past the "factory" nature of it, the logic of the component is actually quite simple. It injects the ViewContainerRef and then clones the TemplateRef input using the context input. Remember, in this particular case, the "context" contains the individually-bound "index" and "item" inputs from the DynamicRepeater component.
// Import the core angular services.
import{ Directive } from "@angular/core";
import{ OnInit } from "@angular/core";
import{ TemplateRef } from "@angular/core";
import{ ViewContainerRef } from "@angular/core";
// I generate Class definitions that exposes custom sub-properties off the "context"
// namespace. This class always exposes:
// --
// * render (aliased as "template")
// * context
// --
// ... however, you can additionally provide other sub-properties of "conext" to make
// the binding syntax easier to read.
export function createTemplateRenderer( ...propertyNames: string[] ) {
// Let's convert the incoming sub-property names into namespaced inputs off the
// "context" object. For example, convert "foo" into "context.foo".
var contextProperties = propertyNames.map(
function operator( propertyName: string ) : string {
return( "context." + propertyName );
}
);
@Directive({
selector: "template[render]",
inputs: [ "template: render", "context", ...contextProperties ]
})
class TemplateRendererDirective implements OnInit {
// I hold the context that will be exposed to the embedded view.
// --
// NOTE: The context is an injectable input. However, it's sub-properties are
// also individually injectable properties based on the arguments passed to the
// factory function.
public context: any;
// I hold the TemplateRef that we are cloning into the view container.
public template: TemplateRef<any>;
// I hold the view container into which we are injecting the cloned template.
public viewContainerRef: ViewContainerRef;
// I initialize the directive.
constructor( viewContainerRef: ViewContainerRef ) {
this.context = {};
this.viewContainerRef = viewContainerRef;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once, when the class is initialized, after the inputs have been
// bound for the first time.
public ngOnInit() : void {
if ( this.template && this.context ) {
this.viewContainerRef.createEmbeddedView( this.template, this.context );
}
}
}
// Return the dynamically generated class.
return( TemplateRendererDirective );
}
When all is said and done, when we run this code, we get the following page output:
As you can see, the template body, provided by the AppComponent, was used to render the inner content of each "dynamic-repeater-item" within the DynamicRepeaterComponent's ngFor loop. Hella sweet!
This was just an experiment; but, I think this taps into a real need in the Angular 2 ecosystem. For static elements, projecting nodes with "ng-content" is great. But, when the "projection" of said content needs to be more dynamic, we need a way to provide custom renderers. And, while this experiment might not be perfect, I think it's definitely heading in the right direction.
Want to use code from this post? Check out the license.
Reader Comments
HI,
Great read, as usual.
Currently (RC1) you need the dynamic template render directive that you built in order to get this done.
I created a PR in the angular repo to allow injecting context into the NgTemplateOutlet directive so you can do this with less code and mostly use HTMl syntax to get it done.
https://github.com/angular/angular/pull/9042
I hope it will get into RC2.
It should be something like
<template [ngTemplateOutlet]="templateRef" [ngOutletContext]="context"></template>
The templateRef can be a local variable (#) and the context can sit on the component instance.
It also supports context rebinding.
@Shlomi,
Whoa whoa, I didn't even know there was a `NgTemplateOutlet` directive :D Dang, this stuff changes so fast, it's so hard to keep up. Ugggg. So frustrating :D
So, just that I'm clear, your PR is to add the "context" portion to the NgTemplateOutput so that we can actually bind to specific values in the template (that's exposed directly by the calling component)?
When I was experimenting with this stuff, that's the approach I tried at first. But, I ended up having something that looked like this:
```
<div *ngFor="let item of values ; let index = index>
<template [render]="tr" context="{ item: item, index: index }"></template>
</div>
```
... and something about the "inline object" made me feel a bit uncomfortable. I liked the idea of being able to explicitly bind to specific sub-properties of the context object. But, just personal preference.
Awesome stuff though, thanks for linking me to the PR.
@All,
As a quick follow-up to this post, I wanted to specifically look at how / if TemplateRefs maintain lexical binding when passed out-of-scope:
www.bennadel.com/blog/3102-templates-appear-to-maintain-lexical-bindings-in-angular-2-rc-1.htm
It appears as though they do, which is super important.
@Ben,
Let me just drop this here:
https://github.com/shlomiassaf/angular/blob/b6d5a065c866ac6f44d8fd49d614508cce317351/modules/%40angular/common/src/directives/ng_template_outlet.ts
Now, look at your TemplateRendererDirective, funny ha :)
You just implemented it right :)
Great post, tx for sharing....
@Shlomi,
I didn't think to destroy an existing view when the inputs change. But, then again, that's also because I am rendering in the ngOnInit() method, which will only ever be called once. I never considered that the selected template or that the template's context would change. But, I guess that makes sense.
@Sean,
Always a pleasure :D
Thanks ben for that great tutorial,
I'm creating a custom grid component its something like table and I want to pass a dynamic html element , but it doesn't work for me .
Can you help me with that?
Do you use proxies to allow the destruction of the elements, new calls on factories to rebuild objects tied to a view and the related elements, and thereby garner a mechanism for archiving and recreating user state without maintaining the state actively or reporting it
You just return the string fragments on the tagged templates to last state on that "page" 1/4 page, w/e
I have been working on above about a month
Integrating proxies and a querying tag system that is more efficient than "destroy that whole part of page a rebuild it
Although I can destoy whole page with every pixel move for resizing and it's still not visible yet
But as I run more background tasks etc I am seeing some of the more difficult architecture issues
I am really just asking if there is a model for this kind of shard tempkating. It's really cool and really fast and is similar to your work
Thanks in advance
Jason
in case the template content is to be a result of a function call .. then?
Nice tutorial Ben, thank you for sharing.
But it still updated? ... I finding a error on dynamic-repeater.component.ts.
directives: [
createTemplateRenderer( "item", "index" )
],
cant declare directives on component, I believe that change along the way... How do I pass it around?
Thank in advance, Cardoso
I have implemented this example in our solution and it was working fine. Thank you very much.
But I have just upgraded to Angular2.4.3 and Angular-material 2.0.0-Beta.1.
It generate problem while running the it.
i.e. "zone.js:422 TypeError: Cannot read property 'context' of undefined
at Function.Wrapper_TemplateRendererDirective.check_context.item (wrapper.ngfactory.js:36)
at View_GridComponent20.detectChangesInternal (component.ngfactory.js:1256)
at View_GridComponent20.AppView.detectChanges (core.umd.js:12583)
at ViewContainer.detectChangesInNestedViews (core.umd.js:12841)
at View_GridComponent17.detectChangesInternal (component.ngfactory.js:1352)
at View_GridComponent17.AppView.detectChanges (core.umd.js:12583)
at ViewContainer.detectChangesInNestedViews (core.umd.js:12841)
at CompiledTemplate.proxyViewClass.View_GridComponent0.detectChangesInternal (component.ngfactory.js:2619)
at CompiledTemplate.proxyViewClass.AppView.detectChanges (core.umd.js:12583)
at CompiledTemplate.proxyViewClass.AppView.internalDetectChanges (core.umd.js:12568)
at View_ManageCalendarOptionsComponent1.detectChangesInternal (component.ngfactory.js:437)
at View_ManageCalendarOptionsComponent1.AppView.detectChanges (core.umd.js:12583)
at ViewContainer.detectChangesInNestedViews (core.umd.js:12841)
at CompiledTemplate.proxyViewClass.View_MyComponent0.detectChangesInternal (component.ngfactory.js:912)
at CompiledTemplate.proxyViewClass.AppView.detectChanges (core.umd.js:12583)
Can you please help us.