Recursive Angular Rendering of a Deeply Nested Travel Gallery
Join the DZone community and get the full member experience.
Join For FreeSuppose you like to travel and have collected a large photo gallery. The photos are stored in a tree folder structure, where locations are structured according to the geography and administrative division of a country:
The actual photos of particular places are stored in the corresponding leafs of the tree. Different branches of the tree may have different height. You want to show these photos in your portfolio website that is made on Angular. Also, the gallery should be easily extendible with new locations and photos.
The Problem
This problem statement is similar to that of my previous post. To show this gallery, we need two Angular libraries: angular-material-tabs and ivy-carousel or their equivalents. The tabs library provides a tree structure for the gallery locations, while the carousel shows the photos of a particular place.
Let's take a look at a simple tabs example:
xxxxxxxxxx
<mat-tab-group>
<mat-tab label="First"> Content 1 </mat-tab>
<mat-tab label="Second"> Content 2 </mat-tab>
<mat-tab label="Third"> Content 3 </mat-tab>
</mat-tab-group>
Here the tabs are grouped within the <mat-tab-group>
tag. The content of an individual tab is wrapped in the <mat-tab>
tag, while the tab title is the label value.
To add an extra level of tabs, one needs to insert a whole <mat-tab-group>
block into an appropriate <
. Clearly, such construct becomes very cumbersome and hard to extend as the gallery becomes sufficiently deep. So we need to automatically inject Angular template or component into the mat-tab
><
elements and automatically pass the appropriate title to the
>mat-tab
<mat-tab label="title">
element.
Angular carousel works as follows:
xxxxxxxxxx
<carousel>
<div class="carousel-cell">
<img src="path_to_image">
</div>
<div class="carousel-cell">
...
</carousel>
The carousel content is wrapped into the <carousel>
tag. Each individual image is wrapped into a <div class="carousel-cell">
tag. To show a large number of images in a tree folder structure, we need to automatically construct and provide a full folder path to every image into the src
field.
Let's see how to solve these problems in Angular.
The Solution
Firstly, there is a convenient data structure for this problem. Secondly, I demonstrate 3 separate approaches to render the data structure in Angular: a recursive template approach, a recursive component approach, and a recursive approach with a single mutable shareable stack. The first two approaches are inspired by the post by jaysermendez. The last approach doesn't duplicate data and illustrates the difference between the Angular and React change detection mechanisms. The code for all 3 approaches is here.
The Data Structure
Let's pack our data to the following data structure:
xxxxxxxxxx
data =[
{name:'Russia',
children:[{name:'Vladimir Area',
children:[
{name:'Vladimir',
children:[
{title:"Uspenskii Cathedral",
screen:"uspenski.JPG",
description:"The cathedral was built in 12th century by Duke Andrey Bogoliubov."},
{title:"Saints of Dmitrov Cathedral",
screen:"saints.JPG",
description:"Saints of Dmitrov cathedral. The cathedral was built in 12th century by Duke Andrey Bogoliubov."},
]
},
{name:'Bogoliubovo',
children:[{ },{ }]}
]
},
{name:'Moscow Area',
children:[ ]
}
]
},
{
name:'Spain',
children:[ ]
},
{ }
];
This recursive tree pattern has nodes:
xxxxxxxxxx
{ name:'Name1',
children:[
{name:'Sub Name1',children:[ ]}
]
}
and leafs:
xxxxxxxxxx
{ title:'Title1',
screen:'screen1.png',
description:'Description 1'
}
Angular code for this data structure can be generated in a top-down recursive manner. All the grammar, production rules, and compiler theory arguments from my previous post are applied here as well. The Angular specific thing is how to pass the proper folder paths to each template or component.
Approach 1: Recursive Template
For this approach we need a node and a leaf templates. For the details look at this post. I focus on how to pass folder paths to the templates. So, the node template is:
xxxxxxxxxx
<ng-template #treeView let-data="data" let-path="path" >
<mat-tab-group>
<mat-tab *ngFor="let item of data" label="{{item.name}}">
<div *ngIf="item[this.key]?.length > 0 && item[this.key][0]['name'];
else elseBlock">
<ng-container
*ngTemplateOutlet="treeView;
context:{ data: item[this.key] , path:path.concat(item.name)} ">
</ng-container>
</div>
<ng-template #elseBlock>
<ng-container *ngTemplateOutlet="leafView;
context:{data:item[this.key] ,leafPath:path.concat(item.name).join('/')} " >
</ng-container>
</ng-template>
</mat-tab >
</mat-tab-group>
</ng-template>
This template accepts data
(in the form described above) and path
as input parameters. The this.key="children"
is taken from the component; only 1 component is needed in this approach. At lines 4-5 the system determines if the child is a node, or a leaf. In the former case the system calls the treeView
template again with item['children']
at lines 6-9. In the later case calls the leafView
template with item['children']
at lines 11-15.
The leaf template is
xxxxxxxxxx
<ng-template #leafView let-data="data" let-path="leafPath" >
<carousel>
<div class="carousel-cell" *ngFor="let item of data">
<img [src]="'assets/images/'+path+'/'+item.screen">
</div>
</carousel>
</ng-template>
Notice how the path is passed to the template. Since every Angular template has its own scope, we concatenate the previous path with the item.name
. So, every template receives its own copy of the path and some of the path strings get duplicated in every node and leaf. There is no path.pop()
anywhere.
The recursive process is initiated as
xxxxxxxxxx
<ng-container *ngTemplateOutlet="treeView; context:{ data: data,path:this.pathStack} "></ng-container>
Here the data
is our data structure and the this.pathStack=[]
.
Approach 2: Recursive Component
A very similar approach to the previous one. Here we replace the node template with a node component (see the post and the code for details). The component's view is
xxxxxxxxxx
<ng-template #leafView let-data="data" let-path="leafPath" >
<carousel >
<div class="carousel-cell" *ngFor="let item of data">
<img [src]="'assets/images/'+path+'/'+item.screen">
</div>
</carousel>
</ng-template>
<mat-tab-group>
<mat-tab *ngFor="let item of this.items" label="{{item.name}}">
<div *ngIf="item[this.key]?.length > 0 && item[this.key][0]['name'];
else elseBlock">
<tree-view *ngIf="item[this.key]?.length" [key]="this.key" [data]="item[this.key]" [path]="this.path.concat(item.name)"></tree-view>
</div>
<ng-template #elseBlock>
<ng-container *ngTemplateOutlet="leafView;
context:{ data: item[this.key], leafPath:this.path.concat(item.name).join('/')} " >
</ng-container>
</ng-template>
</mat-tab >
</mat-tab-group>
where the leaf template is already included. The component's controller is:
xxxxxxxxxx
@Component({
selector: 'tree-view',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './tree-view.component.html',
styleUrls: ['./tree-view.component.css']
})
export class TreeViewComponent {
@Input('data') items: Array<Object>;
@Input('key') key: string;
@Input('path') path: Array<Object>;
}
No life cycle hooks are needed. Ones again the old path
get concatenated with the item['name']
to get the new path
.
The recursion starts as
xxxxxxxxxx
<tree-view [data]="data" [key]="this.key" [path]="this.pathStack"></tree-view>
where the data
is the data structure, key="children"
, pathStack=[]
.
Every node component stores its copy of the path in the this.path
variable. Is there a way not to duplicate the path data in node components?
Approach 3: Recursive Components With a Single Shareable Stack
For this approach we need 2 components (for the node and leaf) and 1 service (to store and modify a stack). The challenge here is how to use the component's life cycle hooks to update the stack and to properly use the Angular change detection mechanism to provide the right stack to the right leaf.
The stack service (a singleton by default) is very simple:
xxxxxxxxxx
@Injectable({
providedIn: 'root'
})
export class StackService {
stack: Array<String> =[]
constructor() { }
getStack(){
console.log('stack: ',this.stack)
return this.stack;
}
popStack(){
this.stack.pop();
}
pushStack(path){
this.stack.push(path);
}
}
The stack is stored as an array of strings. The service provides the push/pop and get operations on the stack.
The node component controller is:
xxxxxxxxxx
import { Component, OnInit, Input, ChangeDetectionStrategy, AfterViewInit} from '@angular/core';
import {StackService} from '../services/stack.service';
@Component({
selector: 'tree-view-stack',
templateUrl: './tree-view-stack.component.html',
styleUrls: ['./tree-view-stack.component.css']
})
export class TreeViewStackComponent implements OnInit, AfterViewInit {
@Input('data') items: Array<Object>;
@Input('key') key: string;
@Input('path') path: Array<Object>;
constructor(private stackService:StackService) { }
ngAfterViewInit(): void {
this.stackService.popStack();
}
ngOnInit() {
this.stackService.pushStack(this.path);
}
}
The component receives the data
array, path
, and key
string as inputs. The StackService
is injected into the constructor. We use 2 component life cycle hooks to update the stack. A child folder name is pushed to the stack by the ngOnInit()
hook. This hook is called only ones after the component initializes and receives its inputs from its parent component.
Also, we use the ngAfterViewInit()
hook to pop the stack. The hook is called after all the child views are initialized. This is a direct analogue of pathStack.pop()
of recursive JSX rendering, described in my previous post.
The node component view is quite simple:
xxxxxxxxxx
<mat-tab-group>
<mat-tab *ngFor="let item of this.items" label="{{item.name}}">
<div *ngIf="item[this.key]?.length > 0 && item[this.key][0]['name'];else elseBlock">
<tree-view-stack *ngIf="item[this.key]?.length" [key]="this.key" [data]="item[this.key]" [path]="item.name"></tree-view-stack>
</div>
<ng-template #elseBlock>
<tree-leaf-stack [data]="item[this.key]" [key]="this.key" [path]="item.name">
</tree-leaf-stack>
</ng-template>
</mat-tab >
</mat-tab-group>
Ones again, the system chooses what component, a node or a leaf, to render next. No path concatenations this time though.
The leaf component controller is
xxxxxxxxxx
import { Component, Input,OnInit, ChangeDetectionStrategy } from '@angular/core';
import {StackService} from '../services/stack.service';
@Component({
selector: 'tree-leaf-stack',
templateUrl: './tree-leaf-stack.component.html',
styleUrls: ['./tree-leaf-stack.component.css']
})
export class TreeLeafStackComponent implements OnInit {
@Input('data') items: Array<Object>;
@Input('key') key: string;
@Input('path') path: String;
fullPath: String;
constructor(private stackService:StackService) { }
ngOnInit() {
this.fullPath = this.stackService.getStack().concat(this.path).join('/');
}
}
Here I cut the corner and don't use the ngAfterViewInit()
hook to pop the stack. The this.fullPath
variable gets computed ones in the ngOnInit()
hook as the leaf component initializes. Finally, the leaf view is
xxxxxxxxxx
<carousel >
<div class="carousel-cell" *ngFor="let item of items">
<img [src]="'assets/images/'+fullPath+'/'+item.screen">
</div>
</carousel>
The key question here is why we can't call this.stackService.getStack()
directly in the template instead of first saving the call result in the fullPath
variable and then interpolate the variable {{...+fullPath+...}}
?
The reason is how Angular detects changes. Whenever the variables in the stackServce
mutate, the onChange()
component life cycle hook triggers in every component that calls the service. This happens since Angular sets a watcher on every interpolation {{...}}. So, whenever the stack gets pushed or popped, the {{this.stackService.getStack()}}
would be called in every template of every component where there is such an interpolation.
First, this would mean that all the templates get the same value (an empty string) of the stack after the DOM is fully rendered. Second, it would greatly slow down the browser since there will be a lot of calls.
The ChangeDetectionStrategy.OnPush
doesn't help in this case since the strategy only addresses the @Input parameters. The strategy prevents updates if the references to an input parameter object remains the same while the fields of the object get mutated.
React on the other hand detects changes differently. A React component is re-rendered if the component's props
change, setState(...)
method is called, or shouldComponentUpdate(...)
is called. If any of these happen, the component just calls its render()
method to emit JSX code. There are no watchers inside the render()
method. So, this explains the difference between how the recursive rendering problem is implemented in Angular and in React.
The Results
All 3 approaches give the same results:
The first approach is the fastest to run since it doesn't initialize a full-fledged component on every recursion step. The third approach doesn't duplicate the path data in every node component.
Conclusions
In this post I demonstrated 3 approaches to recursively render a deeply nested travel gallery in Angular. The third approach illustrates how the change detection mechanism works in Angular and how it differs from that of React. Never call methods directly from an Angular template!
The code for all 3 approaches is here.
Opinions expressed by DZone contributors are their own.
Comments