Velocity.js animations in Mithril
Some people have asked me how they could get animations to work with Mithril, so today we'll look at how to run some simple Velocity.js-powered animations in Mithril templates.
Velocity.js is a library that allows us to run Javascript-based animations which perform on par with CSS transitions (and which work in IE). It's designed to be a drop-in replacement for jQuery's animate
method, but it can also be used as a standalone library (i.e. without jQuery).
The easiest way to get started with it is to include a reference to it from a CDN:
<script src="//cdn.jsdelivr.net/velocity/1.0.0/velocity.min.js"></script>
Calling Velocity as standalone library typically looks like this:
Velocity(theElement, {opacity: 0})
The code above should hopefully be self-explanatory: it animates the opacity of an element from its current value to zero. So, assuming it started at opacity: 1
, then it performs a fade-out effect.
Let's create a contrived mini-application so we can hook up some animations. First let's define some data:
//model
var people = [
{id: 1, name: "John"},
{id: 2, name: "Mary"},
{id: 3, name: "Bob"}
]
Next, let's create a view to display a list of people:
//view
var view = function() {
return m("ul", [
people.map(function(person) {
return m("li", person.name)
})
])
}
And finally, let's add a bit of functionality: removing a person when its list item is clicked.
//controller
var controller = function() {
this.remove = function(person) {
people.splice(people.indexOf(person), 1)
}
}
//view
var view = function(ctrl) {
return m("ul", [
people.map(function(person) {
return m("li", {
key: person.id,
onclick: ctrl.remove.bind(this, person)
}, person.name)
})
])
}
One thing to notice is that we added a key
attribute to the <li>
. This is usually good practice if you delete list items because it allows Mithril's virtual DOM diffing engine to be smarter about DOM reuse by providing referential metadata.
Putting it all together:
//model
var people = [
{id: 1, name: "John"},
{id: 2, name: "Mary"},
{id: 3, name: "Bob"}
]
//controller
var controller = function() {
this.remove = function(person) {
people.splice(people.indexOf(person), 1)
}
}
//view
var view = function(ctrl) {
return m("ul", [
people.map(function(person) {
return m("li", {
key: person.id,
onclick: ctrl.remove.bind(this, person)
}, person.name)
})
])
}
//run the app
m.module(document.body, {controller: controller, view: view})
As we saw earlier, in order to create an animation with Velocity, we need to pass a DOM element as the first argument. In Mithril, templates like the view
function above are merely javascript functions that spit out javascript objects, but we can get a handle to the real DOM element by declaring a config
attribute.
The config
callback gets called after rendering occurs, when all the DOM elements generated by the template are guaranteed to be attached to the HTML document. It is meant to be used for arbitrary DOM manipulation.
var fadesIn = function(element, isInitialized, context) {
if (!isInitialized) {
element.style.opacity = 0
Velocity(element, {opacity: 1})
}
}
var view = function(ctrl) {
return m("ul", [
people.map(function(person) {
return m("li", {
key: person.id,
onclick: ctrl.remove.bind(this, person),
config: fadesIn
}, person.name)
})
])
}
In the snippet above, we defined a new helper function called fadesIn
, which we use as a config
callback for the li
. It sets the list item's opacity to zero, and then animates it back to 1 (i.e. it fades the element in)
The element
argument is, as the name suggests, the <li>
element.
The second argument, isInitialized
is a flag that is set to false on initial rendering, and true for subsequent renders. As you can see, we used it to run the animation only when the element gets created, as opposed to running it every time a redraw occurs.
The third argument is an object that can be used to store element-specific data between redraws. You can read more about it here
With this config
callback in place, you should now see the list items fade in when the page loads.
What about fading out?
Fading out is not that much harder to implement, but there's a cognitive dissonance caveat associated with it that often confuses people.
When Mithril redraws, views always look at the current state of the data to figure out what DOM elements should or should not be in the document. But fading out after removing an element from the model breaks that thought model: by definition, an animation starts and ends at different times, so if we remove a person from the list, the system would need to know somehow that its corresponding <li>
element should stay in the document for the duration of the animation, even though the person was already removed from our data object at the beginning of the animation.
But because we're using a third-party library to integrate animations to Mithril, the framework would not be able to hide some of the complexities that come with the asynchronous nature of the animations. For example, what is the framework supposed to do with a DOM element if an animation is cancelled mid-way? Should it force you to rollback the deletion in the data model? Should it assume that you will clean up the DOM element manually? If you allow the framework to remove it for you, it might do it too early (e.g. if a redraw happens during a animation), but if you forget to remove it manually or mark it for removal, there's no way the system would know when to handle it, and the element would just sit there forever.
A simpler solution to this conundrum is to shift back in time and allow the animation to happen before the destructive data change happens, and only then, allow the removal of the person to happen atomically in our model layer. Here's a helper that runs the animation before the removal:
var fadesOut = function(callback) {
return function(e) {
//don't redraw yet
m.redraw.strategy("none")
Velocity(e.target, {opacity: 0}, {
complete: function() {
//now that the animation finished, redraw
m.startComputation()
callback()
m.endComputation()
}
})
}
}
The snippet above defines a helper function called fadesOut
, which returns an event handler that runs an animation, and then runs an arbitrary callback when the animation finishes.
The m.redraw.strategy("none")
line tells Mithril that we don't want to redraw when the event handler returns (because at that point, nothing has changed yet).
The complete
callback that we pass to Velocity.js is an asynchronous 3rd party callback, so there we call m.startComputation
and m.endComputation
to tell Mithril that we want to potentially redraw. Notice that we are not calling m.redraw
because that function forces a redraw to happen immediately. We might conceivably want to run AJAX requests or other asynchronous operations in the callback
function, so we need to use m.startComputation
and m.endComputation
to allow Mithril to wait for those asynchronous operations to complete.
Using the fadesOut
helper is simple: just wrap it around the remove
function.
var view = function(ctrl) {
return m("ul", [
people.map(function(person) {
return m("li", {
key: person.id,
onclick: fadesOut(ctrl.remove.bind(this, person)),
config: fadesIn
}, person.name)
})
])
}
Now we have list items that fade in on page load, and fade out when we click on them. Here's the entire code so far.
//model
var people = [
{id: 1, name: "John"},
{id: 2, name: "Mary"},
{id: 3, name: "Bob"}
]
//controller
var controller = function() {
this.remove = function(person) {
people.splice(people.indexOf(person), 1)
}
}
//view
var view = function(ctrl) {
return m("ul", [
people.map(function(person) {
return m("li", {
key: person.id,
onclick: fadesOut(ctrl.remove.bind(this, person)),
config: fadesIn
}, person.name)
})
])
}
//view helpers
var fadesIn = function(element, isInitialized, context) {
if (!isInitialized) {
element.style.opacity = 0
Velocity(element, {opacity: 1})
}
}
var fadesOut = function(callback) {
return function(e) {
//don't redraw yet
m.redraw.strategy("none")
Velocity(e.target, {opacity: 0}, {
complete: function() {
//now that the animation finished, redraw
m.startComputation()
callback()
m.endComputation()
}
})
}
}
//run the app
m.module(document.body, {controller: controller, view: view})
What about page changes?
The code above works well for actions within a single page, but what if we want to run animations when jumping between routes?
As per the documentation, config: m.route
is the idiomatic way of creating routed links, but there's nothing stopping us from using a custom config
function instead, if we want to run animations before leaving a page. Here's how one might go about implementing it:
//helper
var fadesOutPage = function(element, isInitialized, context) {
if (!isInitialized) {
element.onclick = function(e) {
e.preventDefault()
Velocity(document.getElementById("container"), {opacity: 0}, {
complete: function() {
m.route(element.getAttribute("href"))
}
})
}
}
}
//in the templates
m("#container", {config: fadesIn}, [
m("a[href='/foo']", {config: fadesOutPage}, "go to foo")
])
As you can see, the code is strikingly similar what we have been doing before.
The fadesIn
helper makes the page fade in when it loads, as it did before.
On the link, we defined an onclick
handler that calls Velocity to run some animations on the container element and then redirect using m.route after the animation is done. One difference is that defining an event handler within a config
callback doesn't require us to call m.redraw.strategy("none")
. Recall that config
is designed to be used for integrating non-Mithril code to Mithril templates, and in this case the onclick
handler is just plain vanilla javascript, which doesn't do any auto-redrawing. In addition, we don't need to call m.startComputation
and m.endComputation
either because the m.route()
redirect forces the page to redraw anyways.
Note that we could have used onclick
as we did with fadesOut
, instead of config
- There's really no hard rules for whether you should use one or the other. In this article, I used onclick
for fadesOut
to make it clear that there's a cause-effect relationship between clicking and fading and removing a person, and I used config
for fadesOutPage
to make it look consistent with the way regular {config: m.route}
links do. But as we saw, the default rendering strategy when we attach an onclick
in the template required us to add some extra code to prevent auto-redrawing from happening in that particular event handler. The rule of thumb is that an onclick
handler auto-redraws by default (for the sake of convenience in the data-model-updating case), whereas config
has auto-redrawing turned off by default (for convenience in the free-reign-over-the-DOM case). As we saw, it's perfectly possible to change these defaults, so just use your best judgement to decide what option makes your code the most readable.
comments powered by Disqus