Skip to main content

Animation

Back on Transforms, you learned that a View's transform> is cheap enough to change every frame. Here's where that fact pays off: changing values smoothly over time.

Animation in Doodle really comes down to two questions, and it's worth keeping them separate from the very start:

  • What should the value do over time? That's an animation plan — a tween, key frames, a repeat, a delay.
  • How do you actually run it? That's the Animator — one value, a group, or a chain.

Nearly everything on this page falls into one bucket or the other. Let's start with plans.

Setup​

Animation lives in its own library, so let's start by adding it:

Library Required

Include the Animation library to your app's dependencies.

dependencies { implementation ("io.nacular.doodle:animation:$doodleVersion") }

Here's a small wrinkle worth flagging: unlike the modules you've used so far, `Animator doesn't come with a ready-made one — you bind it yourself:

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.AnimatorImpl import io.nacular.doodle.application.Application import io.nacular.doodle.application.application import io.nacular.doodle.core.Display import org.kodein.di.DI.Module import org.kodein.di.bindProvider import org.kodein.di.instance class MyApp(display: Display, animator: Animator): Application { init { // .. } override fun shutdown() {} } //sampleStart val AnimationModule = Module(name = "AnimationModule") { bindProvider<Animator> { AnimatorImpl(timer = instance(), animationScheduler = instance()) } } fun main() { application(modules = listOf(AnimationModule)) { MyApp(display = instance(), animator = instance()) } } //sampleEnd

From there, Animator gets injected exactly like everything else you've met so far.

Plans: what the value does​

Tweens​

A tween interpolates between two values over a duration, following an easing curve. Every tween* function has the same shape:

package animation import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun //sampleStart tween___(easing: EasingFunction, duration: Measure<Time>) //sampleEnd { }

Tweens are happy to work on anything convertible to a number. Doodle ships variants for the obvious numeric types (tweenFloat, tweenDouble, tweenInt) plus the geometry and drawing types you'd actually want to animate in practice — tweenPoint, tweenSize, tweenRect, tweenColor.

An EasingFunction turns out to be delightfully simple underneath: just (Float) -> Float, mapping progress in 0..1 to output in (or slightly outside) that same range. Doodle includes the usual suspects — linear, easeOutBounce, easeInElastic — and since the signature is this minimal, writing your own is a one-liner.

This Switch draws a heart (uses a custom Behavior) that animates selection using an arbitrary easing function.

Inspired by Tore Bernhoft

The cubicBezier function returns an EasingFunction, so it works with all the existing animation APIs. The app below lets you play with various curves to see how they affect the animation.

Inspired by cubic-bezier.com
package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.cubicBezier import io.nacular.doodle.animation.tweenFloat import io.nacular.doodle.geometry.Point import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun cubicBezier(animate: Animator, duration: Measure<Time>) { //sampleStart animate(0f to 1f, tweenFloat(cubicBezier(1f, 1f, 1f, 0.5f), duration)) { // ... } animate { 0f to 1f using (tweenFloat(cubicBezier(Point(1f, 1f), Point(1f, 0.5f)), duration)) { // ... } } //sampleEnd }

Key frames​

Where a tween travels start-to-end along one curve, key frames let you pin intermediate values at specific moments, each with its own easing to the next:

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.keyFramesDouble import io.nacular.doodle.animation.transition.easeInElastic import io.nacular.doodle.animation.transition.easeOutBack import io.nacular.doodle.animation.transition.linear import io.nacular.doodle.core.View import io.nacular.measured.units.Time.Companion.seconds import io.nacular.measured.units.times fun size(animate: Animator, graph: View) { //sampleStart val duration = 1 * seconds animate(0.0 to graph.height, keyFramesDouble(duration) { // value | at what time | easing to next value // -------------------------------------------------------------- 0.0 at duration * 0 then easeInElastic graph.height * 1/3 at duration * 1/3 then linear graph.height * 2/3 at duration * 2/3 then easeOutBack }) { // ... } //sampleEnd }
tip

Easing from the start value to the first key frame is linear by default. The example above overrides that by making the first key frame the start value itself.

The keyFrames* functions cover the same data types as tween*.

Repetition and delays​

Three small wrappers take an AnimationPlan and hand back a modified one, so they compose neatly with anything above:

  • repeat — run it a fixed number of times.
  • loop — same idea, forever.
  • after — wait, then go.

The type is either Restart (jump back to the beginning each time) or Reverse (play backwards, then forwards, ping-pong style).

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.RepetitionType.Reverse import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.repeat import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tweenFloat import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun repeat(animate: Animator, easing: EasingFunction, duration: Measure<Time>) { //sampleStart animate(0f to 1f, repeat(tweenFloat(easing, duration), times = 2, type = Reverse)) { // ... } //sampleEnd }
package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.RepetitionType.Reverse import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.loop import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tweenFloat import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun loop(animate: Animator, easing: EasingFunction, duration: Measure<Time>) { //sampleStart animate.invoke(0f to 1f, loop(tweenFloat(easing, duration), type = Reverse)) { // ... } //sampleEnd }
package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.after import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tweenFloat import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun delay(animate: Animator, easing: EasingFunction, duration: Measure<Time>, delay: Measure<Time>) { //sampleStart animate(0f to 1f, after(delay, tweenFloat(easing, duration))) { // ... } //sampleEnd }

Running plans: one, many, or in sequence​

A single value​

The simplest form of all: a start value, an end value, a plan, and a lambda that fires on every increment.

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.linear import io.nacular.doodle.animation.tweenFloat import io.nacular.measured.units.Time.Companion.seconds import io.nacular.measured.units.times fun singleValue(animate: Animator) { //sampleStart animate(0f to 1f, using = tweenFloat(linear, 0.25 * seconds)) { value: Float -> // ... } //sampleEnd }

Notice this API never once mentions View — it animates values, full stop, so it's happy to work on anything.

A group of values​

Sometimes several properties want to move together, under one handle you can track, pause, or cancel as a unit:

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.RepetitionType.Reverse import io.nacular.doodle.animation.loop import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tweenColor import io.nacular.doodle.animation.tweenDouble import io.nacular.doodle.animation.tweenSize import io.nacular.doodle.core.view import io.nacular.doodle.drawing.Color.Companion.Blue import io.nacular.doodle.drawing.Color.Companion.Red import io.nacular.doodle.geometry.Size import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun groupedValues(animate: Animator, easing: EasingFunction, duration: Measure<Time>) { val circle = view {} //sampleStart animate { Blue to Red using loop(tweenColor(easing, duration), type = Reverse).invoke { circle.backgroundColor = it } 200.0 to 100.0 using loop(tweenDouble(easing, duration), type = Reverse).invoke { circle.suggestY(it) } Size(100) to Size(75) using loop(tweenSize(easing, duration), type = Reverse).invoke { circle.suggestSize(it) } } //sampleEnd }
tip

Animations in one block don't need to share a duration, a plan type, or even a data type.

A sequence​

then chains animations together so the next one starts the moment the previous finishes — and, crucially, keeps the whole chain cancelable as a single unit. Try to do this by hand with completion callbacks instead, and you end up with a handful of independent animations you have to track and cancel separately. That is exactly the kind of bookkeeping this API exists to save you from.

This is the heart of the rolling-wheel demo: the wheel falls and bounces, then slides and rolls down the ramp, with the slide and the roll happening together as one step.

package io.nacular.doodle.docs.apps import io.nacular.doodle.animation.Animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.transition.easeOutBounce import io.nacular.doodle.animation.tween import io.nacular.doodle.animation.tweenDouble import io.nacular.doodle.animation.tweenPoint import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.core.View import io.nacular.doodle.core.height import io.nacular.doodle.core.renderProperty import io.nacular.doodle.core.view import io.nacular.doodle.core.width import io.nacular.doodle.docs.utils.controlBackgroundColor import io.nacular.doodle.drawing.AffineTransform.Companion.Identity import io.nacular.doodle.drawing.Canvas import io.nacular.doodle.drawing.Color.Companion.Black import io.nacular.doodle.drawing.Color.Companion.Blue import io.nacular.doodle.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.Stroke import io.nacular.doodle.drawing.opacity import io.nacular.doodle.drawing.paint import io.nacular.doodle.drawing.stroke import io.nacular.doodle.event.PointerListener.Companion.on import io.nacular.doodle.geometry.Circle import io.nacular.doodle.geometry.Point import io.nacular.doodle.geometry.Rectangle import io.nacular.doodle.geometry.Size import io.nacular.doodle.geometry.Vector3D import io.nacular.doodle.geometry.circumference import io.nacular.doodle.geometry.inset import io.nacular.doodle.geometry.lineTo import io.nacular.doodle.geometry.path import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.layout.constraints.fill import io.nacular.doodle.utils.autoCanceling import io.nacular.measured.units.Angle.Companion.acos import io.nacular.measured.units.Angle.Companion.degrees import io.nacular.measured.units.Time.Companion.seconds import io.nacular.measured.units.times import kotlin.math.PI import kotlin.math.cos import kotlin.math.sqrt class AnimationChainingApp(private val display: Display, private val animate: Animator): Application { private class Wheel: View() { val radius get() = circle.radius + thickness / 2 val circumference get() = 2 * PI * radius var bottom get() = position + Point(radius, 2 * radius); set(new) { suggestPosition(new - Point(radius, 2 * radius)) } var rotation by renderProperty(0 * degrees) private val thickness = 20.0 private val dashLength get() = circle.circumference / 8 private val circle get() = Circle( center = Point(width, height) / 2.0, radius = minOf(width/2, height/2) - thickness / 2 ) override fun contains(point: Point) = false override fun render(canvas: Canvas) { val c = circle val d = dashLength canvas.circle(c.inset(-thickness / 2 + 0.5), Lightgray.stroke) canvas.circle(c.inset( thickness / 2 ), Lightgray.stroke) canvas.rotate(around = Point(radius, radius), by = rotation) { circle( c, Stroke( color = Blue, dashes = doubleArrayOf(d, d), thickness = thickness, ) )} } } private var animation: Animation<*>? by autoCanceling() private val wheel = Wheel().apply { suggestSize(Size(100)) } private var rampBounds = calculateRampBounds() private val rollBounce = easeOutBounce(0.15f) private val fallDuration = 1 * seconds private val rollDuration = 3 * seconds init { display += view { + wheel render = { outerShadow( color = Black opacity 0.05f, vertical = -10.0, blurRadius = 10.0 ) { path( path(0.0, rampBounds.y) .lineTo(rampBounds.position ) .lineTo(rampBounds.right, rampBounds.bottom) .lineTo(0.0, rampBounds.bottom) .finish(), White.paint ) } } pointerChanged += on( entered = { startAnimation() }, pressed = { startAnimation() } ) wheel.boundsChanged += { _,old,new -> if (old.size != new.size) { rampBounds = calculateRampBounds() animation?.cancel() resetWheel() rerender() } } } display.fill(controlBackgroundColor.paint) display.layout = constrain(display.first(), fill) display.sizeChanged += { _,_,_ -> rampBounds = calculateRampBounds() animation?.cancel() resetWheel() } } private fun resetWheel() { wheel.bottom = Point(wheel.radius, 2 * wheel.radius) wheel.rotation = 0 * degrees wheel.transform = Identity } private fun startAnimation() { resetWheel() animation = animate { val wheelBottomStart = Point(wheel.radius, 2 * wheel.radius) val rampLength = sqrt(rampBounds.width * rampBounds.width + rampBounds.height * rampBounds.height) val radiusLengthRatio = wheel.radius / rampLength val bottomYOffsetFromCenter = rampBounds.width * radiusLengthRatio val bottomXOffsetFromCenter = rampBounds.height * radiusLengthRatio val bottomXOffsetWall = wheel.radius + bottomXOffsetFromCenter val bottomYOffsetFloor = bottomXOffsetWall * rampBounds.height / rampBounds.width val tippingPoint = Identity.rotate( around = Point(wheel.radius, rampBounds.y), by = acos(rampBounds.width / rampLength) )(Point(wheel.radius, rampBounds.y - wheel.radius)) val wheelBottomEnd = Point( rampBounds.right - bottomXOffsetWall + bottomXOffsetFromCenter, rampBounds.bottom - bottomYOffsetFloor - bottomYOffsetFromCenter + wheel.radius ) val rollLength = wheelBottomEnd distanceFrom wheelBottomStart val rotations = rollLength / wheel.circumference //sampleStart wheelBottomStart to Point(wheel.radius, rampBounds.y) using (tweenPoint(easeOutBounce, fallDuration)) { // (1) Ball falls and bounces wheel.bottom = it } then { // (2) Then it slides down the hill and bounces off the wall, by animating x and deriving y wheel.bottom.x to rampBounds.width using (tweenDouble(rollBounce, rollDuration)) { x -> wheel.bottom = Point(x, wheelBottomY(x, tippingPoint)) } // (2) While rolling at the same time 0 * degrees to 360 * degrees * rotations using (tween(degrees, rollBounce, rollDuration)) { wheel.rotation = it } } //sampleEnd } } /** * Calculate the ball's bottom position to keep it on the ramp as it moves horizontally. * * The first phase of the move is the portion where the ball is rolling over the edge. * Then it transitions to normal linear motion. */ private fun wheelBottomY(centerX: Double, tippingPoint: Vector3D) = when { centerX < tippingPoint.x -> rampBounds.y - wheel.radius * cos((centerX - wheel.radius) / wheel.radius) else -> tippingPoint.y + (centerX - tippingPoint.x) * rampBounds.height / rampBounds.width } + wheel.radius private fun calculateRampBounds() = Rectangle( x = wheel.radius, y = display.height / 2, width = display.width - wheel.radius, height = display.height / 2 ) override fun shutdown() { animation?.cancel() } }

Pause, cancel, and completion handlers all reach across the entire chain, not just the step that's currently running.

Animatable properties​

Rather than triggering animations by hand every time, you can make a property animate itself the instant it's assigned:

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.linear import io.nacular.doodle.animation.tweenColor import io.nacular.doodle.core.View import io.nacular.doodle.drawing.Color.Companion.Red import io.nacular.measured.units.Time.Companion.milliseconds import io.nacular.measured.units.times //sampleStart class MyView(animate: Animator): View() { // .. var color by animate(default = Red, tweenColor(linear, 250 * milliseconds)) { old, new -> rerenderNow() } // .. } //sampleEnd

Setting color now glides from the current value to the new one, no extra ceremony required. Doodle manages the whole lifecycle for you: assigning again mid-animation cancels the in-flight one and heads toward the new target instead.

info

An interruption resets elapsed time. Reassigning color halfway through still takes the full 250 ms to reach the new value from wherever it currently is.

Lifecycle​

Every animation hands you back an Animation handle implementing Completable:

package animation import io.nacular.doodle.animation.Animation fun lifeCycle(animation: Animation<*>) { //sampleStart animation.completed += { /*...*/ } animation.canceled += { /*...*/ } //sampleEnd }

Here's a mistake that's easy to make without realizing it: starting a new animation while an old one is still running on the same value. The two fight over it, and the result jitters unpleasantly. The autoCanceling delegate heads this off by canceling whatever's already there the moment a new animation is assigned:

package animation import io.nacular.doodle.animation.Animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.easeInElastic import io.nacular.doodle.animation.transition.linear import io.nacular.doodle.animation.tweenFloat import io.nacular.doodle.utils.autoCanceling import io.nacular.measured.units.Time.Companion.seconds import io.nacular.measured.units.times fun autoCancel(animate: Animator) { //sampleStart // using autoCanceling ensures existing animation is canceled when a new one is assigned var someAnimation: Animation<Float>? by autoCanceling() someAnimation = animate.invoke(0f to 1f, using = tweenFloat(linear, 1 * seconds)) { // ... } // ... // Any ongoing animation is canceled with this assignment someAnimation = animate.invoke(1f to 0f, using = tweenFloat(easeInElastic, 1 * seconds)) { // ... } //sampleEnd }
caution

Cancel running animations in your app's shutdown(), the same way the rolling-wheel demo does.

Animating custom data​

Any type that can be boiled down to a number is fair game for animation. Implement SingleDataConverter (one dimension) or MultiDataConverter (several), then hand it to the generic tween:

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.SingleDataConverter import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tween import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun customData(animate: Animator, easing: EasingFunction, duration: Measure<Time>) { //sampleStart data class Foo(val value: String) val customDataConverter = object: SingleDataConverter<Foo> { override val zero : Foo = Foo("") override fun serialize (value: Foo ): Double = value.value.toDouble() override fun deserialize(value: Double): Foo = Foo("$value") } animate(Foo("1") to Foo("2"), tween(customDataConverter, easing, duration)) { value: Foo -> // ... } //sampleEnd }

And that's exactly how Doodle's own built-in converters work under the hood — no special magic beyond what you just wrote.

info

For data that genuinely can't be numeric, Animator exposes a lower-level AnimationPlan API. Doodle has no use cases for it today — it's there if your app does.

Recap​

  • Animation is a separate library, and Animator needs a Kodein binding you write yourself.
  • An AnimationPlan describes what a value does over time: tween* for a single curve, keyFrames* for pinned intermediate values, wrapped in repeat loop after as needed.
  • The Animator runs plans: one value, a group under one handle, or a then chain that stays cancelable as a unit.
  • by animator(...) makes a property animate itself on assignment.
  • Use autoCanceling to avoid two animations fighting over the same value, and cancel in shutdown().

Next: on to Drag & Drop for moving data between Views and applications.