Catching Completed Elm Animations

This post continues a series I’ve been writing as I toy with the mdgriffith/elm-style-animation Elm package. I’ve primarily been focused on both learning how to use the animation package, but as I’ve explored, there seems to be room for leveraging a higher layer of abstraction to more rapidly compose and deploy animations. Maybe things will work out, or maybe I’ll crash and burn. Either way, it has been a fun journey so far and I’ve enjoyed talking with the Elm community and the animation package’s author, Michael!

Let’s recap what we’ve covered so far. I’ll be mainly discussing changes that I’ve made to the previous code, so you’ll want to take a look at these posts (especially the first post) before we begin.

  • In the first post, I discussed a basic framework for Elm animations that seemed to have fallen out naturally as I was manipulating data types for composing animations.
  • In the second post, I extended this framework to perform more complex behavior like swapping view elements for controlling transitions in flow control.

In this post, I’m going to explore detecting the lifecycle of animations and triggering commands based on that lifecycle.

Read on for details, or just grab the code from this Github repo and run with it. You’ll want to look for the FinishAnimation.elm module.

As I’ve done previously, I’ll walk through the changes I’m making to the previous code examples. I encourage you to run the end result, FinishAnimation.elm, in your browser, so that you can see where we’re headed. This post involves no visible changes, so I encourage you to keep an eye on the developer console and source explorer as you manipulate the animations. For the code, I’m going to start from the SwapView.elm example in the GitHub repo and we’re going to evolve it a bit.

There seems to be a bit of a missing piece in the animation package (Michael, if you’re reading this, please don’t take it personally!). We can define animations, we can execute animations, we can update animations… But, there’s no direct way for observing the completion of an animation.

In many cases, this is just fine, because we just want to “fire and forget” the animation. In other cases, we may want to queue up a subsequent animation or immediate styling change. The animation package allows this, although it requires defining the subsequent animation along with the running animation.

Queuing non-animation or styling behavior is a little bit trickier. For example, maybe you’d like to trigger a Google Analytics event when an animation completes, as it signifies a new kind of view event. Or, perhaps you’d like to tie the animations into some automated test framework; these signals will help.

At the end of the last post, I hinted at a bug. If you played with the interface a bit and kept an eye on the Elm debugger, you might have noticed that you could click on each of the view elements after they go fully transparent. If these were real form elements, this would be highly undesirable! We wouldn’t want to be able to click on view elements before they were ready.

What I’m going to do is thread in a little bit of extra control to detect animations completing, in order to make the view elements not just transparent, but fully hidden. Technically, this could be done more easily by just queuing up instantaneous styling animations, but the main intent is to show how we can capture completed animations for other purposes.

Enough talk, where’s the code?

We’re going to jump around a bit more this time, to show related changes. I’m going to dive right in to the trickiest part.

type alias AnimationTuple =
  ( List (Animation.Messenger.Step Msg) -> Animation.Messenger.State Msg -> Animation.Messenger.State Msg
  , List (Animation.Messenger.Step Msg)
  , Animation.Messenger.State Msg
  )

type alias AnimationMappings =
  Dict AnimationId AnimationTuple

Here, we’re defining a new AnimationTuple and updating our AnimationMappings dictionary to return this definition, rather than the (Maybe (Animation.Messenger.State Msg) -> Animation.Messenger.State Msg) we had previously. Unfortunately, this implies we’re going to need to matriculate this change through all of our animation definitions.

This is a side effect of using the animation package to extend existing animations. Unfortunately, it appears that an animation can’t currently be modified once a creation function is used to generate a Animation msg result, as we’ve previously been doing. So instead, we use our new AnimationTuple definition to return the requisite parts of an animation. We’ll later combine these pieces.

All of our animation definitions will need to be tweaked to accommodate this new structure. I’ll just show one; they all change the same way:

-- Example of basic animation usage
fadeIn : AnimationTuple
fadeIn =
  ( -- Style of animation execution
    Animation.interrupt
    -- Animation to perform
  , [ Animation.to [ Animation.opacity 1 ] ]
    -- Default starting animation state, if none exists
  , Animation.style [ Animation.opacity 0 ]
  )

Rather than executing the Animation.interrupt creation function immediately, we simply return it and the other two parts, as a 3-tuple.

A minor Model tweak

For the next part of setup, we’re going to update our Model:

type alias Model =
  { animations        : Dict ElementId (Animation.Messenger.State Msg)
  , animationMappings : AnimationMappings
  , viewDisplayStyles : Dict ElementId String
  }
 
initializeModel : Model
initializeModel =
  Model empty animationMappings empty

We’re defining a new dictionary for controlling some sort of model state outside of the animation framework. Don’t get too hung up on this being a styling change (which an animation can easily also provide), just know that we’re doing something else with it.

That something else is to define the display style on each view element to hide or reveal it. This is pretty simple:

element01View : Model -> Html Msg
element01View model =
  let
    displayStyle =
      case get "Element01" model.viewDisplayStyles of
        Nothing -> "block"
        Just x  -> x
  in
    div
      (  (renderAnimationsByElementId "Element01" model)
      ++ [ onClick (SwapElements "Element01" "Element02")
        , style
            [ ( "position", "fixed" )
            , ( "margin", "100px auto" )
            , ( "padding", "25px" )
            , ( "width", "200px" )
            , ( "height", "50px" )
            , ( "background-color", "#268bd2" )
            , ( "color", "white" )
            , ( "left", "40%" )
            , ( "display", displayStyle )
            ]
          ]
      )
      [ text "Click to Animate Element01!" ]
 
element02View : Model -> Html Msg
element02View model =
  let
    displayStyle =
      case get "Element02" model.viewDisplayStyles of
        Nothing -> "block"
        Just x  -> x
  in
    div
      (  (renderAnimationsByElementId "Element02" model)
      ++ [ onClick (SwapElements "Element02" "Element01")
        , style
            [ ( "position", "fixed" )
            , ( "margin", "250px auto" )
            , ( "padding", "25px" )
            , ( "width", "200px" )
            , ( "height", "50px" )
            , ( "background-color", "#8bd226" )
            , ( "color", "white" )
            , ( "left", "40%" )
            , ( "display", displayStyle )
            ]
          ]
      )
      [ text "Click to Animate Element02!" ]

Update the update!

The last changes are solely concentrated in the Msg and update definitions. First, we add a new AnimationCompleted Msg:

type Msg
  = Initialize
  -- Signal completion of an animation
  | AnimationCompleted ElementId AnimationId
  -- Start a new animation
  | ExecuteAnimation ElementId AnimationId
  -- Update an "in progress" animation
  | UpdateAnimation Animation.Msg
  -- Swap visibility of two elements
  | SwapElements ElementId ElementId

…and we have to add a handler for it in the update function:

update msg model =
  ...
    
    AnimationCompleted elementId animationId ->
      let
        _ = Debug.log (toString msg) ()
        displayStyle =
          if animationId == "fade_out" || animationId == "opac_0"
            then "none"
            else "block"
        viewDisplayStyles = insert elementId displayStyle model.viewDisplayStyles
      in
        ( { model | viewDisplayStyles = viewDisplayStyles }
        , Cmd.none
        )

This is pretty simple; we just listen for an AnimationCompleted message, check which animation completed, and determine the display of the affected view element accordingly. In your code, whatever you do here will likely be 100% custom.

The last piece is where the magic happens.

    ExecuteAnimation elementId animationId ->
      let
        _ = Debug.log (toString msg) ()
        executeAnimation dict =
          case get animationId model.animationMappings of
            Nothing -> dict
            Just (f, xs, def) ->
              let
                startAnim = animationOrDefault (get elementId dict) def
                xs_ = xs ++ [ Animation.Messenger.send (AnimationCompleted elementId animationId) ]
              in
                insert elementId (f xs_ startAnim ) dict
        viewDisplayStyles = insert elementId "block" model.viewDisplayStyles
      in
        ( { model | animations = executeAnimation model.animations, viewDisplayStyles = viewDisplayStyles }
        , Cmd.none
        )

All the new stuff really exists within the Just pattern match. I’ll touch that in a second. The viewDisplayStyles is the only other change; we’re just initializing the affected view element to make sure it’s always visible when an animation starts. Otherwise, you may not see the animation, if it was previously hidden!

Inside the Just pattern match, let’s walk through it line by line.

Just (f, xs, def) ->

Here, we’re just pattern matching on the AnimationTuple we defined at the beginning. We have our creation function, f, our list of animations to execute, xs, and our default animation, def.

startAnim = animationOrDefault (get elementId dict) def

This should look familiar. We just moved the animationOrDefault function usage into our update, rather than within the internals of the animation definitions. This actually makes those definitions read a little cleaner, which is where we really want our focus.

xs_ = xs ++ [ Animation.Messenger.send (AnimationCompleted elementId animationId) ]

Here, we’re taking the function we’d defined and appending a last “animation” to it. What we’re doing is telling the animation message pump to fire off our AnimationCompleted command as the final segment of the animation.

Lastly, we create the animation:

insert elementId (f xs_ startAnim ) dict

We insert the animation into the dictionary after executing the creation function on our manipulated parameters.

That’s it!

That actually didn’t take too many changes; we really just needed to modify our animations to return the tuple structure.

Most of this is highly custom code. It’s really just an example of how to signal the end of an animation and do something with interesting with it.

In the next post, we’ll look at leveraging some more of Elm’s strengths by making our animation definitions a bit safer.