Animating Ember
From the beginning
Rob Harper / @rdharper
Jan 9, 2014
Press s
to see presentation notes
Why bother?
Animation is a requirement to make a web app feel like a native app on a mobile device
Animations help the user preserve context and help show relationships between application states and actions
Why us?
Ember-animated-outlet is a great solution for a set of animation needs (route transitions)
Adding animation to the core library has been discussed , but it's not trivial to design well
But at the app level, it's not so hard
Route Transitions (back button) {{outlet}}
Modals ContainerView or {{outlet}}
...any view coming or going CollectionView or {{each}}
Route transitions: Navigating data hierarchies complete with back button support
Modals: Reveal effects
Lists: deleted items slide away
Implementing all of these boil down to a single view class: ContainerView. Outlets are containers in which the current route's view is rendered. Lists are CollectionViews, subclasses of a container.
Let's start with route animation and modals
Ember.ContainerView = Ember.View.extend(Ember.MutableArray, {
/**
If you would like to display a single view in your ContainerView,
you can set its currentView property. When the currentView property
is set to a view instance, it will be added to the ContainerView. If
the currentView property is later changed to a different view, the
new view will replace the old view. If currentView is set to null,
the last currentView will be removed.
*/
currentView: null
});
ContainerView - it manages the lifecycle of views within it: added views are placed
on the DOM and rendered; removed views are removed from the DOM and destroyed. Its implementation
has a contains a little sugar for managing a single "currentView" using property binding.
CurrentView Default Behaviour
Old View
container.removeObject( oldCurrentView );
New View
container.pushObject( newCurrentView );
We can boil the parts of ContainerView's handling of currentView that we care about down to these two lines of code. Remember that the ContainerView is managed just like an array. Views added to the array are added to the DOM; views removed from the array are removed from the DOM.
Reimplementing Default Behaviour
Edit this example
Here's the simple test harness we'll use as we develop our animation capabilities. We'll subclass ContainerView and get to work. Clicking within the dotted box will advance through steps of the test case, the test case code for the step will be shown below.
Here's our ContainerView subclass with currentView handling reimplemented (copied).
Old View
Ember.run.later( function() {
container.removeObject( oldCurrentView );
}, 2000 );
New View
container.pushObject( newCurrentView );
Recall that ContainerView
manages an array of views
Let's make a simple change to the removal of the view being dismissed. Let's defer that for 2 seconds.
This is fairly useless but illustrates an important point about the ContainerView: recall that it's an array. Just because currentView only allowed to be a single view at any time, the ContainerView can contain as many views as we like. This is arguably the most important point in this presentation.
Edit this example
Notice how the view being removed now sticks around for 2 seconds before being destroyed. Clicking quickly can build up a whole queue of views to be destroyed.
Old View
oldCurrentView.$().fadeOut( 2000, function() {
container.removeObject( oldCurrentView );
});
New View
container.pushObject( newCurrentView );
Change the deferred removal to a fade out then deferred removal.
Old View
oldCurrentView.$().fadeOut( 2000, function() {
container.removeObject( oldCurrentView );
});
New View
container.pushObject(newView);
newView.one('didInsertElement', function() {
newView.$().hide().fadeIn(2000);
});
Adding a fade in will allow us to complete a cross-fade effect. Two things to note:
We have to wait until the new view receives didInsertElement. Until then it is not on the DOM and there isn't an element to fade in
We need to use a little css to position the views ontop of each other
Edit this example
Quick and dirty cross-fading ContainerView in a few lines of code. It's not production ready because there are a number of edge cases and gotchas to consider (covered later) but it's functional.
Now What?
Our ContainerView can easily handle fading in and out modal views by binding to currentView. But what about route transitions?
{{outlet view="myAnimatedContainer"}}
Routes and Modals
Luckily all an outlet is is a ContainerView whose currentView is managed by the Ember Router. By specifying the viewClass to use in an outlet we can instruct Ember to use our subclass and our route changes start animating. This also has the effect of animating on the browser's back button - just another route transition.
Using outlets for modal view containers is also a common pattern and works the same way
Instead of animating currentView
, animate all the views!
Override CollectionView's arrayWillChange
and arrayDidChange
Getting collections ({{each}}) to work requires extending the concepts we applied when changes occur to the single currentView property to a changing array of views.
Edit this example
I've extracted the code to reveal and dismiss views into helper methods
A large amount of the code here is copied from the base implementation of the array change methods. The current implementation doesn't provide hooks to allow us to manipulate the way views are added and removed from the DOM so we have to override the entire method. Breaking up this logic into overridable template methods is arguably the easiest first step for Ember to support animation in the core.
One complicating factor in the implementation is the need to keep a parallel array of views. In the base implementation of the CollectionView the list of child views matched the array content model 1:1. However since we are deferring the removal of views to animate out this relationship is broken. If the content array removes the 3rd item, we need to know which view represents that item.
Whole container...
{{outlet view="myAnimatedContainer" effect="drop"}}
...or by view
MyAnimatedContainer.defineTransition({
from: 'some-parent-view',
to: 'some-child-view',
effect: 'slideLeft',
duration: 500
});
MyAnimatedContainer.defineTransition({
from: 'some-child-view',
to: 'some-parent-view',
effect: 'slideRight',
duration: 250
})
Before discussing some of the gotchas, let's add features!
Whole container: We could control the animation effect on the whole container - every view is given the same treatment. Reasonable choice for many cases
By view: Alternately we could register view-to-view "state transitions"
Delegate to child views...
FadingView = Ember.Mixin.create({
reveal: function() {
var self = this;
return new Ember.RSVP.Promise( function(resolve, reject) {
self.$().hide().fadeIn(500, resolve);
});
},
dismiss: function() {
var self = this;
return new Ember.RSVP.Promise( function(resolve, reject) {
self.$().fadeOut(500, resolve);
});
}
});
MyAlertModal = Ember.View.extend( FadingView, { /* ... */ });
Delegate: Take the effects out of the container and let the child views themselves handle reveal/dismiss while communicating effect completion with promises (although this still requires style-coordination between incoming and outgoing views, e.g. layout)
...or CSS3 Transitions
Enter / Leave Start During
Enter .em-animate .em-enter .em-enter-active
Leave .em-animate .em-leave .em-leave-active
CSS3: Take an Angular ng-animate approach and use css classes for animation setup and execution - my preferred approach. The details of the effect are entirely captured in CSS, nothing in JS.
Caveats and Gotchas
not quite production ready yet...
Container Destruction: Animations could interact with destroyed views
Animation Queues: Jump to the end of active animation when a second is triggered
Initial State: How should view / collection items that start in container be handled?
Container Destruction: If the container is destroyed during an animation it will short circuit the child view destruction. When the animation complete timeout fires both the container and the child view are destroyed. Need to guard.
Animation Queues: Without this you get the effect as we saw earlier with several views animating at once. Canceling can be a pain using CSS3 transitions
Initial State: Initial view contents should probably not animate in. Adding views in arrayDidChange should check to see if container is on the DOM yet and if not, don't animate children in.
Caveats and Gotchas
not quite production ready yet...
Deferred destruction: Two views on the DOM at once, active bindings
Performance: Child view creation can cause major jitter in the animation
Same route, new data: Route's view is reused so no animation
Deferred destruction: Often not an issue but could be. For example, a 3rd party plugin that expects only to be in the DOM once, such as a commenting widget. Also, the view being dismissed still has active bindings which, depending on your app design, could mean it changes as it animates out to reflect new app state.
Performance: If the inbound view contains an iframe that must then load, the animation will stutter terribly. This may necessitate a whole new set of lifecycle signals around animation (didStart, didComplete, didCancel, etc)
Same route, new data: Ember reuses the same route view if the model for the route changes but the view/controller class does not. As such no transition between states will occur. This is not a trivial problem to solve as @ghedamat can attest!
Wrapping Up
Manipulating view lifecycle in a ContainerView can be easy...
...but it means overwriting existing functions, copy pasta
Adding animation to your Ember app can be easy...
...but animation as part of the Ember is not trivial
Robust view lifecycle with deferred support in Ember core → implement animation support with better building blocks