Scroll to Bottom

Automatic Scroll to Bottom Chat Interface with Mutation Observers in Ionic



·

It is common in chat interfaces to have the latest messages displayed at the bottom of the screen, and for the screen to automatically scroll to the latest message when a new one is added. This is a feature I added to the chat application that is built in the last module of Elite Ionic, but there is a bit of a trick to it.

It’s easy enough to scroll to the bottom of a list in Ionic, in fact, all you need to do is use this method:

this.contentAreaReference.scrollToBottom();

So, in order to trigger this behaviour, you would just add whatever it is you are adding to your list (like a new chat message) and then you would trigger this scroll:

addMessage(){

    // Add message to list

    // Scroll to bottom of list

}

However, depending on the order of operations in your application, the item may not have actually been added to the list in the DOM by the time the scroll is triggered. The scroll to bottom functionality will scroll to the bottom of the list as it is when it is called, and if the element has not been added to the DOM yet (even if you have already triggered the code to add the message) then it will only scroll to whatever is currently the last element in the list.

This leads to an issue where the list scrolls almost all the way to the bottom, but it misses the last item in the list (which is probably the one that we are interested in). It isn’t just annoying to have to scroll the last little bit of the way manually, it could also lead to people not realising that there is actually another message that they can’t see on screen.

A common solution to problems like this is to use a setTimeout to trigger the scroll after waiting just a little bit to make sure that the element has been added to the DOM. Although setTimeout can be used in a lot of circumstances, it is usually best to avoid it to solve timing issues like this. One of the easiest ways to build buggy and confusing applications, with even more timing issues, is to use setTimeout a lot.

This is where Mutation Observers become useful. In this tutorial, we are going to look into how we can use a mutation observer to react to changes in the DOM of an Ionic application.

What is a Mutation Observer?

It sounds scary, and cool, but the concept is quite simple. Mutation Observers provide a way to react to changes in the DOM. If a new element is added to the DOM, then our mutation observer would be notified and we would be able to take some specific action. This is convenient for us because we want to trigger this scroll to bottom functionality as soon as the new message is added to the DOM. If we use a mutation observer to trigger the scroll, we can be sure that we will be taken to the bottom of the list.

Another cool aspect of mutation observers is that they are not Angular specific, it is just a standard browser API with support across most browsers.

Create a Chat Interface

So that we have something to work with, and to demonstrate the issue, we are going to quickly create a chat interface. This is just going to be a basic/standard chat interface. If you want to follow along feel free to just add this to any existing Ionic application.

Modify src/pages/home/home.html to reflect the following:

<ion-header >
  <ion-navbar color="primary">
    <ion-title>Chat</ion-title>
  </ion-navbar>
</ion-header>

<ion-content>

    <ion-list class="chat-list" no-lines>

        <ion-item *ngFor="let chat of chats">
            <div class="chat-bubble">
                <div class="chat-message">{{chat.message}}</div>
            </div>
        </ion-item>

    </ion-list>

</ion-content>

<ion-footer>

    <ion-toolbar>

        <textarea spellcheck="true" autoComplete="true" autocorrect="true" rows="1" class="chat-input" [(ngModel)]="message" placeholder="type message..." (keyup.enter)="addChat()">
        </textarea>

        <ion-buttons right>
            <button (click)="addChat()" ion-button icon-only item-right class="send-chat-button">
                 <ion-icon name="send"></ion-icon>
            </button>
        </ion-buttons>

    </ion-toolbar>

</ion-footer>

Modify src/pages/home/home.scss to reflect the following:

page-home {

    .scroll-content {
        background-color: map-get($colors, light);
    }

    textarea {
        width: calc(100% - 20px);
        margin-left: 10px;
        background-color: #fff;
        font-size: 1.2em;
        resize: none;
        border: none;
    }   

    ion-item {
        background-color: map-get($colors, light) !important;
    }

    .chat-bubble {

        width: 65%;
        overflow: hidden;

    }

    .chat-message {
        padding: 20px;
        border-radius: 5px; 
        background-color: #fff;
        white-space: normal;
    }

}

Modify src/pages/home/home.ts to reflect the following:

import { Component, ViewChild } from '@angular/core';
import { NavController, Content } from 'ionic-angular';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

    @ViewChild(Content) contentArea: Content;

    private message: string = '';
    private chats: Object[];

    constructor(public navCtrl: NavController) {

        this.chats = [
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'},
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'}
        ];

    }

    addChat(){

        this.chats.push({
            message: this.message
        });

        this.message = '';

        this.contentArea.scrollToBottom();

    }

}

If you were to run this application in the browser now, you will see that the content area is scrolled whenever a new chat is added. However, it will not scroll all of the way to the bottom of the list (assuming that the list is larger than the screen).

Add a Mutation Observer

Let’s add that mutation observer now. We will take a look at the code required first, and then we will talk through it.

Modify src/pages/home/home.ts to reflect the following:

import { Component, ViewChild, ElementRef } from '@angular/core';
import { NavController, Content, List } from 'ionic-angular';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

    @ViewChild(Content) contentArea: Content;
    @ViewChild(List, {read: ElementRef}) chatList: ElementRef;

    private message: string = '';
    private chats: Object[];
    private mutationObserver: MutationObserver;

    constructor(public navCtrl: NavController) {

        this.chats = [
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'},
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'}
        ];

    }

    ionViewDidLoad(){

        this.mutationObserver = new MutationObserver((mutations) => {
            this.contentArea.scrollToBottom();
        });

        this.mutationObserver.observe(this.chatList.nativeElement, {
            childList: true
        });

    }

    addChat(){

        this.chats.push({
            message: this.message
        });

        this.message = '';

    }

}
import { Component, ViewChild, ElementRef } from '@angular/core';
import { NavController, Content, List } from 'ionic-angular';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

    @ViewChild(Content) contentArea: Content;
    @ViewChild(List, {read: ElementRef}) chatList: ElementRef;

    private message: string = '';
    private chats: Object[];
    private mutationObserver: MutationObserver;

    constructor(public navCtrl: NavController) {

        this.chats = [
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'},
            {message: 'Just some'},
            {message: 'dummy messages'},
            {message: 'to fill up'},
            {message: 'space'},
            {message: 'so that'},
            {message: 'the screen'},
            {message: 'is already'},
            {message: 'kind of'},
            {message: 'full'}
        ];

    }

    ionViewDidLoad(){

        this.mutationObserver = new MutationObserver((mutations) => {
            this.contentArea.scrollToBottom();
        });

        this.mutationObserver.observe(this.chatList.nativeElement, {
            childList: true
        });

    }

    addChat(){

        this.chats.push({
            message: this.message
        });

        this.message = '';

    }

}

We are using a couple of interesting concepts to set up the mutation observer, so let’s talk through them. First of all, we are using @ViewChild to grab a reference to the element we want to watch:

@ViewChild(List, {read: ElementRef}) chatList: ElementRef;

This is mostly a standard use of @ViewChild (if you don’t understand @ViewChild and @ContentChild you might want to check out this tutorial). However, we are supplying an additional object with a read property:

{read: ElementRef}

What this does is rather than grabbing a reference directly to the List, it will grab a reference to the ElementRef for the list, which will allow us to access the native DOM element. This is important because this is what we need to supply to the mutation observer. If you aren’t familiar with ElementRef you might be interested in this tutorial.

In order to set up the mutation observer, we do this:

        this.mutationObserver = new MutationObserver((mutations) => {
            this.contentArea.scrollToBottom();
        });

        this.mutationObserver.observe(this.chatList.nativeElement, {
            childList: true
        });

First, we set up the action for the mutation observer which is to scroll to the bottom of the content area. Then we tell that mutation observer to observe the children of chatList which is the list component we are using for the chat messages. This means that whenever a new child element is added to the list, the mutation observer will be triggered.

Now watch with pride as that list slides all the way to the very bottom of the list:

Scroll to bottom chat interface in Ionic

Smooth.

Summary

We are now triggering the scroll in a safe way, rather than relying on a workaround like using setTimeout. The mutation observer is useful in this circumstance, but now that you know how to use it you could use it in any circumstance where you want to perform some action in response to some new element being added somewhere.

What to watch next...