Part 10 from series: How to build a real-time chat app

Angular Animations

Animate new incoming messages in an Angular component

Messages are appearing within our app in real-time from our subscription. Lets make the user interface more interesting with animations.

There are lots of methods to approaching integration of animations for web documents. We could use SVG animations or add a different set of elements and CSS properties to achieve the same result. This tutorial describes just one method that will work well for our application. It features a responsive web design animation that can adapt to any resolution or display.

Our approach

Our intention is to create a cloned message or spacer element that will act as a placeholder to create the space we need to animate a new incoming message in real time. Once the animation has completed, the spacer will be deleted.

Before we can start using animations, we need to first import the BrowserAnimationsModule module into our new feature module:

From your project folder, open the file web-app/src/app/messages/messages.module.ts and add the following lines defined in comments:

File path icon web-app/src/app/messages/messages.module.ts
// Existing imports...
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; // <-- Add new import

@NgModule({
  // Existing code...
  imports: [
    CommonModule,
    FormsModule,
    BrowserAnimationsModule, // <-- Add new module import
    MessagesRoutingModule
  ]
})
export class MessagesModule {}

We're going to add the logic in our component to hook up the animation for new messages before we describe the animation behaviour. Open the file web-app/src/app/messages/view-conversation/view-conversation.component.ts in your IDE and add the new imports:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Existing code...
import { trigger, transition, useAnimation } from '@angular/animations'; // <-- Add new import

interface MessageModel {
// Existing code...
}

// Existing code...

To attach an animation to an HTML element, we first need to describe a trigger to make the association between our template and component animation. In the component metadata add the animations property and new trigger called newMessage:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Existing code...

@Component({
  // Existing code...
  animations: [ // Add new animation property and trigger
    trigger('newMessage', [

    ])
  ]
})
export class ViewConversationComponent implements OnInit {
  // Existing code...
}

The trigger name will be used in the HTML template to bind to events of the animation. We will later describe the animation inside the trigger.

View-model message properties

We will need to add two optional properties to our existing MessageViewModel interface called spacer and isNew to hold Boolean values.

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Exiting code...

interface MessageViewModel {
  // Exiting code...
  fromYou: boolean;
  spacer?: Boolean; // <-- Add new property
  isNew?: Boolean; // <-- Add new property
}

// Exiting code...

These will help the template render the animation by defining the state of a new message and also the difference between new and existing messages.

We will use these properties to create two new (almost identical) MessageViewModel instances. One will be a spacer, the other will be the readable message that uses with the isNew property set to true for the duration of the animation. We need to change the addNewMessage method we previously defined to create two instances on our messages array:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
export class ViewConversationComponent implements OnInit {
  // Existing code...

  updateMessages(result: ApolloQueryResult<{messages: Array<MessageModel>}>) {
    // Existing code...
  }

  /**
   * Change method to code below
   */
  addNewMessage(messageData: any) {
    this.messages.push(
      ViewConversationComponent.createMessageInstance({
        ...messageData,
        isNew: true // <-- New property within mixin
      })
    );

    // New spacer instance
    this.messages.push(
      ViewConversationComponent.createMessageInstance({
        ...messageData,
        spacer: true
      })
    );
  }

  // Existing code...
}

If you look at our application now (you can open the browser dev tools to explore how the HTML elements interact), you will see two identical messages get created when sending a single message.

We need to turn the additional message into a spacer by attaching existing styles we previously included with a conditional class name to a new element in the template. And adding conditional inline-style rules to the containing view-conversation__item.

Open the HTML layout file view-conversation.component.html within the same folder and add (or replace with) the following code:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.html
<div class="view-conversation">
  <div class="view-conversation__list">

    <!-- 1. Add the [ngStyle]="..." directive below -->
    <div *ngFor="let message of messages"
      class="view-conversation__item"
      [ngClass]="{'view-conversation__item--you': message.fromYou}"
      [ngStyle]="{
        'opacity': message.spacer ? 0 : 1,
        'z-index': message.spacer ? 0 : 1,
        'position': message.isNew ? 'absolute' : 'static'
      }">

    <span class="view-conversation__time">{{message.sent | date: 'shortTime'}}</span>

    <!-- 2. Add the [@.disabled] and [@newMessage] directives below -->
    <div class="view-conversation__message-animation"
      [ngStyle]="{'border-color': '#' + message.user.colour}"
      [@.disabled]="!message.isNew"
      [@newMessage]="!message.spacer || ''">

      <!-- 3. Add new cover element -->
      <div class="view-conversation__message-cover cover-anim"
        [ngStyle]="{
          'display': message.isNew ? 'block' : 'none',
          'background-color': '#' + (message.user.colour || 'dca629')
        }"></div>
    </div>
  </div>
</div>

Part 1. Add the ngStyle directive

The first part (under comment number 1. above) conditionally adds styles based on whether the message is a spacer or not. The spacer will take up normal document flow to push out the right amount of space for our message so its position is static. This is the reason the spacer also includes the same text as the message so that the size of the space can change with the text, whatever the resolution of the viewport. The spacer is also transparent and stacked underneath the readable message with a z-index and opacity of 0.

Part 2. Add the @.disabled and @newMessage directives

The second part uses an animation event binding @.disabled to conditionally disable the animation only if the message is a spacer. We want the spacer to appear immediately.

This part also uses another animation event binding to our custom trigger @newMessage that we defined in our component metadata. This syntax will attach our animation to this HTML element only if it's not a spacer. The @ syntax in Angular HTML templates is reserved for describing animation semantics.

Part 3. Add new cover element

In the third part, we add a new element that will display only when the message isNew and not a spacer. This will cover the entire message with a block of colour (the colour defined by Slack data) as it animates and eventually fades to reveal the message underneath. At the moment, the cover has no size to it, which we will adjust later with animations.

When the animation is done

To achieve our animation we create a lot of artefacts that we need to clean up once our animation is complete.

Open the file view-conversation.component.ts and add the following method after the addNewMessage method.

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Existing code...

export class ViewConversationComponent implements OnInit {
  // Existing code...

  addNewMessage(messageData: any) {
    // Existing code...
  }

  /**
   * Add the new method below
   */
  onNewMessageAnimated(event, newMessageInstance) {
    const spacerInstanceIndex = this.messages.findIndex(message => message.spacer && message.id === newMessageInstance.id);

    if (spacerInstanceIndex > -1) {
      this.messages.splice(spacerInstanceIndex, 1);
      newMessageInstance.isNew = false;
    }
  }

  // Existing code...
}

We only want the additional spacer message to appear for the duration of our animation and remove it once the animation is complete. To remove this we call the splice method on the messages array. We also remove the cover element that's no longer needed once the message has faded into view. We take this out of the document flow by setting the isNew property of the recently animated message to false.

We need to hook up our new onNewMessageAnimated method to be called upon when the animation becomes complete (or done). Back in our HTML template view-conversation.component.html add the following line beginning with (@newMessage.done)=":

Be sure to move the existing angle bracket > to this next line in order to close the element tag with the new attribute.

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.html
<div class="view-conversation">
  <div class="view-conversation__list">
    <div *ngFor="let message of messages"
      class="view-conversation__item"
      [ngClass]="{'view-conversation__item--you': message.fromYou}"
      [ngStyle]="{
        'opacity': message.spacer ? 0 : 1,
        'z-index': message.spacer ? 0 : 1,
        'position': message.isNew ? 'absolute' : 'static'
      }">

    <span class="view-conversation__time">{{message.sent | date: 'shortTime'}}</span>

    <!-- Add the (@newMessage.done) event binding below -->
    <div class="view-conversation__message-animation"
      [ngStyle]="{'border-color': '#' + message.user.colour}"
      [@.disabled]="!message.isNew"
      [@newMessage]="!message.spacer || ''"
      (@newMessage.done)="message.isNew && onNewMessageAnimated($event, message);"><!-- <-- New event binding -->

      <!-- Existing code... -->
    </div>
  </div>
</div>

The above line references the event @newMessage.done, which as you may have guessed calls the function specifed as its value when the newMessage animation completes. The call to onNewMessageAnimated($event, message); from this event is guarded by the isNew property check to make sure this will only fire once for a new message and not for a spacer.

Try some improvements

An improvement to this code would be for the spacer logic to be separated out into its own component. This will demonstrate a clearer separation of concerns and make the code easier to maintain. I leave this optional refactor up to the readers discretion. It would be a good exercise to practice what has been included in this tutorial so far.

Hint: There is a directive similar to Angular's ngIf that may offer better points of exension to this area of code.

The Angular animation

We've linked our animation from our template to our component. We're going to separate the logic that describes the animation sequence into its own file. Create and open the file web-app/src/app/messages/new-message.animation.ts.

touch web-app/src/app/messages/shared/new-message.animation.ts

Lets first set up the file with an exported constant for our new animation by adding the following code:

File path icon web-app/src/app/messages/shared/new-message.animation.ts
import { animation, animate, query, sequence, style } from '@angular/animations';

export const newMessageAnimation = animation([

]);

We import a number of functions from the @angular/animations package to build animations. All logic of our animation will be held in the array passed to the animation function.

The array will contain a composition of various instances, these created by calling the other functions we have imported. Creating an animation this way will make the animation reuasable in other areas of the application.

Our animation is quite specific and sequential, which will affect what we import from Angular.

In order for the new animation to be used, we need to use it in the animations metadata of the component.

Add the following code to the component metadata in the TypeScript file view-conversation.component.ts:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Existing code...
import { newMessageAnimation } from '../shared/new-message.animation'; // <-- Add new import

@Component({
  selector: 'app-view-conversation',
  templateUrl: './view-conversation.component.html',
  styleUrls: ['./view-conversation.component.scss'],
  animations: [
    trigger('newMessage', [
      transition(':enter', [ // <-- Add new transition
        useAnimation(newMessageAnimation) // <-- Add reference to newMessageAnimation
      ])
    ])
  ]
})
export class ViewConversationComponent implements OnInit {
  // Existing code...
}

The useAnimation function will link our newly created animation to the transition it is defined in. The call to transition(':enter') will describe what animation to use when the element tagged with newMessage reaches the :enter state.

Transitions

A Transition describes which animation styles to use between each state. We use transition(':enter', [...]) to tell the animation to run upon the element first appearing on (or entering) the page. This transition will run when the *ngFor directive updates our list of messages with new content. It will also run in conjunction with state changes to *ngIf directives.

Open the new-message.animation.ts file, we're going to start animating.

Styles

We first define a style on the host element in our animation. Add the following code to the array passed to the animations function:

File path icon web-app/src/app/messages/shared/new-message.animation.ts
// Existing code...

export const newMessageAnimation = animation([
  style({
    height: 0,
    width: '5px'
  }),
]);

The style rule will immediately run and force a height and width on the host element it is attached to.

Then we will use the query method of the @angular/animations package that is similar to the native DOM method querySelector to find the element with class cover-anim.

File path icon web-app/src/app/messages/shared/new-message.animation.ts
// Existing code...

export const newMessageAnimation = animation([
  style({
    // Existing code...
  }),
  query('.cover-anim', [ // <-- Add new query and style
    style({
      opacity: 1
    })
  ]),
]);

We previously added the cover-anim class to an element in the template. In the animation, we're changing the style of this element to force an opaque opacity again at the very start of the animation. This will be applied to the inner element as soon as the element is drawn on the page.

We then need to add a sequence to describe a sequence of animation timings and styles

File path icon web-app/src/app/messages/shared/new-message.animation.ts
// Existing code...

export const newMessageAnimation = animation([
  style({
    // Existing code...
  }),
  query('.cover-anim', [
    // Existing code...
  ]),
  sequence([ // <-- Add new sequence
    animate('0.15s 400ms ease-out', style({ // <-- Add new animate style
      height: '*',
      width: '5px'
    })),
    animate('0.3s 0ms ease-out', style({ // <-- Add new animate style
      height: '*',
      width: '*'
    })),
]);

The new sequence above will animate the host element in two steps. First the height will grow from 0px to full height using * with the width restrained to 5px. This will happen fairly quickly within 0.15 seconds. Only once this has completed will the next animation run. This will release the restriction on the width (using width: '*') to allow the host element to completely fill the area within the next 0.3 seconds.

Lastly we need to fade out the cover element to reveal the message underneath.

File path icon web-app/src/app/messages/shared/new-message.animation.ts
export const newMessageAnimation = animation([
  // Existing code...
  sequence([
    animate('0.15s 400ms ease-out', style({
      // Existing code...
    })),
    animate('0.3s 0ms ease-out', style({
      // Existing code...
    })),
    query('.cover-anim', [ // <-- Add new query
      style({ // <-- Add new style
        opacity: 1
      }),
      sequence([ // <-- Add new sequence
        animate('1s 0ms ease-out', style({
          opacity: 0
        }))
      ])
    ])
  ])
]);

We use query(...) once again to target the element with class cover-anim. The new style call in the array will now target the child element with class cover-anim and not the previous host element to make sure it is opaque. We then declare another sequence to animate the fade of the child element to make it transparent within 1 second.

We should now be able to see our message fully animated. Open http://localhost:4200 and create a new message. Try sending a new message from a downloaded Slack app to see how the animation works with a message sent from another person.

Our animation is working fine but it would be good to stop the text box used to create a message from going out of view every time a new message is created.

Scroll to the latest message

To make our app more interactive, we're going to add automatic scrolling to the latest message as soon as it appears. This will prevent the page jumping around and enable a user to always see the text box they're typing in whilst other users are sending messages to them.

Open the file web-app/src/app/messages/messages/messages.component.html and add the following code:

File path icon web-app/src/app/messages/messages/messages.component.html
<div class="messages" #containerEl> <!-- <-- Add new identifier #containerEl -->
  <header class="header">
    <!-- Existing code -->
  </header>
  <!-- Existing code -->
</div>

Here we're adding a new Angular template reference variable called #containerEl to be able to select the element within the component. We can make a reference to the element and save this as a component property.

From the same folder, open the file messages.component.ts and add the following lines to the top of the class.

File path icon web-app/src/app/messages/messages/messages.component.ts
import { Component, OnInit, ViewChild } from '@angular/core'; // <-- Add new ViewChild import

@Component({
  // Existing code...
})
export class MessagesComponent implements OnInit {
  @ViewChild('containerEl', { static: true }) // <-- Add new decorator
  containerEl: any; // <-- Add new property reference

  // Existing code...  
}

The ViewChild TypeScript decorator will tell Angular to find an element with the template reference variable containerEl and assign it to the class property containerEl. This is defined by stacking the ViewChild decorator directly above the property. This can also be placed to the left side of the property to make an association between property and decorator.

We need to add a method that will do the scrolling to keep the text input element in view when a new message has been added. We are already using a spacer to instantly create the room for the animation so we won't have to wait for the full height of the visible message to be rendered before we scroll the window.

The scrollable element is held in a different component to the message list component that will trigger scrolling. We will have to co-ordinate component interaction via an event binding to add scrolling when a message is added.

At the end of the class add the method scrollToContainerBottom:

File path icon web-app/src/app/messages/messages/messages.component.ts
@Component({
  // Existing code...
})
export class MessagesComponent implements OnInit {
  // Existing code...

  scrollToContainerBottom() {
    const config = {
      behavior: 'auto',
      block: 'end',
      inline: 'nearest'
    };

    setTimeout(() => {
      this.containerEl.nativeElement.scrollIntoView(config);
    }, 0);
  }
}

The setTimeout call will ensure the scrolling behaviour happens after the message is rendered. The timeout is set to wait zero milliseconds to run immediatley and ensure that scrolling won't happen in the same tick of the event loop.

We will call the scrollToContainerBottom method from an output event binding that will emit from the ViewConversationComponent. Open the file web-app/src/app/messages/view-conversation/view-conversation.component.ts and add the following code:

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
import { Component, OnInit, ViewChild, Output, EventEmitter } from '@angular/core'; // <-- Add new imports Output and EventEmitter
// Existing imports...

@Component({
  // Existing code...
})
export class ViewConversationComponent implements OnInit {
  // Existing code...

  @Output() // <-- Add new line
  messagesUpdatedEvent = new EventEmitter(); // <-- Add new line
}

Next, we need to use the emitter to emit an event notifying the parent component that a message has been added. Add the following new lines to the bottom of each method updateMessages and messageReceived in the same file.

File path icon web-app/src/app/messages/view-conversation/view-conversation.component.ts
// Existing code...

@Component({
  // Existing code...
})
export class ViewConversationComponent implements OnInit {
  // Existing code...

  updateMessages(result: ApolloQueryResult<{messages: Array<MessageModel>}>) {
    // Existing code...

    this.messagesUpdatedEvent.emit() // <-- Add new line
  }

  // Existing code...

  messageReceived(result: ApolloQueryResult<{messageAdded: MessageModel}>) {
    // Existing code...

    this.messagesUpdatedEvent.emit(); // <-- Add new line
  }
}

We can now hook up the event in the template and tell Angular to listen to events emitted from the messagesUpdatedEvent event and call the scrollToContainerBottom method.

Open the file web-app/src/app/messages/messages/messages.component.html and add the following code to the element app-view-conversation.

File path icon web-app/src/app/messages/messages/messages.component.html
<div class="messages" #containerEl>
  <!-- Existing code... -->
  <div class="messages__conversation">
    <app-view-conversation
      (messagesUpdatedEvent)="scrollToContainerBottom()"><!-- <-- Add new event binding -->
    </app-view-conversation>
    <!-- Existing code... -->
  </div>
</div>

Open your browser at http://localhost:4200 and type a few messages (enough to create a scroll bar down the page) then scroll back to the top of the page. Open a downloaded Slack app, keeping the Angular app in view and type a message in the channel conversation-1.

Once the message has been received in our Angular application, the page should automatically scroll to the bottom showing the latest message animation.

Congratulations!

Well done!🥂🎉 if you've made it all the way to the end. This concludes the final part of the 10 step tutorial on how to build a messaging web application. Below are some links to the source code for your reference.

Messaging Angular app source code

Messaging GraphQL api source code