Providing Custom View Templates For Components In Angular 2 Beta 6
This morning, I read a rather interesting post by Michael Bromley on providing components with custom templates in Angular 2. In his approach, he used the "*" micro syntax to inject and then stamp-out a view in much the same way the *ngFor directive works. His way got the job done; but, it inspired me to think about the problem from another perspective. One of the most awesome aspects of Angular 2 is the ability to get a reference to an instantiated component using a view-local variable. With this in mind, I wondered if I could achieve the same result, but in reverse. Meaning, rather than providing a view externally, what if I could just consume the public API of the component and use the public properties and methods to render custom component content.
Run this demo in my JavaScript Demos project on GitHub.
In an Angular 2 view, you can use the "#var" syntax to reference an item within the view:
<element #myRef />
If the element in question is an HTML DOM (Document Object Model) node, "myRef" will refer directly to the DOM node. If, however, the element in question is an Angular 2 component, "myRef" will refer to the instantiated component. Which, in turn, means that myRef will expose all of the public properties and methods to the calling context.
Given this feature, rather than thinking about providing a custom view to the component, we can think about consuming the component's public API within the current view. Of course, there still needs to be some degree of cooperation between the component and the calling context since, by default, the content of the component will be replaced by the component's view. As such, we need the component to transclude the content into its own view using the ng-content directive.
Going back to Michael's example of a Timer, I've tried to duplicate the demo using this alternative approach. In the following code, you can see that the StopTimer component exposes the following public properties and methods:
- time
- timeString
- toggle()
- reset()
... but, that its view template does nothing more than transclude the component content (provided by the calling context). The public properties and methods are then consumed by the calling context to render a totally custom user interface (UI) for the StopTimer component:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Proving Custom View Templates For Components In Angular 2 Beta 6
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Proving Custom View Templates For Components In Angular 2 Beta 6
</h1>
<my-app>
Loading...
</my-app>
<!-- Load demo scripts. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/6/es6-shim.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/6/Rx.umd.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/6/angular2-polyfills.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/6/angular2-all.umd.js"></script>
<!-- AlmondJS - minimal implementation of RequireJS. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/6/almond.js"></script>
<script type="text/javascript">
// Defer bootstrapping until all of the components have been declared.
// --
// NOTE: Not all components have to be required here since they will be
// implicitly required by other components.
requirejs(
[ "AppComponent" ],
function run( AppComponent ) {
ng.platform.browser.bootstrap( AppComponent );
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the root App component.
define(
"AppComponent",
function registerAppComponent() {
var StopTimer = require( "StopTimer" );
// Configure the App component definition.
ng.core
.Component({
selector: "my-app",
directives: [ StopTimer ],
// The StopTimer component doesn't provide its own template.
// But, it does provide a public API. In order to consume that
// API, we have to store a VIEW-LOCAL reference to the component
// instance using the "#var" syntax. This will give us access
// to the public properties and pubic methods of the StopTimer
// instance, which we can use to render our own view.
template:
`
<stop-timer #timer>
<div class="time">
{{ timer.timeString }}
</div>
<div class="actions">
<a (click)="timer.toggle()" class="toggle">Toggle</a>
<a (click)="timer.reset()" class="reset">Reset</a>
</div>
</stop-timer>
`
})
.Class({
constructor: AppController
})
;
return( AppController );
// I control the App component.
function AppController() {
// ... noting to do here.
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the StopTimer component.
// --
// CAUTION: The StopTimer component doesn't actually provide any view of its
// own - it just exposes a public API that the calling context can use to build
// and render a component interface.
define(
"StopTimer",
function registerStopTimer() {
// Configure the StopTimer component definition.
ng.core
.Component({
selector: "stop-timer",
// Since the component isn't really providing its own interface,
// we're just going to transclude any view content that was
// provided by the calling context.
template:
`
<ng-content></ng-content>
`
})
.Class({
constructor: StopTimerController
})
;
return( StopTimerController );
// I control the StopTimer component.
function StopTimerController() {
var vm = this;
// I hold information about the running timer interval.
var interval = null;
var intervalDuration = 10;
// I hold the raw time value, in milliseconds.
vm.time = 0
// I hold the formatting time value, for display (00:00.000).
vm.timeString = formatTime( vm.time );
// Expose the public methods.
vm.reset = reset;
vm.toggle = toggle;
// ---
// PUBLIC METHODS.
// ---
// I start or stop the timer depending on its current state.
function toggle() {
// If the timer is running, stop it.
if ( interval ) {
interval = clearInterval( interval );
// Otherwise, if the timer is stopped, start it.
} else {
interval = setInterval( increment, intervalDuration );
}
}
// I reset the timer value.
// --
// NOTE: If the time is currently running, it will continue to run
// even as the value is reset.
function reset() {
vm.time = 0
vm.timeString = formatTime( vm.time );
}
// ---
// PRIVATE METHODS.
// ---
// I increment the internal time value of the timer.
function increment() {
vm.time += intervalDuration;
vm.timeString = formatTime( vm.time );
// CAUTION: We are keeping this super simple for the purposes
// of the demo. This approach is actually problematic for two
// reasons:
// --
// 1. We can't guarantee that interval actually executes with
// the current duration.
// 2. The browser may actually stop the interval while the page
// isn't focused.
}
// I take the given time in milliseconds and return it as an
// easy-to-read time string with a breakdown of minutes, seconds,
// and milliseconds.
function formatTime( timeInMilliseconds ) {
var milliseconds = ( timeInMilliseconds % 1000 );
var seconds = ( Math.floor( timeInMilliseconds / 1000 ) % 60 );
var minutes = ( Math.floor( timeInMilliseconds / 60000 ) % 60 );
return(
padTimeSlot( minutes, 2 ) + ":" +
padTimeSlot( seconds, 2 ) + "." +
padTimeSlot( milliseconds, 3 )
);
}
// I ensure that the value is left-padded with enough zeros to
// ensure an output with the given length.
function padTimeSlot( value, length ) {
return( ( "00" + value ).slice( -length ) );
}
}
}
);
</script>
</body>
</html>
As you can see, the StopTimer component is really nothing more than an implementation of timer business logic. The entire view is driven by the calling context. And, when we run this code, we get the following page output:
Being able to reference a rendered / instantiated component, in Angular 2, using view-local variables is a really powerful feature and allows us to do some very interesting stuff, like consuming the API exposed by those rendered components. What would be a cool follow-up to this would be to see if we this kind of approach could be used conditionally. Meaning, with a component that provides a "default" view implementation if the calling context doesn't provide anything.
Want to use code from this post? Check out the license.
Reader Comments
ya I read his post and it is really nice as it explains the inner working of *ng- and detaching the detection.... but yes I agree for practicality ng-content is the correct approach...
Regards,
Sean
Ben! This is exactly the kind of feedback I was hoping for!
This method is a much better fit for what what I need. I'll update my article to include this approach too.
This is really cool, but I have one question - if you want to expose only the business logic, why not refactor stoptimer into a class and inject it into AppComponent's constructor with the same name "timer" (so that you even dont need to change the template from your example) ?
@John one reason I can think of is that your component may rely on component life cycle methods. Not sure how these interact (if they are used at all) when must injecting the class.
@Michael,
Awesome my man - glad you found this approach interesting. The ability to grab a reference to the instantiated component is really very cool. And, while I haven't done it yet myself, I'm pretty sure you can do something similar from within the component controller using "live collections".... but, only seen that in passing so far.
@John,
Really great question. Like Michael said, I think the life-cycle methods could be helpful. Especially if the timer were behind something like an *ngIf directive - the timer component could clean up after itself.
But, also, keep in mind that we just happened to provide an entirely custom UI. There could also be a component that only exposes *some* of the UI as an ng-content transclusion:
<div>
. . . . <h2>Ben's Awesome Sauce Timer</h2>
. . . . <ng-content></ng-content>
</div>
In this case, most of the UI is custom, but not all of it.
Of course, at the end of the day, Angular 2 is very very new and I think we're all just trying to figure out how to make sense of it all :P
Awesome stuff! both Michael & Ben!
With a little help from the IDE you'll have full intellisense support in the templates, I think this is coming in WebStrom.
Ben I took your follow-up suggestion and created a version with default/fallback support.
Here's my blog about it.
http://blog.assaf.co/custom-view-templates-for-components-in-angular-2/
It also contains a full plunker on the top.
Thanks again!
@Shlomi,
Very cool - will check it out.
Hey guys,
I wonder how I would provide a default view template but still make it "overwritable"? You know, to make the original component work on it's own an just optionally by able to replace the looks.
@Matthias,
Great question. And, I think it depends on the type of directive. For example, in a "structure directive", like ngFor, this is already possible because the directive is intended to deal with templates. So, with the ngFor directive, it will either use the template you implicitly provide in the DOM; or, it will use the template you provide via the [ngForTemplate] input property:
<template ngFor [ngForTemplate]="someTemplateRef"></template>
Now, "component directives," on the other hand don't deal with templateRef implicitly - they let Angular deal with the view and bind the component instance to the view instance. In that case, I think we could come up with a way to check for an explicit template internally. Imagine a View that looks like this:
<template [ngIf]=" ! customTemplate ">
.... normal view logic here ....
</template>
<template *ngIf=" customTemplate " [ngTemplateOutlet]="customTemplate"></template>
Here, if no input binding exists for "customTemplate", we would render the "normal view". But, if an input binding for "customTemplate" does exist, we're going to render that one instead.
I'll see if I can put together a demo for this (as I'm sure there are technical details I am missing).
Also, some of this syntax ^ is new in RC 2.
@All,
Also, if any one is curious, here's a little exploration in custom template rendering pre-RC2:
www.bennadel.com/blog/3101-experimenting-with-dynamic-template-rendering-in-angular-2-rc-1.htm
@Ben, @Matthias,
Re. custom templates - I have implemented this kind of solutions in a project where the user "template" is projected into the view with <ng-content>, and then queried at run-time to see if anything has been provided.
You can see the view code here: https://github.com/michaelbromley/ng2-pagination/blob/4bb9f12cff4086f2cb099b53c2fec1497e096eb6/src/template.ts#L7-L13
And the controller logic here: https://github.com/michaelbromley/ng2-pagination/blob/4bb9f12cff4086f2cb099b53c2fec1497e096eb6/src/pagination-controls-cmp.ts#L67-L69
Ben, I also checked out your other article when you published it - very cool, but I think I shall wait for friendlier APIs to land in Angular before I invest time building these features.
@Michael,
With RC 2, the custom template syntax is a little more full-featured now, including both a template reference and context:
<template [ngTemplateOutlet]="templateRef" [ngOutletContext]="{ ... }">
This allows you to render both the template and provide a custom view-local context object.
I don't think there's anything wrong with your approach. It's been a lot of fun to think about all of this stuff.
@Ben,
be great if we can have a new post on the new syntax:
<template [ngTemplateOutlet]="templateRef" [ngOutletContext]="{ ... }">