The code side of life

Native slide toggle for AngularJS 1.4.x

Introduction

user

Shlomi Assaf


Native slide toggle for AngularJS 1.4.x

Posted by Shlomi Assaf on .
Featured

Native slide toggle for AngularJS 1.4.x

Posted by Shlomi Assaf on .

The latest angular 1.4 release comes with major changes, most of are in 2 modules: animation & routing (e.g: the new router).

The guys @ google changed the animation module significantly yet managed to keep the API backwards compatible, extending with new animation services that joined the neighborhood. In fact, it could be easily tagged ngAnimate 2.0, but its backward compatibility makes it 1.4 and yes, it will work on both 1.x and 2.0 (same for the new router)

I believe that ngAnimate 1.4 is the first step for ngAnimate becoming a high grade animation service.

jQuery slideToggle in Angular

Up to 1.4, trying to mimic jQuery.slideToggle() in AngularJS was not a simple task.

  • Using CSS transitions/keyframes is insufficient, the height is unknown.
  • Using directives can do the trick, but it seems to be out of context and of course needs a manual hook into the ngAnimate service.

I always thought jQuery slide animation should be a trivial task for angular, but it wasn't so, up until ngAnimate 1.4.

Here comes $animateCss

Simply put, $animateCss is a new service enabling JavaScript based animation and CSS animation to flow together, either by hooking to core ngAnimate events (enter, leave, etc..) or by invoking animations from $animate. You can read more about it here

You can think of $animateCss as a dynamic CSS class generator. The dynamic part (JS) gives you the ability to change CSS property values before initiating an animation, on the fly.

It is important to understand that $animateCss implements animation using CSS transitions/keyframes thus it will not work in IE9 and below.

In the example below, I chose to implement slide animation for ng-show/ng-hide events. (You can find enter/leave events in the documentation)

app.animation('.slide-toggle', ['$animateCss', function($animateCss) {
    return {
        addClass: function(element, className, doneFn) {
            if (className == 'ng-hide') {
                var animator = $animateCss(element, {                    
                    to: {height: '0px', opacity: 0}
                });
                if (animator) {
                    return animator.start().finally(function() {
                        element[0].style.height = '';
                        doneFn();
                    });
                }
            }
            doneFn();
        },
        removeClass: function(element, className, doneFn) {
            if (className == 'ng-hide') {
                var height = element[0].offsetHeight;
                var animator = $animateCss(element, {
                    from: {height: '0px', opacity: 0},
                    to: {height: height + 'px', opacity: 1}
                });
                if (animator) {
                 return animator.start().finally(doneFn);
                }
            }
            doneFn();
        }
    };
}]);

The idea is simple, before we want to show an element, measure the size of the element then create a transition from 0 to the size of the element, and the opposite way if we want to hide the element. Note that the naming is confusing, there is only one CSS class for show/hide state - ng-hide so removeClass actually implements show.

The example shows minimal use of CSS but it is mainly for simplicity, you can mix multiple CSS classes and add some sugar to them with dynamic values.

See it in action:

See the Pen AngularJS slide Toggle (Without cancel support) by Shlomi Assaf (@shlomiassaf) on CodePen.

Hey, I'm a rapid clicker!

The example above is simple, it doesn't assume a user might click the button multiple time.
In such event the code needs to stop the animation and, from the current state, re-run the opposite animate.

Thankfully, $animateCss supplies a cancel callback via the animator object's end property.

Simple is really simple...

In the first example we used a fire & forget approach, we don't care about the state when we fire the animation and we don't care what happens afterwards. The code is stateless.

State your purpose

To handle callbacks we need to manage states. Each event comes with an element, we need to identify each element and cache its animation state. I chose to assign an ID to each element, this allows me to handle cancel/re-run request separately for each element.

I wrapped everything inside the animation but you can use another cache resource if you'd like.
I'v added some helper function to manage the cache and id's of elements.

app.animation('.slide-toggle', ['$animateCss', function($animateCss) {
        var lastId = 0;
        var _cache = {};

        function getId(el) {
            var id = el[0].getAttribute("data-slide-toggle");
            if (!id) {
                id = ++lastId;
                el[0].setAttribute("data-slide-toggle", id);
            }
            return id;
        }
        function getState(id) {
            var state = _cache[id];
            if (!state) {
                state = {};
                _cache[id] = state;
            }
            return state;
        }

        function generateRunner(closing, state, animator, element, doneFn) {
            return function() {
                state.animating = true;
                state.animator = animator;
                state.doneFn = doneFn;
                animator.start().finally(function() {
                    if (closing && state.doneFn === doneFn) {
                        element[0].style.height = '';
                    }
                    state.animating = false;
                    state.animator = undefined;
                    state.doneFn();
                });
            }
        }

        return {
            addClass: function(element, className, doneFn) {
                if (className == 'ng-hide') {
                    var state = getState(getId(element));
                    var height = (state.animating && state.height) ? 
                        state.height : element[0].offsetHeight;

                    var animator = $animateCss(element, {
                        from: {height: height + 'px', opacity: 1},
                        to: {height: '0px', opacity: 0}
                    });
                    if (animator) {
                        if (state.animating) {
                            state.doneFn = 
                              generateRunner(true, 
                                             state, 
                                             animator, 
                                             element, 
                                             doneFn);
                            return state.animator.end();
                        }
                        else {
                            state.height = height;
                            return generateRunner(true, 
                                                  state, 
                                                  animator, 
                                                  element, 
                                                  doneFn)();
                        }
                    }
                }
                doneFn();
            },
            removeClass: function(element, className, doneFn) {
                if (className == 'ng-hide') {
                    var state = getState(getId(element));
                    var height = (state.animating && state.height) ?  
                        state.height : element[0].offsetHeight;

                    var animator = $animateCss(element, {
                        from: {height: '0px', opacity: 0},
                        to: {height: height + 'px', opacity: 1}
                    });

                    if (animator) {
                        if (state.animating) {
                            state.doneFn = generateRunner(false, 
                                                          state, 
                                                          animator, 
                                                          element, 
                                                          doneFn);
                            return state.animator.end();
                        }
                        else {
                            state.height = height;
                            return generateRunner(false, 
                                                  state, 
                                                  animator, 
                                                  element, 
                                                  doneFn)();
                        }
                    }
                }
                doneFn();
            }
        };
    }]);

The idea is to save the animator so we can cancel the animation later, if we need to. Remember that a "close" request fired while a "open" request is still in animation must have 2 animators. 1 used to cancel the running "open" animation and one used to start the "close" animation. For each call there is a done callback supplied by $animateCss, this callback will run only for non-cancelled animations, so if a cancel request is set, the callback is not the original done callback but the start of a new animation runner.

See it in action:

Click the buttons multiple times, the animation will gracefully turn around.

See the Pen AngularJS slide Toggle (With cancel support) by Shlomi Assaf (@shlomiassaf) on CodePen.

Summery

Slide toggling is now an easy to implement feature, it doesn't need any workaround like directives and/or clever hacks, we get there by simply using the module we should use, ngAnimate.
I didn't check performance but it should be ok, if you find any bugs and/or performance issues please comment below.

Update: Instead of animator.start().finally... use animator.start().done. done does not fire a digest cycle and should be used when ever possible.

user

Shlomi Assaf