Always Animating
A Word From Our Sponsor: Ourselves! 🤣
We just released Paktol our first paid app (free 2-month trial) to the stores:
- 🍎 App Store,
- 🤖 Google Play.
Paktol is a mindful spending app that helps change your spendings habits without having to plan and categorize. It's as simple as playing "Hot or Cold"!
If you fail at budgeting, give it a try!
Looking for new and great projects!
With Paktol out, we are now available for new opportunities.
If you are looking for help from experimented Clojure devs, reach out to us!
And Now, On To Our Main Feature!
:animate
101
Recently we added a new :animate
directive to cljd.flutter/widget
macro.
Here is the code for a simple button which displays a quantity. When clicked the quantity is incremented by 20.
(f/widget
:watch [qty (atom 100) :as *qty]
(m/TextButton
.onPressed #(swap! *qty + 20))
(m/Text (str qty)))
Now when we add :animate [qty qty]
, it gets more lively!
(f/widget
:watch [qty (atom 100) :as *qty]
:animate [qty qty] ; 👈 🎁 🤔
(m/TextButton
.onPressed #(swap! *qty + 20))
(m/Text (str qty)))
How does it work? Well let's first make [qty qty]
less mystifying:
(f/widget
:watch [qty (atom 100) :as *qty]
:animate [interpolated-qty qty] ; 👈 👍
(m/TextButton
.onPressed #(swap! *qty + 20))
(m/Text (str interpolated-qty)))
Each time qty
changes, interpolated-qty
will be successively bound (once per frame) at an intermediate value between the old and new values of qty
!
The animation's duration and curve can be tweaked:
(f/widget
:watch [qty (atom 100) :as *qty]
:animate [interpolated-qty qty
:duration (Duration .seconds 2) ; 🐌
:curve m/Curves.slowMiddle] ; ▶️⏸️▶️
(m/TextButton
.onPressed #(swap! *qty + 20))
(m/Text (str interpolated-qty)))
:animate
102
The above section lied by oversimplification.
Each time
qty
changes,interpolated-qty
will be successively bound (once per frame) at an intermediate value between the old and new values ofqty
!
In fact there are two lies: a trivial one and a fundamental one.
The trivial one is using the word "between" as a curve may overshoot the target value (or even the initial value) to provide some elastic feeling:
The fundamental one is that the interpolation is done between the old and new value of qty
. It's wrong! The interpolation is done between the current interpolated value of the animation (even if it's at rest) and the new value of qty
.
This has two consequences:
-
you can change course of an animation without having glitches, see for example if we repeatedly click on the button with the slow animation:
-
it's that animation state space is a super set of model state space. It may mean little to you but to Christophe it was an actual epiphany.
(For the record, Christophe has been toying with Cavalry App and indulging himself in Animation and Motion Design youtubes too much.)
Before going further we need to see how one can write custom interpolations.
If you are like us, you may think that animation is only for numeric quantity or things made of numbers (like sizes, opacities, matrices, colors...).
It's not true: you can animate anything! A string, a map!
However we don't provide a default interpolation (default interpolations are provided for int
, double
, Offset
, Size
, TextStyle
, Color
, Alignment
, Border
, EdgeInsets
, Rects
, ThemeData
and more) for strings or maps.
Interpolatings strings
Let's say we want to animate a string value as if it was typed and only backspace could be used to make edits.
Thus if you want to animate from "Hello World"
to "Hello Clojure"
you have to first repeatedly backspace to "Hello "
and then enter the new characters. Below is an interpolation function which does just that.
Interpolation functions are curried: instead of taking three arguments (a
(from), b
(to) and t
within [0..1]
or its vicinity), it takes a
and b
first so to have a stage where everything independent of t
can be precomputed and then returns a function expecting t
.
(defn term-text-lerp [a b] ; from a to b
(let [; not efficient but it's teaching material so 🤷♂️
common-prefix-length (count (take-while true? (map = a b)))
; precomputing all steps at once, could be better to compute them on the fly
steps (-> []
(into (map #(subs a 0 %)) (range (count a) common-prefix-length -1))
(into (map #(subs b 0 %)) (range common-prefix-length (inc (count b)))))
n (count steps)]
(fn [t] ; don't use with curves producing values out of [0..1]
; b as default is there for then t is 1.0
(nth steps (* t n) b))))
And now the whole widget:
(f/widget
:managed [*text (atom "") :dispose false]
m/Column
.children
[(m/TextField
.onSubmitted (fn [x] (reset! *text x) nil))
(f/widget
:watch [text *text]
:animate [text text
:duration (Duration .seconds 2)
:lerp term-text-lerp]
(m/Text (str text "◼️")
.style (m/TextStyle .fontSize 36 .fontFamily "courier")))])
Interpolating maps
Now what about animating where values spontaneously appear and disappear? Framed like this it seems weird but it describes some synchronization mechanism, including concurrent modifications.
Let's fake it with a stream of maps. We produce maps whose keys are integers between 0 and 11 and whose values are the generation at which the entry was added. At each generation, we chose a key at random, if it was present we dissoc it, if it was absent we assoc it.
(f/widget
:watch [m (let [*m (atom {})]
(Stream.periodic
(Duration .milliseconds 300)
(fn [n]
(let [k (rand-int 12)]
(swap! *m (fn [m] (if (m k) (dissoc m k) (assoc m k (str "#" k " at " n)))))))))]
(m/ListView
.children
(for [[k v] (sort-by key m)]
(f/widget
:key k
m/Center
(m/Text v .style (m/TextStyle .fontSize 24 .height 1.2))))))
If we want to animate it, we have to put (like mentioned before) in a bigger state space (or bigger domain) to have room for animation. That's the purpose of ui-map
:
(defn ui-map [m]
(update-vals m (fn [v] [1.0 v])))
We add a scalar to each entry. 1.0 meaning the entry is fully inserted. 0.0 meaning the entry is ready to be dissoc'ed. ui-map
projecting regular maps in the animation domain the scalar value is 1.0 because in a regular map an entry is always fully there!
Now we need to be able to interpolate between these maps:
(defn ui-map-lerp [a b]
;; ensure that a and b have the same keyset
(let [b (into b
(keep (fn [[k [w v]]] ; w for weight
(when (not (b k))
[k [0.0 v]])))
a)
a (into a
(keep (fn [[k [w v]]]
(when-not (a k)
[k [0.0 v]])))
b)]
(fn [t]
;; TODO: prune entries when weight is zero
(into b
(map (fn [[k [wa _]]]
(let [[wb vb] (b k)
w (+ (* wa (- 1.0 t)) (* wb t))]
[k [w vb]])))
a))))
Modifying the previous example to use these two functions (👈s point to changes):
(f/widget
:watch [m (let [*m (atom {})]
(Stream.periodic
(Duration .milliseconds 300)
(fn [n]
(let [k (rand-int 12)]
(swap! *m (fn [m] (if (m k) (dissoc m k) (assoc m k (str "#" k " at " n)))))))))]
:animate [m (ui-map m) ; 👈
:lerp ui-map-lerp] ; 👈
(m/ListView
.children
(for [[k [t v] #_👈] (sort-by key m)]
(f/widget
:key k
m/Center
(m/Text (str v " @ " t) #_👈 .style (m/TextStyle .fontSize 24 .height 1.2))))))
(gif low frame rate doesn't do justice to the actual motion blur.)
Okay, our maps are interpolated and we have weights indicating how much entries are really there. Let's use it for some kind of accordion effect.
(f/widget
:watch [m (let [*m (atom {})]
(Stream.periodic
(Duration .milliseconds 300)
(fn [n]
(let [k (rand-int 12)]
(swap! *m (fn [m] (if (m k) (dissoc m k) (assoc m k (str "#" k " at " n)))))))))]
:animate [m (ui-map m)
:lerp ui-map-lerp]
(m/ListView
.children
(for [[k [t v]] (sort-by key m)]
(f/widget
:key k
:height (* 24 1.2 t) ; 👈🪗
m/Center
(m/Text v ; 👈 revert to regular value
.style (m/TextStyle .fontSize 24 .height 1.2))))))
Obviously it's a proof of work, in practice you may want to disable event handlers on widgets being removed (and thus would need to make part of the ui map domain if the widget is appearing or disappearing). Also try to come up with a less jarring animation 🤢.
Another point is that ui-map
/ui-map-lerp
are begging to be bundled together. Maybe in a future update to :animate
once we get more practice with this pattern.
Does it compose?
Our least queasy readers will have noticed that va
is unused during the interpolating stage of ui-map-lerp
, meaning that the value changes brutally. This wasn't visible in previous example because we inserted and removed entries, never updating them. Let's update the stream to either update or dissoc existing keys:
(f/widget
:watch [m (let [*m (atom {})]
(Stream.periodic
(Duration .milliseconds 300)
(fn [n]
(let [k (rand-int 12)]
(swap! *m (fn [m]
(if (and (m k)
; 👇 only dissoc 50% of the time
(zero? (rand-int 2)))
(dissoc m k)
(assoc m k (str "#" k " at " n)))))))))]
:animate [m (ui-map m)
:lerp ui-map-lerp]
(m/ListView
.children
(for [[k [t v]] (sort-by key m)]
(f/widget
:key k
:height (* 24 1.2 t)
m/Center
(m/Text v .style (m/TextStyle .fontSize 24 .height 1.2))))))
We can see values "jumping". Let's add :animate [v v]
to animate changes to v
!
Oops...
Another exception was thrown: Cannot lerp between "#4 at 8" and "#4 at 18".
We were too quick, forgetting our v
is not just the generation number but a string and thus we need to provide an interpolation. Hopefully we developed term-text-lerp
at the start of this article! Let's use it!
(f/widget
:watch [m (let [*m (atom {})]
(Stream.periodic
(Duration .milliseconds 300)
(fn [n]
(let [k (rand-int 12)]
(swap! *m (fn [m] (if (and (m k) (zero? (rand-int 2))) (dissoc m k) (assoc m k (str "#" k " at " n)))))))))]
:animate [m (ui-map m)
:lerp ui-map-lerp]
(m/ListView
.children
(for [[k [t v]] (sort-by key m)]
(f/widget
:key k
:height (* 24 1.2 t)
:animate [v v :lerp term-text-lerp] ; 👈
m/Center
(m/Text v .style (m/TextStyle .fontSize 24 .height 1.2))))))
It composes 🎉.
Parting Words
We hope we managed to interest you in making your UI more lively.