The code side of life

Native slide toggle / collapse animation using Angular 2 and Animate

Introduction

user

Shlomi Assaf


Angular TypeScript AngularJS Animation Collapse SlideToggle animate

Native slide toggle / collapse animation using Angular 2 and Animate

Posted by Shlomi Assaf on .
Featured

Angular TypeScript AngularJS Animation Collapse SlideToggle animate

Native slide toggle / collapse animation using Angular 2 and Animate

Posted by Shlomi Assaf on .

Note:

This article is obsolete. The animation module in the upcoming angular 2 RC2 has a different API and a different approach.

In Angular 2 RC2+ slide toggle is a non brainer, the animation module comes with a property calculation feature which automatically calculates a css property (e.g: height). Note that this is not like setting height: auto in vanilla CSS which will not trigger a transition. simply set height from * to 0, that's it.

A year ago I wrote a post about implementing a native slide toggle for AngularJS 1.4.x+, it seems that Angular 2 is mature enough so we can try tackling it with Angular2 and the angular2/animate module.

I'v tried to explain every step I took while developing, I'f you're completely new to Angular 2 I suggest to start with the tutorials first, if you've played with angular 2 a bit you'll be fine.

The current animate module for Angular 2 is still in the works. Though Angular 2 is in beta I feel that the animate module is more close to alpha then beta.

In the AngularJS version I have used the $animateCss module to trigger CSS animations, we will take the same approach and use the CssAnimationBuilder class from the animate module in Angular 2.

Angular 2 animate module structure

TL;DR

This section provides some background about the new animate module, I strongly recommend reading it but its not crucial for understanding.

From the way things look now (beta 15) the animate module is built on top of a central animation class called Animation which manages the life cycle of an animation process, from start to completion and also provide logic for element interaction (add/remove styles/classes).

The Animation class seem's to support CSS animation only, at this point. I'm sure JS support will add as we progress to Angular 2 final.

To ease animation integration, animate introduces the concept of builders which are high level, fluent API interfaces for building animation sequences. Using Fluent API is pretty smart and provides an expressive way to describe an animation.

Currently there is only 1 builder, the CssAnimationBuilder which makes sense, but as more features introduced into Animation we will see more builders, at least a JsAnimationBuilder I assume.

While developing I had at least 3 ideas for extending the current builder or creating new ones, I'm sure the community will provide a lot of those making animation a simple task.
For example, the builder can hold logic for multiple animation instances, one can implement a smart builder that fires multiple animation in parallel or as a sequence, just what's next by a set of configurable predicates or even better callback, ovservables and what not! Crazy.

CssAnimationBuilder

CssAnimationBuilder is not @Injectable, it means that you can't request it via Dependency Injection. From the way things look at the source you can import and instantiate it but you will need to supply some constructor parameters which force you to use an @Injector, quite a fuss. Luckily we don't care, the AnimationBuilder is a factory/facade the handles everything for us.

The AnimationBuilder is a factory for CssAnimationBuilder (an other builders in the future) that is @Injectable and easy to use, a one stop shop for animation builders.

The process looks something like this:

  • Inject AnimationBuilder to your service/directive/component
  • Create a new CssAnimationBuilder instance
  • Expressively create your animation sequence.
  • Start the animation.

AngularJS vs Angular 2

In the AngularJS version of the native slide toggle we used a CSS class selector for registering and activating our animation control. When an element contains that CSS class our animation service wraps the element and start listening to CSS class add/remove events, specifically the ng-hide event. Once a add/remove event for ng-hide was triggered the animation service kicked in. This was done by creating a angular recipe like this:

angular.module('myApp')  
    .animation('.slide-toggle', ['$animateCss', function($animateCss) {
       // logic here
    };

In the Angular 2 version we will use the same selector approach but we will centralise it around a directive.
Angular 2 is all about components, this is true for animation as well.
It is a powerful concept, we use the same API all over.
Let's see how a rough representation of the code above looks like in Angular 2:

@Directive({
    selector: '.slide-toggle'
})
export class SlideToggle {  
    private _animation: CssAnimationBuilder;
    constructor(animationBuilder: AnimationBuilder) {
        this._animation = animationBuilder.css();
    }
}

We see that CssAnimationBuilder is the Angular 2 version on $animateCss in AngularJS.

Directives vs Components

If you get confused by the terms directive, component and their role in AngularJS vs Angular 2 I suggest deep diving into the tutorials in angular.io.

Here a short walkthrough: The role of a Directive is to provide extra functionality and logic to UI Elements, usually DOM Elements.
A Component is also about providing functionality for UI Elements but it also about describing the UI itself. Every Component is a Directive, the Component class inherits/extends the Directive class.

So when I say Angular 2 is all about components it also means Angular 2 is all about directives

What's so powerful here?

In AngularJS calling app.animation requires us to provide a selector as the first parameter, app.service requires a service name as the first parameter, app.run a function as the first parameter and so on... you get the point.

Looking at the AngularJS code, it's hard to figure right away that this animation function wraps all elements with the selector provides in the first parameter.
Looking at the Angular 2 code it's easy to figure that out.

Implementation

A slightly different approach

The CssAnimationBuilder does not support acting upon CSS class change event at this moment so we will take a slightly different approach, we will trigger a slide toggle based on state, a boolean value. Think ngIf but instead of creating or destroying elements we will hide them with animation. This is the same behaviour implemented by by bootstrap Collapse plugin.

Basic logic

  • Use an attribute directive
  • Bind the value of the attribute (boolean) to a property on the directive.
  • Listen to changes in the attribute value.
  • On change, trigger animation.

Living inside a Angular 2 Directive

A Directive extends a UI Element. It can add new attributes to the element, emit output values, change state and add logic. A Directive also enjoys the rich eco system Angular 2 has to offer, Dependency Injection, Change Detection and Life cycle management are the core features we will use in our directive.

Building the flow

First let's build a basic flow, how changes in state flow through our directive and trigger an action. There is no animation here, just handling everything up to the point when we need to trigger an animation.

import {Directive, OnChanges, ElementRef, Input} from 'angular2/core';  
import {AnimationBuilder} from 'angular2/src/animate/animation_builder';  
import {CssAnimationBuilder} from 'angular2/src/animate/css_animation_builder';

@Directive({
    selector: '[collapse]'
})
export class Collapse implements OnChanges {  
  @Input() collapse: boolean;
  private _animation: CssAnimationBuilder;

  constructor(animationBuilder:AnimationBuilder, private _el:ElementRef) {
    this._animation = animationBuilder.css();
  }

  ngOnChanges(changes) {
    // if the change happened in the collapse property
    if (changes.collapse) {
      if (this.collapse) {
        // Logic to hide the element
      } else {
        // Logic to show the element
      }
  }
  }
}

Several things to note here

Attaching the directive to an attribute

Our directive will extend UI elements that have the collapse attribute.

<div class="my-class" collapse></div>  

Using dependency injection

We request the animation builder service as well as the ElementRef instance which represents the UI Element attached to this Collapse instance.

Binding to external data source

By declaring an @Input annotation we are telling Angular to create a One-Time binding for the collapse attribute.

@Input() collapse: boolean;

A user of our directive provides and expression as the value of the collapse attribute, Angular will make sure to update that expression when changes occur.

<div class="my-class" [collapse]="isCollapsed"></div>  

Notice the square brackets [ ], it instructs angular to treat the value of the attribute as an expression, if we remove them angular will treat it as string literal.
The expression isCollapsed refer to a property isCollapsed on the instance of the host component.

Implementing the OnChanges Life-Cycle hook

By implementing the OnChanges interface we are now able to get notification when the collapse attribute changes, this will allow us to trigger animation on such event.

Internal module imports

For animation modules, we are using direct imports into internal paths, this is not ideal but it is the status at this point, once issue #5983 resolves we can do import {AnimationBuilder} from 'angular2/animate';

Running animations

We have 2 animations to create:

  • Hide / slide-up
  • Show / slide-down

The key point in this animation is measuring the element height. This is why this animation needs some JS help, it is not possible with transitions and key-frames.

We will use 3 CSS classes to manage the UI state as well as programmatically setting inline element styles.

.collapse {
    display: none;
}

.collapse.in {
    display: block;
}

.collapsing {
    position: relative;
    height: 0;
    overflow: hidden;
    -webkit-transition-timing-function: ease;
    -o-transition-timing-function: ease;
    transition-timing-function: ease;
    -webkit-transition-duration: .35s;
    -o-transition-duration: .35s;
    transition-duration: .35s;
    -webkit-transition-property: height, visibility;
    -o-transition-property: height, visibility;
    transition-property: height, visibility;
}

Look's familiar? it is the bootstrap CSS classes for collapse.

You can achieve the same animation functionality without using CSS classes (with a lot of extra code), I have chosen this path to demonstrate animation using both styles and classes.

Our directive will manage the animation by changing the CSS classes on the element and supplying the height value as an inline style.

Using CSSAnimationBuilder

We will use the builder to create the Hide and Show animations.
After some refactoring, it turns out that Hide and Show have common animation instructions, so I'v created a function to create those:

  private get _baseSequence(): CssAnimationBuilder {
    return this._animation
      .setDuration(250) // the transition duration
      .removeClass('collapse') // remove a class before the transition start
      .removeClass('in')
      .addAnimationClass('collapsing') // add a temp class for the transition period
  }

First thing to notice is that CssAnimationBuilder implements a Fluent API, this means that almost every method return the CssAnimationBuilder instance. In the example above this._animation is our instance of CssAnimationBuilder and the method return it as well, so calling this._baseSequence() return this._animation (with modified state)

Seconds thing that pops up right away is how easy it is to understand what's going on, it is so expressive and self explaining, I have added comment just in case.

Hide

    hide(): void {
        this._baseSequence
            .setFromStyles({
                height: this._el.nativeElement.scrollHeight + 'px'
            })
            .setToStyles({
                height: '0'
            });

        // a is the Animation instance running this animation.
        let a = this._animation.start(this._el.nativeElement);
        a.onComplete(() => {
            a.removeClasses(['in']); // rapid change will leave 'in'
            a.addClasses(['collapse']) 
        });
    }

Our Hide implementation is straightforward, preform a transition from the element's height to 0, once done add the collapse class.
We must add the collapse class at the end since it set display: none which will hide our slide animation.

Show

    show(): void {
        this._animation // 1st animation build 
            .setDuration(0)
            .addClass('collapse')
            .addClass('in')
            .setFromStyles({
                    overflow: 'hidden'
             })
            .start(this._el.nativeElement) // 1st animation start 
            .onComplete(() => {
                let a = this._baseSequence //  2nd animation build 
                    .setFromStyles({
                        height: '0'
                    })
                    .setToStyles({
                        height: this._el.nativeElement.scrollHeight + 'px'
                    })
                    .start(this._el.nativeElement); // 2nd animation start 

                a.onComplete(() =>  a.addClasses(['collapse', 'in']) );
            });
    }

Our Show implementation is slightly more complex, it has 2 steps:

  • Make sure the element has a height
  • Same as Hide but the opposite way, preform a transition from 0 to element's height

The 1st step is the extra step, we need it because at the time we run show() the element is in a collapsed state where CSS display: none is applied, it means that the height is actually 0. We run a quick animation that change the state of the element so it is not collapsed (by adding the class in). To prevent flickering when a hide() and show() runs at the same time (rapid clicking, close before open is done) we set the style overflow: hidden.

The 2nd step is just hide() upside down with an extra style instruction to cancel the overflow we set at the first step.

Note about the extra step:

We could save the height before calling hide(), this will remove the need for the first step but on un-collapsing after a resize will set a wrong height, this in turn can be fixed by listening to resize events...

Plunker

I'v made a plunker demo with some sugar and better support.
In this demo you can use the duration attribute to control the length of the transition (in ms) or ignore it for default (500ms).
I'v also added some support for a11y (aria attributes) and smoother animation by enabling animation for padding and applying padding styles on hide/show.

Next?

  • Getting rid of native element usage.
  • A nice feature will be to add an onComplete attribute to notify when the element is close/open.
  • Another feature is to disable animation on first load, i.e: if the first value is false (showing) then don't display the animation. This is easy to implement, the ngOnChanges lifecycle hook provides an object that holds SimpleChange items as values, take a look at the SimpleChange interface and figure it out :)
export declare class SimpleChange {  
    previousValue: any;
    currentValue: any;
    constructor(previousValue: any, currentValue: any);
    /**
     * Check whether the new value is the first value assigned.
     */
    isFirstChange(): boolean;
}

Early adopter alert

It is important to understand the current state on angular2/animate, it is still in early stages and things might change.
As time progress more solutions to tackle this issue will pop up.

Thanks for reading, if you have any comments or suggestions please post below.

user

Shlomi Assaf