As developers, we know, some things that may seem trivial end up taking lots of time. I was making a chat app in Angular and this cropped up. The app requires the scroll position to be maintained when messages are loaded dynamically without any noticeable difference to the user. And this simple little thing, took the better part of a week!

The Problem

The chat app loads the most recent messages initially and when the older messages are loaded, the view seems to jump. This is bad user experience. A good solution would be for the older messages to load in the background so that the user can scroll up to see the loaded messages without a noticable delay.

The Initial Code

<div class="messages" infiniteScroll [infiniteScrollUpDistance]="9.9" [scrollWindow]="false" [infiniteScrollThrottle]="1500"
    (scrolledUp)="loadMore()">
    <mat-spinner *ngIf="loading" [diameter]="50"></mat-spinner>
    <ul>
      <ng-container *ngFor="let list of messageList; trackBy:key">
        <li *ngIf="list?.user_type === 'User'" class="sent">
          <span>
            <p>{{list?.message}}</p>
            <small>{{list?.message_timestamp}}</small>
          </span>
        </li>
        <li *ngIf="list?.user_type === 'Agent'" class="replies">
          <span>
            <p>{{list?.message}}</p>
            <small>{{list?.message_timestamp}}</small>
          </span>
        </li>
      </ng-container>
    </ul>
  </div>

I am using the awesome ngx-infinite-scroll to manage scroll events.

Things I Tried Out

I thought about numerous approaches and tried out a few.

Attempt 1: Element.scrollIntoView()

Each message is assigned an id attribute which can be used to query the element using getElementById(). Next, I used the native element method scrollIntoView() to scroll the concerned element into view.

Since the message length and the viewport varies for each user; this method didn't work out. It was not possible to predict which all id attributes are in the current viewport and hence an event cannot be triggered appropriately.

Attempt 2: Remember the scrollHeight; then scroll back

The idea is to remember the distance of the scroll from the bottom and then restore the same distance after the new messages are loaded.

A good article by Andrew Petersen solved the problem using JavaScript. You can find an Angular implementation of the same in Gaurav Mukherjee's Medium post. It is highly recommended that you read both.

This method solved 99% of the problem.

Really! 99% of the problem. We were able to remember the scroll position at which the messages loaded. The scroll back action resulted in a jarring effect in UI. And the scroll back was not responsive at times. The solution is a really good one, but I wanted to improve on that.

Solution

Our solution is a debugged and refactored version of Gaurav's solution. Refactoring the service into a directive brought us a long way into fixing the flickering and jarring. We avoided the use of the lifecycle hook ngAfterViewInit, which in my modest and personal opinion, was an overkill.

Updated HTML snippet:

<div class="messages" appScrollDirective infiniteScroll [infiniteScrollUpDistance]="9.9" [scrollWindow]="false" [infiniteScrollThrottle]="1500"
    (scrolledUp)="loadMore()">
    <mat-spinner *ngIf="loading" [diameter]="50"></mat-spinner>
    <ul>
      <ng-container *ngFor="let list of messageList; trackBy:key">
        <li *ngIf="list?.user_type === 'User'" class="sent">
          <span>
            <p>{{list?.message}}</p>
            <small>{{list?.message_timestamp}}</small>
          </span>
        </li>
        <li *ngIf="list?.user_type === 'Agent'" class="replies">
          <span>
            <p>{{list?.message}}</p>
            <small>{{list?.message_timestamp}}</small>
          </span>
        </li>
      </ng-container>
    </ul>
  </div>

The corresponding TypeScript snippet which managed the scroll:

ngOnInit() {
    this.store.select(getAllMessages).subscribe(messageList => {
      this.scrollDirective.prepareFor('up'); // this method stores the current scroll position
      this.messageList = this.messageVO.getMessageVO(messageList); // the updated message list
      setTimeout(() => this.scrollDirective.restore()); // method to restore the scroll position
    });
    this.store.select(getSelectChatId).distinctUntilChanged()
      .subscribe(id => {
        this.scrollDirective.reset(); // refresh scroll to initial position
      });
  }

We follow the NgRx architecture, hence, the store and select commands.

The prepareFor() method, is called everytime just before the messageList is updated.

The restore() method is called after the messageList is updated.

The setTimeout() with 0 ms delay is used because by definition, 0 ms is just the minimum time to execute the method. We tried Promise.resolve(1).then() but didn't work because setTimout() is a Macrotask whereas Promises is considered Microtasks. Refer this article by Jake Archibald for a great explanation.

Now onto to the main event, scroll.directive.ts:

import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: '[appScrollDirective]'
})
export class ScrollDirective {
  previousScrollHeightMinusTop: number; // the variable which stores the distance
  readyFor: string;
  toReset = false;

  constructor(public elementRef: ElementRef) {
    this.previousScrollHeightMinusTop = 0;
    this.readyFor = 'up';
    this.restore();
  }

  reset() {
    this.previousScrollHeightMinusTop = 0;
    this.readyFor = 'up';
    this.elementRef.nativeElement.scrollTop = this.elementRef.nativeElement.scrollHeight;
    // resetting the scroll position to bottom because that is where chats start.
  }

  restore() {
    if (this.toReset) {
      if (this.readyFor === 'up') {
        this.elementRef.nativeElement.scrollTop =
          this.elementRef.nativeElement.scrollHeight -
          this.previousScrollHeightMinusTop;
          // restoring the scroll position to the one stored earlier
      }
      this.toReset = false;
    }
  }

  prepareFor(direction) {
    this.toReset = true;
    this.readyFor = direction || 'up';
    this.elementRef.nativeElement.scrollTop = !this.elementRef.nativeElement.scrollTop // check for scrollTop is zero or not
      ? this.elementRef.nativeElement.scrollTop + 1
      : this.elementRef.nativeElement.scrollTop;
    this.previousScrollHeightMinusTop =
      this.elementRef.nativeElement.scrollHeight - this.elementRef.nativeElement.scrollTop;
      // the current position is stored before new messages are loaded
  }
}

The ElementRef returns a reference to the <div> which is bind by the directive. Using the scrollTop property of the element is used to manipulate the position of the element.
The three methods reset(), prepareFor() and restore() have three purposes:

  • reset() - reset the position when new chats are loaded. This is called when the chatroom is loaded.
  • prepareFor() - to store the position before the older messages are stored. The check if scrollTop is zero because that's an edge case.
  • restoreFor() - to restore the scroll position.

Along the way, we learnt a lot of new things like, writing a directive, Angular lifecycle hooks, JavaScript event loop and other minor things.