Swapping Elm Animations
I recently wrote about a handy extension to layer animations on top of the standard Elm Architecture.
This post explores taking the basic animation framework and building more complex animations upon it.
Read on for details, or just grab the code from this Github repo and run with it. You’ll want to look for the SwapView.elm
module.
For a more complex animation than what we saw last time, we’re going to simply have two clickable elements which will alternate visibility, depending upon which element is clicked. This is simple enough, but it demonstrates the ability to make more feature-rich interfaces more interesting. For example, the same behavior could be used to swap out different steps through a multi-part form.
If you haven’t already look at the original post, you’ll want to take a look at it now. I’m going to start from that basis as a primer and just show the parts that are new or changed. Visualizing changes can be a little easier with a diff tool between the the two completed code modules; I apologize that the formatting of this blog platform is a little limiting!
Model updates
First up, we have some changes to our Model
. This is really, really simple and actually changes nothing! It’s 100% intended to help guide the programmer, but makes zero difference to the app.
-- MODEL type alias ElementId = String ... type alias Model = { animations : Dict ElementId (Animation.Messenger.State Msg) , animationMappings : AnimationMappings }
All we’ve done here is define a new type alias, ElementId
, and we’ve updated the animations
field of our Model
to refer to use this new type alias as the key comparator for our animations dictionary. Keeping with the theme of template/custom code separation that I talked about in the last post, this is a cookie-cutter change that you can just copy as-is into your own projects.
This doesn’t actually change anything, because we previously had used AnimationId
as the key, which was also a type alias for a String
! But, we’re going to differentiate how each ID is used as we go further and I wanted to make the distinction more obvious than simply having multiple String
uses floating around, making you wonder “Which String was that again?”
Updating our… Update
Next, we’re going to extend the Update tier of our Elm Architecture.
First, we update our Msg
definition to contain a reference for our more complex animation layering.
-- UPDATE type Msg = Initialize -- Start a new animation | ExecuteAnimation ElementId AnimationId -- Update an "in progress" animation | UpdateAnimation Animation.Msg -- Swap visibility of two elements | SwapElements ElementId ElementId
Here, we’ve defined a new message, SwapElements
, that will take two different references to the new ElementId
type alias that we just defined. We’ll use this to swap from the first ElementId
to the second ElementId
. This new message will likely be custom code for your app; this is the one non-cookie-cutter piece that we’ll see mixed in with our template code.
We’ve also made a tiny tweak to our ExecuteAnimation
message to also pass it an ElementId
. Let’s take a look at that change in our update
function:
update msg model = ... ExecuteAnimation elementId animationId -> let _ = Debug.log ("ExecuteAnimation " ++ elementId ++ " " ++ animationId) () executeAnimation dict = case get animationId model.animationMappings of Just f -> insert elementId (f <| get elementId dict) dict Nothing -> dict in ( { model | animations = executeAnimation model.animations } , Cmd.none )
We haven’t changed much here. We just pass in a new elementId
variable and update our log message, to start with. Then, we updated our Just f -> insert elementId ...
line to use our new elementId
, rather than the previous animationId
definition. We’re still using the animationId
just above to retrieve the animation from our defined mappings. The net change is that we now define our ongoing animations in terms of the elements they apply to, rather than the names of the animations themselves.
Why did I make this change? First, it just makes more intuitive sense, but I had to stumble over a subtle bug to figure this out. If you take our completed code and change this back to using animationId
in both places of the insert
line and run the code, you might see that the animation will sort of work the first time, if you’re lucky, but then everything just stops working. This is because of a nuance within the mdgriffith/elm-style-animation package. If you try to define multiple animations on the same property, only the last definition will be used – all others will be ignored!
What we’ve done here is make each element only have one currently running animation. This makes sense intuitively, but also doesn’t cause any problems, as the animations package and the way we’ve previously layered our framework nicely allow us to interrupt and transition from ongoing animations.
The mdgriffith/elm-style-animation
package does contain some debugging output to warn against this scenario if you explicitly define an animation with the same property more than once, but our case obscures this check due to the way we’re building up animations piece by piece. Finding this bug took me quite a while to figure out!
The last update to our update
function (that’s just fun to say!) is to define our new SwapElements
message. This can be really simple, but I’m also going to define a couple new helper functions to help us out here.
type alias Update = (Msg -> Model -> (Model, Cmd Msg)) chainUpdate : Update -> Msg -> (Model, Cmd Msg) -> (Model, Cmd Msg) chainUpdate updateFunc msg (model, cmds) = let (model_, cmds_) = updateFunc msg model in model_ ! [ cmds, cmds_ ] performChainedUpdates : Update -> Model -> List Msg -> (Model, Cmd Msg) performChainedUpdates updateFunc model cmds = List.foldl (chainUpdate updateFunc) (model ! []) cmds update msg model = ... SwapElements srcView destView -> performChainedUpdates update model [ (ExecuteAnimation srcView "fade_out") , (ExecuteAnimation destView "fade_in") ] {- LOOKS LIKE THIS WHEN UNROLLED let (m1, c1) = update (ExecuteAnimation srcView "fade_out") model (m2, c2) = update (ExecuteAnimation destView "fade_in") m1 in ( m2, Cmd.batch [ c1, c2 ] ) -}
The helper stuff we’ve added is just to help manage the chaining. It looks obscure, but it’s really just doing type mappings and transformations, using the Core Elm Platform.Cmd module. The new type alias Update
should look familiar – it’s just the type of our update
function! We just use this to clean up the type definitions of our new helper functions a bit, to make them more readable.
All of this sums up to make our new SwapElements
message handling a bit easier to manage. The new definition just chains two commands one after the other to execute two different animations. With our helper functions, this reads very cleanly and you don’t have to worry about keeping the model definitions chained correctly. If you did it by hand, it would look like the commented out piece. Here, you have to be careful to put the updated model variables in the right places, otherwise you’ll lose information. It also become more painful to maintain as we chain more and more commands. Our helper functions nicely do away with all this complexity and boilerplate. (I really should just release those helpers as an independent, standalone Elm package…)
Updating the View
We’ve got one last piece of template code to define, then everything we’re going to see will be custom to your application.
The template code is also really simple:
-- VIEW renderAnimationsByElementId : ElementId -> Model -> List (Attribute Msg) renderAnimationsByElementId elementId model = toList model.animations |> List.filter (\(k, _) -> k == elementId) |> List.map (\(_, v) -> Animation.render v) |> List.concat
This is a simple helper function that will help each view element pick out the appropriate animations to render. We saw this before, for the most part, in the custom view definition of the last post. The only thing we’ve done is to add the filter by elementId
, to ensure that views aren’t rendering animations that were intended for other views!
As for our new custom view, I’ll just skim over it:
appView : Model -> Html Msg appView model = div [] [ element01View model , element02View model ] element01View : Model -> Html Msg element01View model = div ( (renderAnimationsByElementId "Element01" model) ++ [ onClick (SwapElements "Element01" "Element02") , style [ ( "position", "relative" ) , ( "margin", "100px auto" ) , ( "padding", "25px" ) , ( "width", "200px" ) , ( "height", "50px" ) , ( "background-color", "#268bd2" ) , ( "color", "white" ) ] ] ) [ text "Click to Animate Element01!" ] element02View : Model -> Html Msg element02View model = div ( (renderAnimationsByElementId "Element02" model) ++ [ onClick (SwapElements "Element02" "Element01") , style [ ( "position", "relative" ) , ( "margin", "100px auto" ) , ( "padding", "25px" ) , ( "width", "200px" ) , ( "height", "50px" ) , ( "background-color", "#8bd226" ) , ( "color", "white" ) ] ] ) [ text "Click to Animate Element02!" ]
We haven’t done much, except to split appView
into two different view element definitions. Also, note how each element uses distinct IDs for renderAnimationsByElementId
and the onClick (SwapElements elem1 elem2)
handler.
I’m also going to update the initializeApp
function to change the animations that will fire on app start:
-- Initialize and fire off any "at startup" animations initializeApp : (Model, Cmd Msg) initializeApp = ( initializeModel , Cmd.batch ( List.map (Task.perform (\(elementId, animationId) -> ExecuteAnimation elementId animationId)) [ Task.succeed ("Element01", "fade_in") , Task.succeed ("Element02", "opac_0") ] ) )
This isn’t much different than what we did in the last post, except this time, we’re running more than one animation (we’ll see the new definition for “opac_0” in a moment). This isn’t anything special beyond the Core Elm Platform.Cmd and Task modules, although I’ve seen a lot of confusion about commands and tasks online. Just remember, initializeApp
has the same final output as our update
function, so we need to get the types to match up. This will help guide you through any compilation problems when defining and performing tasks.
The very last bit is to define our new animations. Using the framework we came up with last time, this is trivially easy:
-- Define function mappings for our different hashed animations animationMappings : AnimationMappings animationMappings = fromList [ ( "fade_in", fadeIn ) , ( "fade_out", fadeOut ) , ( "fade_out_fade_in", fadeOutFadeIn ) , ( "opac_0", opac_0 ) , ( "opac_1", opac_1 ) ] -- Example of basic animation usage fadeOut : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg fadeOut manim = Animation.interrupt -- Animation to perform [ Animation.to [ Animation.opacity 0 ] ] -- Prior animation state, or a default (animationOrDefault manim (Animation.style [ Animation.opacity 1 ])) -- Example of basic animation usage opac_0 : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg opac_0 manim = Animation.interrupt -- Animation to perform [ Animation.to [ Animation.opacity 0 ] ] -- Prior animation state, or a default (animationOrDefault manim (Animation.style [ Animation.opacity 0 ])) -- Example of basic animation usage opac_1 : Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg opac_1 manim = Animation.interrupt -- Animation to perform [ Animation.to [ Animation.opacity 1 ] ] -- Prior animation state, or a default (animationOrDefault manim (Animation.style [ Animation.opacity 1 ]))
We’ve just defined a few new animations, according to the style we’ve gone over previously:
- fadeOut – this does just what it says; it fades opacity to zero.
- opac_0 – this simply sets opacity to zero, immediately (without animating).
- opac_1 – this simply sets opacity to fully visible, immediately (without animating).
Whew!
And with that, we can run our code and see how different view elements can interact with different animations! We really didn’t do much to extend on top of the basic framework we defined last time, now we’re just doing something a tiny bit more interesting with it. We’re also getting closer to something that can be individually released as an Elm package!
But, before we get there, there’s one bug in our visual interface. Can you spot it? We’ll find it and deal with it in the next post. 🙂
Cheers! And happy Elm hacking!
One comment