Skip to main content
Ben Nadel at Angular 2 Master Class (New York, NY) with: Pascal Precht
Ben Nadel at Angular 2 Master Class (New York, NY) with: Pascal Precht

Creating An Index Loop Structural Directive In Angular 2 Beta 14

By
Published in Comments (3)

Angular 2 has three different kinds of directives: Component directives, Attribute directives, and Structural directives. For the most part, component and attribute directives are fairly straightforward. In fact, they're basically the same thing, only attribute directives don't have views. Structural directives, on the other hand, are a bit more complex. And, if I'm being honest, I found the syntactic sugar around structural directives to be quite intimidating. However, once I started to dig into them, I discovered that they were a lot less complex that I had first thought.

Run this demo in my JavaScript Demos project on GitHub.

To start learning about structural directives, in Angular 2 Beta 14, I wanted to create an index loop directive. I imagined this loop directive working much like the native ngFor directive; only, it would iterate over a range of values rather than a collection. I wanted it to look something like this:

<span *bnLoop="#i from 1 to 10 step 2 ; #isLast = last ;">{{ i }}</span>

As you can probably guess, this would loop from 1 to 10 using an increment of 2. For each iteration, the directive would store the current iteration value in the view variable, "i", which you can see I am using in the element content. Furthermore, it's also binding to another view-local value, "last", which indicates whether or not the current iteration is the last iteration.

When all you've done is work with basic attribute directives, this complex bnLoop attribute expression looks menacing! But, once you understand what it's doing, it's actually quite cool and easy to think about.

The "*" notation, before the bnLoop attribute, is a special syntactic sugar that compiles down to a more verbose version of the directive that uses the Template element. And, the verbose version, while longer, is much closer to the standard attribute directives that we are used to working with.

To see what I mean, let's look at how the above "*bnLoop" compiles down into its Template equivalent:

How Angular 2 compiles structural directive syntax sugar down into a Template element.

As you can see, Angular is parsing the expression inside the bnLoop attribute into a set of key-value pairs. Those key-value pairs are then used to setup the input bindings on the generated Template element. As it does this transformation, it prepends each key with the name of the directive such that:

  • from becomes [bnLoopFrom]
  • to becomes [bnLoopTo]
  • step becomes [bnLoopStep]

The two "#" expressions become view-local variables. Notice, however, that one of them is a stand-alone token - #i - and one of them is a key-value pair - #isLast=last. The stand-alone token becomes the "implicit" view-local variable. Internally to our directive implementation, we can treat both of these tokens in same way; only, one of them is named "last" and one of them is named "$implicit".

You can see this clearly if put a break-point in our Chrome dev-tools and look at the parsed template bindings:

Structural bindings parsed from syntax sugar for bnLoop.

Here, you can clearly see how Angular 2 Beta 14 is parsing and transforming the original *bnLoop attribute expression.

As a quick aside, not all *-based directive expressions are parsed into a tuple of key-value pairs. *ngIf, for example, just compiles down to a Template element that has an [ngIf] input binding. But, we can see that both directives go through the same binding parser:

Structural bindings parsed from syntax sugar for ngIf.

I'll admit that I don't fully understand the breadth of expression parsing in the Angular 2 compiler. Not even close. So, the above is what I can deduce from the Chrome dev-tools and from some experimentation.

That said, let's take look at how I implemented the *bnLoop directive. This directive takes the following input bindings:

  • [bnLoopFrom] - The lower-bound of the range.
  • [bnLoopTo] - The upper-bound of the range.
  • [bnLoopStep] - The increment for each iteration. Optional, defaults to 1 or -1.

And, it makes the following view-local variables available to each template clone:

  • $implicit - The current iteration value (stored in #i in my example).
  • first - Determine if current iteration is the first iteration.
  • last - Determines if current iteration is the last iteration.
  • middle - Determines if the current iteration is a middle iteration.
  • even - Determines if the current iteration value (#i in my example) is even.
  • odd - Determines if the current iteration value (#i in my example) is odd.

Now, inputs and view-local variable bindings are only the meta-data around the directive. When we implement the directive, we actually have to clone the template in order to make the magic happen. To do this, we have to inject the ViewContainerRef and the TemplateRef implementations into our structural directive. The ViewContainerRef takes care of actually inserting and removing our clones. And, the TemplateRef is the template we tell it to clone.

Ok, let's look at the actual code. In this demo, I have used the *bnLoop directive three times: one to provide lower-bound options; one to provide upper-bound options; and, one to implement the loop from the selected lower-bound to the selected upper-bound. As you will see, the lower-bound doesn't have to be less than the upper-bound - the bnLoop directive can move in either direction:

<!doctype html>
<html>
<head>
	<meta charset="utf-8" />

	<title>
		Creating An Index Loop Structural Directive In Angular 2 Beta 14
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></lin>
</head>
<body>

	<h1>
		Creating An Index Loop Structural Directive In Angular 2 Beta 14
	</h1>

	<my-app>
		Loading...
	</my-app>

	<!-- Load demo scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/es6-shim.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/Rx.umd.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-polyfills.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-all.umd.js"></script>
	<!-- AlmondJS - minimal implementation of RequireJS. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/almond.js"></script>
	<script type="text/javascript">

		// Defer bootstrapping until all of the components have been declared.
		requirejs(
			[ /* Using require() for better readability. */ ],
			function run() {

				ng.platform.browser.bootstrap( require( "App" ) );

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I provide the root application component.
		define(
			"App",
			function registerApp() {

				// Configure the App component definition.
				ng.core
					.Component({
						selector: "my-app",
						directives: [
							require( "Loop" ),
							require( "LogLifeCycle")
						],
						template:
						`
							<p>
								<strong>From ( {{ lowerBound }} ):</strong>

								<!-- Using the sweet sweet syntax sugar. -->
								<a *bnLoop="#i from 1 to 20 step 1" (click)="setFrom( i )">{{ i }}</a>
							</p>

							<p>
								<strong>To ( {{ upperBound }} ):</strong>

								<!-- Using an explicit Template. -->
								<template bnLoop #i [bnLoopFrom]="1" [bnLoopTo]="20" [bnLoopStep]="2">

									<a (click)="setTo( i )">{{ i }}</a>

								</template>
							</p>

							<hr />

							<p
								*bnLoop="#i from lowerBound to upperBound step increment ; #first = first ; #last = last ;"
								[lifeCycleLabel]="i">

								{{ i }}
								<span *ngIf="first">- First</span>
								<span *ngIf="last">- Last</span>

							</p>
						`
					})
					.Class({
						constructor: AppController
					})
				;

				return( AppController );


				// I control the App component.
				function AppController() {

					var vm = this;

					// I hold the range configuration for the loop.
					vm.lowerBound = 1;
					vm.upperBound = 10;
					vm.increment = 1;

					// Expose the public methods.
					vm.setFrom = setFrom;
					vm.setTo = setTo;


					// ---
					// PUBLIC METHODS.
					// ---


					// I set the lower bound of the range (inclusive).
					function setFrom( newFrom ) {

						vm.lowerBound = newFrom;
						vm.increment = calculateIncrement( vm.lowerBound, vm.upperBound );

					}


					// I set the upper bound of the range (inclusive).
					function setTo( newTo ) {

						vm.upperBound = newTo;
						vm.increment = calculateIncrement( vm.lowerBound, vm.upperBound );

					}


					// ---
					// PRIVATE METHODS.
					// ---


					// I calculate an increment value that makes the most sense for the
					// given range. We want an increment that won't lead to an infinite
					// series of values.
					function calculateIncrement( from, to ) {

						return( ( from <= to ) ? 1 : -1 );

					}

				}

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I provide a structure directive that creates elements over an inclusive range.
		// The range can be ascending or descending with an optional step (that defaults
		// to 1 or -1 if not provided). For each iteration, the following local variables
		// are exposed:
		// --
		// * first
		// * last
		// * middle
		// * even
		// * odd
		define(
			"Loop",
			function registerLoop() {

				// Configure the Loop directive definition.
				// --
				// NOTE: Add Chrome developer-tools break-point to "return bindings;"
				// to see how these are being parsed out of the template syntax tree.
				ng.core
					.Directive({
						selector: "[bnLoop]",

						// Because we are using the "*" template syntax, our expression
						// is parsed into a number of individual inputs that are all
						// prefixed with the directive name. So, given the expression:
						// --
						// #i from 1 to 10 step 1
						// --
						// ... we are given the following inputs:
						// --
						// bnLoopFrom ( parsed "from" sub-attribute )
						// bnLoopTo ( parsed "to" sub-attribute )
						// bnLoopStep ( parsed "step" sub-attribute )
						// --
						// NOTE: The "#i" portion is an "output", so to speak, that we
						// have to set on each instance of the template that we clone.
						inputs: [ "from: bnLoopFrom", "to: bnLoopTo", "step: bnLoopStep" ]
					})
					.Class({
						constructor: LoopController,

						// Define the
						ngOnChanges: function noop() {}
					})
				;

				LoopController.parameters = [
					new ng.core.Inject( ng.core.ViewContainerRef ),
					new ng.core.Inject( ng.core.TemplateRef )
				];

				return( LoopController );


				// I control the Loop directive.
				function LoopController( viewContainerRef, templateRef ) {

					var vm = this;

					// I hold the actual range values for the loop iteration.
					var renderedRange = [];

					// I hold the metadata about each rendered view in the range. This
					// object is keyed on the range indices.
					var renderedViews = {};

					// Expose the public methods.
					vm.ngOnChanges = ngOnChanges;


					// ---
					// PUBLIC METHODS.
					// ---


					// I get called anyone the bound input values change.
					function ngOnChanges( changes ) {

						// Test the range values.
						if ( ! isNumeric( vm.from ) || ! isNumeric( vm.to ) ) {

							throw( new Error( "bnLoop requires numeric From and To values." ) );

						}

						// Default the step value.
						if ( ! vm.hasOwnProperty( "step" ) ) {

							vm.step = 1;

						}

						// Test the step value.
						if ( ! isNumeric( vm.step ) ) {

							throw( new Error( "bnLoop requires a numeric Step value." ) );

						}

						// Test the range values against the step value - we need to
						// ensure the step value won't create an infinite range.
						if (
							( ( vm.from < vm.to ) && ( vm.step < 0 ) ) ||
							( ( vm.from > vm.to ) && ( vm.step > 0 ) )
							) {

							throw( new Error( "bnLoop Step will cause infinite loop." ) );

						}

						var range = generateRange( vm.from, vm.to, vm.step );
						var views = {};

						// First, let's populate the view metadata for the new range. This
						// step will pick up existing viewRef instances created by previous
						// ranges; but, it will not create new viewRef clones.
						for ( var domIndex = 0, domLength = range.length ; domIndex < domLength ; domIndex++ ) {

							var i = range[ domIndex ];

							var existingViewRef = ( renderedViews[ i ] && renderedViews[ i ].viewRef );

							views[ i ] = {
								index: i,
								viewRef: ( existingViewRef || null ),
								first: ( domIndex === 0 ),
								last: ( domIndex === ( domLength - 1 ) ),
								middle: ( ( domIndex !== 0 ) && ( domIndex !== ( domLength - 1 ) ) ),
								even: ! ( i % 2 ),
								odd: ( i % 2 )
							};

						}

						// Next, let's delete the views that are no longer relevant in
						// new range.
						for ( var domIndex = 0 ; domIndex < renderedRange.length ; domIndex++ ) {

							var i = renderedRange[ domIndex ];

							if ( ! views.hasOwnProperty( i ) ) {

								viewContainerRef.remove( viewContainerRef.indexOf( renderedViews[ i ].viewRef ) );

							}

						}

						// Finally, let's update existing views and render any new ones.
						for ( var domIndex = 0 ; domIndex < range.length ; domIndex++ ) {

							var i = range[ domIndex ];
							var view = views[ i ];

							// If this view didn't pull an existing viewRef from the
							// previous range, let's create a new clone.
							if ( ! view.viewRef ) {

								view.viewRef = viewContainerRef.createEmbeddedView( templateRef, domIndex );

							}

							// Set up all the local variable bindings.
							// --
							// NOTE: The "$implicit" variable is the first #var in the
							// template syntax.
							view.viewRef.setLocal( "$implicit", i );
							view.viewRef.setLocal( "first", view.first );
							view.viewRef.setLocal( "last", view.last );
							view.viewRef.setLocal( "middle", view.middle );
							view.viewRef.setLocal( "even", view.even );
							view.viewRef.setLocal( "odd", view.odd );

						}

						// Store the new range configuration for comparison in the next
						// change event.
						renderedRange = range;
						renderedViews = views;

					}


					// ---
					// PRIVATE METHODS.
					// ---


					// I generate the range of indices that will be used in the given
					// loop configuration.
					// --
					// NOTE: The resultant array should be rendered in top-down order,
					// regardless of whether the range is incrementing or decrementing.
					function generateRange( from, to, step ) {

						var range = [];

						// Incrementing range.
						if ( from <= to ) {

							for ( var i = from ; i <= to ; i += step ) {

								range.push( i );

							}

						// Decrementing range.
						} else {

							for ( var i = from ; i >= to ; i += step ) {

								range.push( i );

							}

						}

						return( range );

					}


					// I determine if the given value is a number.
					function isNumeric( value ) {

						return( Object.prototype.toString.call( value ) == "[object Number]" );

					}

				}

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I provide a directive that logs the initialization and destruction of the
		// context element, using the given label in the logging statement.
		define(
			"LogLifeCycle",
			function registerLogLifeCycle() {

				// Configure the LogLifeCycle directive definition.
				return ng.core
					.Directive({
						selector: "[lifeCycleLabel]",
						inputs: [ "label: lifeCycleLabel" ]
					})
					.Class({
						constructor: function LogLifeCycle() { /* No-op. */ },


						// I get called once when the directive is destroyed.
						ngOnDestroy: function() {

							console.warn( "Destroyed:", this.label );

						},


						// I get called once when the directive is initialized.
						ngOnInit: function() {

							console.info( "Created:", this.label );

						}
					})
				;

			}
		);

	</script>

</body>
</html>

By default, we start with the range 1...10. However, if I change the range from 14...10, you will see that the directive can move in either direction. And, to transform the iteration, it has to destroy a number of instances and then create a few new ones:

bnLoop structural directive in Angular 2 Beta 14.

Pretty exciting stuff! Much loops! So iteration! Very increment!

Once we see that the "*" syntax sugar just compiles down to a set of input bindings on the Template element, all we have to do is observe the changes for those input bindings using the ngOnChanges life-cycle event handler. Then, we either have to clone the Template or destroy an instance of the Template in response to those changes. Depending on your use-case, this can be very simple or very complex.

When I first saw the "*" syntax sugar for structural directives in Angular 2 Beta 14, I was more than a little bit intimidated. But, once I understood how Angular was transforming the expressions into input bindings, structural directives started to feel much more like regular attribute directives. Hopefully, this example makes you feel more comfortable as well.

Want to use code from this post? Check out the license.

Reader Comments

15,902 Comments

@All,

Just a quick note that .setLocal() no longer exists in the NG2 Release Candidate. Now, when you create a dynamic view, you pass in a context object that contains the local variables.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel