Basic Framework for Elm HTML Animations
I’ve been dabbling a bit with the mdgriffith/elm-style-animation package for Elm 0.18 and so far it looks like quite a capable library. I did encounter some headwinds with layering multiple effects on the same HTML element at different times, so I scratched out a basic framework for extending animations. I’ll walk you through the basic extensions I’ve layered on top of the standard Elm Architecture.
What’s the goal of all this? Being able to define easily reusable animations, just like CSS classes! And being able to manage the complexity that comes along with creating many different animations.
Read on for details, or just grab the code from this Github repo and run with it.
First, some bookkeeping
From a blank directory, you can elm-make
to generate the basic project structure. Then, you’ll need to elm-package install
the following packages:
- elm-lang/html – for basic HTML functions
- elm-community/easing-functions – for use of easing functions
- mdgriffith/elm-style-animation – for animations
I use Visual Studio Code to manage all this stuff, as I’ve written previously.
Also, our imports:
import Animation import Animation.Messenger import Dict exposing (..) import Ease exposing (..) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Task exposing (..)
The standard Elm Architecture
Okay! Let’s walk through the setup from the standard Elm Architecture. Not much interesting going on here, but I’ll highlight a couple of things. This section is all meant to be cookie-cutter, that you can just copy and paste into your own project as a starting point.
The app entry point
Our app entry point isn’t anything special. I’ll circle back around to initializeApp
later.
-- ENTRY POINT main : Program Never Model Msg main = Html.program { init = initializeApp , subscriptions = subscriptions , update = update , view = view }
The Model
Up next comes our Model
:
-- MODEL type alias AnimationId = String type alias Model = { animations : Dict AnimationId (Animation.Messenger.State Msg) , animationMappings : AnimationMappings } type alias AnimationMappings = Dict AnimationId (Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg)
AnimationId
is a simple type alias to access an animation by name.
The basis of our Model
consists of two things: The actual, current animations we have (animations
), and a set of mappings for retrieving an animation by name (animationMappings
). This is primarily a helper to manage complexity. It doesn’t strictly have to be in our Model
, but I didn’t want to rebuild the dictionary every time I accessed it.
The type on our animations
looks a little funny, but it’s just a dictionary to a set of animations in progress, hashed by their identifier.
AnimationMappings
looks even funnier. Here’s what it is: A dictionary that, given an animation identifier, yields a function that takes a Maybe
animation as input and yields a new animation as output. I’ll show you how we’re using this in a bit. Why this funny type? It just fell out naturally as I cleaned up and reduced code footprint. I promise, its usage is actually pretty clean, but I would never have thought to build it this way from the beginning! (This is an example of starting from a specific implementation, then building a cleaner abstraction from it.)
Then, we have a couple of helper functions:
initializeModel : Model initializeModel = Model empty animationMappings -- Return the animation, or if the animation hasn't been defined yet, use the provided default animationOrDefault : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg -> Animation.Messenger.State Msg animationOrDefault manim def = case manim of Nothing -> def Just a -> a
Our initializeModel
function isn’t anything special. It references a custom animationMappings
definition that we’ll come back to in a bit.
The animationOrDefault
function is just a simple helper I found myself using in each of my animation definitions. It simply takes a Maybe
animation, returns the Just
definition, or else a supplied default so that we always have a valid animation.
Subscriptions and View
-- VIEW view : Model -> Html Msg view = appView -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = Animation.subscription UpdateAnimation <| values model.animations
Our view
just redirects to a custom function we’ll supply later. I only wrote it this way to keep the basic view
definition within the “cookie-cutter” part of the code.
Our subscriptions
are responsible for communicating updates to our animations. Here, we simply take the values from our animations
dictionary and feed them into the animation package’s Animation.subscription
messenger for relaying updates. UpdateAnimation
is just a regular Msg
definition that we’re about to see.
Update
So far, so good! The only real complexity comes with our update
part of the standard Elm Architecture. I’ll walk through it slowly.
First, our Msg
type:
-- UPDATE type Msg = Initialize -- Start a new animation | ExecuteAnimation AnimationId -- Update an "in progress" animation | UpdateAnimation Animation.Msg
Here, we just define a few types. Initialize
, for when our app starts up. ExecuteAnimation
starts a new animation, given an identifier for the animation. UpdateAnimation
is for responding to animation update subscriptions, as we just saw used.
And now, for our actual update
function:
update : Msg -> Model -> (Model, Cmd Msg) update msg model = case msg of Initialize -> ( model, Cmd.none ) ExecuteAnimation animationId -> let _ = Debug.log ("ExecuteAnimation " ++ animationId) () executeAnimation dict = case get animationId model.animationMappings of Just f -> insert animationId (f <| get animationId dict) dict Nothing -> dict in ( { model | animations = executeAnimation model.animations } , Cmd.none ) UpdateAnimation animMsg -> let f k v = Animation.Messenger.update animMsg v |> (\(v, c) -> (c, (k, v))) (cmds, anims) = Dict.toList model.animations |> List.map (\(k, v) -> f k v) |> List.unzip in ( { model | animations = Dict.fromList anims } , Cmd.batch cmds )
This is where things get a little more hairy. Thankfully, Initialize
is nothing special, so we’ll move on.
ExecuteAnimation
, at its root, just finds a known animation with the given identifier and adds it to our set of active animations – that’s what our model
update is doing at the bottom. The executeAnimation
helper function isn’t actually doing much: It just looks for the specified animation and adds it to the current dictionary, or else just returns the original dictionary. Once we have a valid mapping, this is where our funny AnimationMappings
type comes in. We get the current animation with the same identifier and feed it into our selected mapping. Then, we take the result and plug it back into our dictionary of current animations.
Confused? It will make more sense once we see our individual animation definitions, but basically what it’s doing is this:
- For clarity, we log the
ExecuteAnimation
message to the console, so that we know what’s been fired. You can remove this if you want. - We find the specified animation. If the specifier is unknown, we don’t update anything.
- We add the new animation to our current animations dictionary, feeding the new animation our current (pre-existing) animation with the same identifier, if it already existed.
The reason why we’re feeding in our existing animations is so that we can interrupt any animations currently active. This is handled by the animation package’s internal details, but we still need to feed in the current state.
UpdateAnimation
also looks a little hairy, but it’s really not bad:
- We take our currently active animations; then
- Feed each of them through the animation package’s
Animations.Messenger.update
function to generate updated animations, doing some reshuffling of the tuple result; then - Assigning the updated animations back into our
model
; and - Executing any lingering
Cmd
results that we were given.
The funny tuple shuffling is just to make it easier to unzip
the resulting Cmd
and animation updates into separate lists. Part of why it looks so funny is that we’re actually dealing with a dictionary of animations, remember? So, we have some k
keys and v
values floating around in there.
Custom stuff specific to your app
Surprisingly, once you wrap your head around it, there’s not much going on in the cookie-cutter section, but it turns out to be surprisingly powerful! From that reusable basis, you can focus on layering on the effects of your individual animations, without getting distracted by the details of the plumbing. Just what every developer wants!
I’ll run through the simple, custom app I built for the demo. Your code will be entirely different, but it will give you a taste for how I’m structuring things.
First, our custom appView
that we referred to earlier:
appView : Model -> Html Msg appView model = div ( (values model.animations |> List.map Animation.render |> List.concat) ++ [ onClick (ExecuteAnimation "fade_out_fade_in") , style [ ( "position", "relative" ) , ( "margin", "100px auto" ) , ( "padding", "25px" ) , ( "width", "200px" ) , ( "height", "200px" ) , ( "background-color", "#268bd2" ) , ( "color", "white" ) ] ] ) [ text "Click to Animate!" ]
It’s just a clickable box with some text, shamelessly stolen from the animation package’s original demo. There are only really two interesting things going on here: We’re telling all of our active animations to render on the element; and our onClick
event is being told to fire an ExecuteAnimation
by the identifier "fade_out_fade_in"
.
This gives us something to actually look at, other than a blank canvas. The only pieces we’re missing now are our app initialization and our actual animation definitions. First, the app initialization:
-- Initialize and fire off any "at startup" animations initializeApp : (Model, Cmd Msg) initializeApp = ( initializeModel , Task.perform (\identifier -> ExecuteAnimation identifier) (Task.succeed "fade_in") )
Not much going on here, except that we’re telling our app to fire off an animation as soon as it starts, by the identifier "fade_in"
. You can easily extend this to perform multiple animations as soon as the app starts.
That just leaves us with our animation definitions! First, we start with our known identifier-to-animation mappings:
-- Define function mappings for our different hashed animations animationMappings : AnimationMappings animationMappings = Dict.fromList [ ( "fade_in", fadeIn ) , ( "fade_out_fade_in", fadeOutFadeIn ) ]
Pretty simple, we’re just building a dictionary of identifier keys that link to specific animation functions.
The "fade_in"
animation is an example just about the simplest definition of animation you can find, with some extra layering:
-- Example of basic animation usage fadeIn : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg fadeIn manim = Animation.interrupt -- Animation to perform [ Animation.to [ Animation.opacity 1 ] ] -- Prior animation state, or a default (animationOrDefault manim (Animation.style [ Animation.opacity 0.0 ]))
We’re defining this in three parts:
- We’re saying this is an animation that should interrupt any previously running animation.
- We’re defining the new animation as a simple transition to full opacity.
- We’re supplying a default static animation with full transparency to start from.
All of our animation function definitions will follow this 3-part format. The only really interesting part of the default animation is the actual definition; the rest just feeds back into the funny Maybe
-typed function definitions we ran across earlier.
For a little more complexity, we’re going to customize the "fade_out_fade_in"
animation:
-- Example of animation with custom duration and easing fadeOutFadeIn : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg fadeOutFadeIn manim = Animation.interrupt -- Animation to perform [ Animation.toWithEach [ (Animation.easing { duration = 500, ease = linear }, Animation.opacity 0) ] , Animation.toWithEach [ (Animation.easing { duration = 300, ease = outExpo }, Animation.opacity 1) ] ] -- Prior animation state, or a default (animationOrDefault manim (Animation.style [ Animation.opacity 1.0 ]))
This is just a demonstration of how to build a more complicated animation. Once again, our three parts:
- We’re saying this is an animation that should interrupt any previously running animation.
- We’re defining the new animation. This demonstrates using different easing methods at different durations to first fade out to full transparency, then back in to full opacity. Using
Animation.toWithEach
isn’t really necessary here (we could simply useAnimation.toWith
instead), but we can easily extend each of these lists with multiple other effects, all to be run at the same time. - We’re supplying a default static animation with full opacity to start from.
And that’s it! If you run the code, you’ll see the block fade in, then you can click it to see it fade out and back in again. If you click it rapidly, you can see how the animation interrupt works; the new animation will simply pick up from whatever the current state of the block happens to be.
Parting thoughts
As you build out more animations, all you need to do at this point is extend the animationMappings
we saw at the end and define a new 3-part animation function.
Most of the abstraction complexity comes down to solving two goals:
- Being able to easily find and execute an animation, given a specific identifier (primarily, to avoid having to traverse lists to find the animation).
- Being able to just define an animation in a “fire and forget” way that doesn’t require interweaving definitions for the animation in different places. That’s the primary intent of the “default” definitions – putting all of our animation information in the same place.
If you try to naively thread these throughout your code, you’d be surprised how effects won’t run correctly because of how different animations might stomp on one another. Getting around this, you might run into trouble with not having a starting place to run an animation from (the animation package will actually log errors in this case).
I ran into this exact scenario when I was playing around with the animations, which led to iterating until I had built this little framework. The issue was that I wanted two different transparency animations on the same element: One to fire on app start and another to fire when I clicked on the box. With my first implementation attempt, the second animation was stomping on the first. A bit of a play later and this natural extension of the Elm Architecture was the result.
I hope you find this useful! And I’m always curious to hear other ways to solve this problem.
Cheers!
2 comments