Building an slide-out notification drawer with Angular 2 animations

Update - Aug 15, 2017 - Thanks for jhillhouse92 for the PR to get this working with Angular 4. I appreciate the help!

As Angular 2 is maturing one of the items I was really looking forward to was the animations. I'm certainly no animation expert, but I do appreciate the use of targeted, contextual animations. I think they add a bit of professional polish that tells a user something was really thought about, and can make an already good application a great one.

However, since the animation support was formally added in RC2 I really haven't seen many articles where people are doing anything with it. Sure, there's Tero Parviainen's Angular Chimes demo/talk, but not much else that I've seen. I don't know whether to feel worried by this, or just shrug it off as an indication that people are still getting used to the framework itself and not really worried about animations. Even more interesting, at least to me, is the new Angular Material code isn't using the animation support in the framework (see the checkbox code as an example).

Regardless of the reason, I wanted to play around with animations and try to create a UI pattern around notifications. That pattern is the slide-out notification drawer. Here's what we'll build in this article (note the compression on this animated gif hides some of the "smoothness" you'll see if you run this locally):

This example has a couple animations worth pointing out:

  • The entire panel slides out and back
  • The arrow rotates 180 degrees when expanded
  • The color and background color of the header changes when the panel is expanded
  • New notifications fade in at the top when added
  • The content of a notification fades in when the panel is expanded, and fades out before it's contracted

While this is a relatively simple example, you can see there's a several things to think about when you start to animate components of your application. The code for this example is available in my github repository:

https://github.com/sstorie/experiments/tree/master/angular2-animated-notifications-panel

If you haven't already done so, please check out the official docs about animations:

https://angular.io/docs/ts/latest/guide/animations.html

Now let's dig into how this was built.

Update August 8, 2016 - A helpful user pointed out that this experiment doesn't run properly in Firefox, so I should have stated early on that I used Chrome exclusively to develop this. I did not try this in IE or Firefox, so in those browsers YMMV.

The Component Tree

Here's a visual of the Angular 2 component tree used in this example:

It's pretty simple, and we have our top-level component that contains a component called NotificationPanelComponent. This is responsible for, not surprisingly, the slide-out notification panel. It in turn contains a list of NotificationComponent components that each represents a notification present in the system. All the notifications are generated and published as an observable by the NotificationService...which is an Angular service and not a component.

I'm not going to walk through all the code for the AppComponent or NotificationService since they're pretty standard and not specifically related to the animations. You can see them in the github repo if you wish.

The NotificationPanelComponent

With Angular 2 animations there's a new relationship between the component itself and the HTML template, because the animations are leveraged with special markup in your template. For the notification panel let's first look at the template:

<div class="container" @panelWidthTrigger="expandedState">  
    <div class="title" @titleColorTrigger="expandedState">
        <span *ngIf="expanded" @titleTextTrigger="'in'">Notifications</span>
        <a class="link" (click)="toggleExpandedState()">
            <i @iconTrigger="expandedState" class="fa fa-arrow-circle-left" aria-hidden="true"></i>
        </a>
    </div>

    <div class="notifications">
        <notification *ngFor="let n of notifications"
            [notification]="n"
            [expanded]="expanded"></notification>
    </div>
</div>  

It's pretty simple actually, and we see it has a container div with a title bar div inside, followed by another div that contains the notification components. What probably jumps out however are items like @panelWidthTrigger="expandedState" and @titleColorTrigger="expandedState". These are how you associate an animation defined on the component with a particular element in the template. We'll see in a bit how it works, but you basically have an animated transition defined in the component, and use a value to determine what state the animation is in. In the two cases here we use the component's expandedState property to determine what state the animation should be in. This is where the complexity of a given set of animations becomes apparent. With this relatively simple example, we have 4 different animation transitions defined:

  • One to animate the containing div - @panelWidthTrigger
  • One to animate the colors used in the title div - @titleColorTrigger
  • One to animate the visibility of the word "Notifications" - @titleTextTrigger
  • One to animate the rotation of the arrow icon - @iconTrigger

You might also notice the value of expanded is passed as an input to the Notification component, and this lets us animate that component based on the state of the panel overall. Let's take a look at the panel component itself now:

import {  
    Component,
    OnInit,
    OnDestroy,
    trigger,
    state,
    style,
    transition,
    animate
} from '@angular/core';
import {Subscription} from "rxjs/Rx";

import {Notification, NotificationService} from "../shared/index";  
import {NotificationComponent} from "../notification/index";

@Component({
    moduleId: module.id,
    selector: 'notification-panel',
    directives: [NotificationComponent],
    templateUrl: 'notification-panel.component.html',
    styleUrls: ['notification-panel.component.css'],
    animations: [
        // Define the animation used on the containing dev where the width of the
        //  panel is determined. Here we define the expanded width to be 300px, and
        //  the collapsed width to be 38px.
        //
        // When expanding the panel we transition over a 200ms interval.
        //
        // When collapsing the panel we again use 200ms for the transition, but
        //  we add a delay of 200ms to allow some other animations to complete before
        //  shrinking the panel down.
        //
        trigger('panelWidthTrigger', [
            state('expanded', style({ width: '300px' })),
            state('collapsed', style({ width: '38px' })),
            transition('collapsed => expanded', animate('200ms ease-in')),
            transition('expanded => collapsed', animate('200ms 200ms ease-out'))
        ]),

        // Define the animation used in the title bar where the colors swap from
        //  a red foreground with white background, to the opposite. In this case
        //  we use the same timings as the width animation above so these two
        //  transitions happen at the same time
        //
        trigger('titleColorTrigger', [
            state('collapsed', style({ backgroundColor: '#FFFFFF', color: '#E74C3C' })),
            state('expanded', style({ backgroundColor: '#E74C3C', color: '#FFFFFF' })),
            transition('collapsed => expanded', animate('200ms ease-in')),
            transition('expanded => collapsed', animate('200ms 200ms ease-out'))
        ]),

        // The title text trigger is a little different because it's an animation
        //  for an element being added to the DOM. Here we take advantage of the 'void'
        //  transition using a hard-coded state called 'in' (which is also hard coded in
        //  the template).
        //
        // What we do in this animation is say when the element is added to the DOM
        //  it should have an opacity of 0 (i.e., hidden), wait 300ms, and then animate
        //  it's opacity change to 1 over a 100 ms time span. This effectively delays the
        //  appearance of the text until after the panel has slid out to the full size.
        //
        // When the element is removed we take a different approach and animate the
        //  opacity change back to 0 over a short 50ms interval. This ensures it's gone before
        //  the panel starts to slide back in, creating a nice effect.
        //
        trigger('titleTextTrigger', [
            state('in', style({ opacity: '1' })),
            transition('void => *', [style({ opacity: '0' }),
                animate('100ms 300ms')
            ]),
            transition('* => void', [
                animate('50ms', style({ opacity: '0' }))
            ])
        ]),

        // Define the animation used in the arrow icon where it rotates to point left
        //  or right based on the state of the panel. In this case we use the same 
        //  timings as the width animation above so these two transitions happen at 
        //  the same time.
        //
        trigger('iconTrigger', [
            state('collapsed', style({ transform: 'rotate(0deg)' })),
            state('expanded', style({ transform: 'rotate(180deg)' })),
            transition('collapsed => expanded', animate('200ms ease-in')),
            transition('expanded => collapsed', animate('200ms ease-out'))
        ])
    ]
})
export class NotificationPanelComponent implements OnInit, OnDestroy {  
    expanded = false;
    expandedState = 'collapsed';

    notifications: Notification[];
    notificationSub: Subscription;

    constructor(
        private notificationService: NotificationService
    ) { 
    }

    ngOnInit() {
        this.notificationSub = this.notificationService.notifications$.subscribe((notifications) => {
            this.notifications = notifications.sort((a, b) => b.date.valueOf() - a.date.valueOf()).slice(0, 10);
        });
     }

    ngOnDestroy() {
        this.notificationSub.unsubscribe();
    }

    toggleExpandedState() {
        this.expandedState = this.expanded ? 'collapsed' : 'expanded';
        this.expanded = !this.expanded;
    }
}

I tried to put in clear comments to illustrate my thought process on the animations, so I won't elaborate too much more here. The gist is each of our animation triggers are defined in the animations array within the @Component decorator. There are a lot of options within each trigger, but I tried to keep things as simple as I know how right now. Let's move on to the NotificationComponent next.

The NotificationComponent

As before, let's first examine the template:

<div class="icon" @visibleTrigger="'visible'" [ngSwitch]="notification.type">  
    <i *ngSwitchCase="0" class="fa fa-comment"></i>
    <i *ngSwitchCase="1" class="fa fa-exclamation-triangle"></i>
    <i *ngSwitchCase="2" class="fa fa-code"></i>
    <i *ngSwitchCase="3" class="fa fa-credit-card"></i>
</div>  
<div *ngIf="expanded" @visibleTrigger="'visible'" class="details">  
    <div class="message">
        <span>{{notification.message}}</span>
    </div>
    <div class="date">
        <span>{{notification.date | date:'mediumTime'}}</span>
    </div>
</div>  

This is pretty simple and we have some logic to change the icon based on the type of notification, and a "details" div that is shown only when the notification is expanded. We have a single animation trigger, @visibleTrigger, that defines an animation to use on both the icon and the details div. What's interesting here is the icon is normally always visible, so that trigger only takes effect when a new notification is rendered in the DOM. However, the same trigger is applied to the details when the notification is added, or when the panel is expanded. Let's look now at the component itself:

import {  
    Component,
    OnInit,
    Input,
    trigger,
    state,
    style,
    transition,
    animate
} from '@angular/core';

import {Notification, NotificationType} from "../shared/index";

@Component({
    moduleId: module.id,
    selector: 'notification',
    templateUrl: 'notification.component.html',
    styleUrls: ['notification.component.css'],
    animations: [
        // Define an animation that adjusts the opactiy when a new item is created
        //  in the DOM. We use the 'visible' string as the hard-coded value in the 
        //  trigger.
        //
        // When an item is added we wait for 300ms, and then increase the opacity to 1
        //  over a 200ms time interval. When the item is removed we don't delay anything
        //  and use a 200ms interval.
        //
        trigger('visibleTrigger', [
            state('visible', style({ opacity: '1' })),
            transition('void => *', [style({ opacity: '0' }), animate('200ms 300ms')]),
            transition('* => void', [animate('200ms', style({ opacity: '0' }))])
        ])
    ]
})
export class NotificationComponent implements OnInit {  
    @Input() notification: Notification;
    @Input() expanded: boolean;

    notificationTypes = NotificationType;

    constructor() { }

    ngOnInit() { }
}

This is not too much different from the panel's animation for the title bar text where we're animating the opacity change. What I don't like about this is the timings used in this component need to consider those used in the parent panel component to ensure everything flows properly on the screen. For example, it wouldn't look right if the notification took 2 seconds to fade out when the panel only took 200ms to shrink down.

Perhaps since this a standard parent/child component pair this might not be a problem, but it's not hard to imagine the NotificationComponent being used in another panel component. If that had different timings, or perhaps no animations at all, then the portability of these components could easily be compromised.

My initial thoughts on all this

While I think it's great that someone like me can build this without being very experienced in web animations in general, I can already see that you need to be careful with Angular 2 animations. I think it'd be really easy to create some unruly animation logic in your component decorator, but I imagine some helpful patterns will emerge over time. I'm sure there are cleaner ways to do what I did in this article, but a few questions jumped out as I worked on the code:

  • How do I ensure timings are shared by multiple components?
    • Can I somehow define these at a top-level and ensure my components respect them?
  • What about styles?
    • I can embed sass into my component's styles, but how does that play with the styles I put into my animations?
  • What about keeping the component agnostic to the details of the template?
    • I get the component is just defining a trigger, but the use of styles seems to tie it closely to a specific template...but maybe I'm wrong.

Like I said, I'm hopeful that patterns will emerge that will help make the animations in an Angular 2 app easier to manage. I think right now I'll remain cautious in how I use them until I feel more comfortable.

Wrapping up

In this article I started exploring how to use Angular 2's new animation functionality to create a common UI pattern...the slide out notification drawer. I walked through a simple example that uses multiple animated elements to present a non-trivial (at least to me) example.

I am certainly not proposing the animation code I wrote for this example is a best practice, but I wanted to get an example out there and see if others have started doing anything similar. Please check out the full code sample in my github repository:

https://github.com/sstorie/experiments/tree/master/angular2-animated-notifications-panel

If you have any thoughts, comments or feedback on what I wrote up here please leave a comment. Thanks for reading!

comments powered by Disqus