Rendering realtime list using Angular2

We have built HyperTrack’s dashboard ground up using angular2. Oh and we released a brand spanking new version yesterday that you should check out if you haven’t already. Our dashboard provides realtime visualization of geospatial data for business users. One of the more popular views is the task page that lets you track all live tasks being performed by the workforce.

The task page shows a list of tasks as cards. Hovering over the task card in the list on the left renders tracking data on a map on the right. The task cards are sorted in reverse chronological order viz. most recent first.

Consider the scenario where a new task card gets added to the top of the list while the user is hovering over a task in the list and engaged with details in the map view. In our first implementation, the new card pushed the older tasks under it. This created an inconsistent experience as the user saw a modified list with the selected card moved down while the mouse was on a new card. Engaging with the mouse cursor changed the selected card abruptly. In a scenario where many tasks are getting added to the list in real-time, the experience can be jarring.

To create a consistent experience, we wanted to adjust the scroll of the parent div such that the selected card remained at the same position relative to the view even when the list has been modified. Furthermore, we wanted this to happen immediately as the framework rendered the new task card so as to prevent any flicker due to change in scroll.

We created a service called ScrollFix.

import {Injectable} from "@angular/core"; 
@Injectable()
export class ScrollFix {
node;
previousScrollHeightMinusTop: number;
readyFor: string;
toReset: boolean = false;
constructor() {
  }   
init(node) {
this.node = node;
this.previousScrollHeightMinusTop = 0;
this.readyFor = 'up';
}
restore() {
if(this.toReset) {
console.log("restore");
if (this.readyFor === 'up') {
this.node.scrollTop = this.node.scrollHeight - this.previousScrollHeightMinusTop;
}
this.toReset = false;
}
}
prepareFor(direction) {
this.toReset = true;
this.readyFor = direction || 'up';
this.previousScrollHeightMinusTop = this.node.scrollHeight - this.node.scrollTop;
}
}

ScrollFix has 2 functions. prepareFor(‘up’) is called just before new tasks gets added to the list. After the new DOM is rendered restore() is called to adjust the scroll of the parent.

The nativeElement of the parent div whose scroll needs to be adjusted is passed in the init function of the ScrollFix service right after the TaskListComponent renders the DOM.

Lets look at the TaskListComponent

@Component({
template: `
<div #sidebar
*ngFor="let task of taskList; trackBy:id">
<task-card
(mouseenter)="hoverTask(task)"
[task]="task"
(mouseleave)="hoverOut()">
</task-card>
</div>
`
})
class TasksComponent {
@ViewChild('sidebar') sidebar: ElementRef;
@ViewChildren(TaskCardComponent) taskCards: QueryList<TaskCardComponent>;
constructor(
private taskService: TaskService,
private route: ActivatedRoute,
private scrollFix: ScrollFix,
private traceService: TraceService
) {}
ngAfterViewInit() {
this.scrollFix.init(this.sidebar.nativeElement)
let sub = this.taskCards.changes.subscribe(data => {
this.scrollFix.restore()
});
}
updateTaskList(taskList){
this.scrollFix.prepareFor('up');
this.taskList = taskList
}
id(index, item){
return item.id
}
}

To use ScrollFix service, it is injected in the constructor of the component. ngAfterViewInit() is a lifecycle hook provided by angular which is fired when the DOM of the view is rendered. Here we are initializing ScrollFix by passing in the parent DOM (this.sidebar.nativeElement) of the list.

Using QueryList, we get a reference of the array of taskCard component rendered in the view of the component. this.taskCards provides a handy observable this.taskCards.change(). This subscription is fired only if there is any change in the array of Task Cards.

To take advantage of angular2 change detection strategy, instead of mutating the this.taskList, we are replacing it with a new reference. Because we are using TrackBy feature of ngFor, a card is only added/removed from the DOM when task with the same id doesn’t already exist in the list. This also ensures that let sub = this.taskCards.changes.subscribe() is fired only when new card is added or removed.

We are only considering the case when new tasks are added above the selected task. When the card gets added below the selected card, its position does not change by default.

To see the new HyperTrack dashboard in action, checkout the demo account.