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:
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.
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.
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
}
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:
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
}
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.
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
}
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.
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
Animatorneeds a Kodein binding you write yourself. - An
AnimationPlandescribes what a value does over time:tween*for a single curve,keyFrames*for pinned intermediate values, wrapped inrepeatloopafteras needed. - The
Animatorruns plans: one value, a group under one handle, or athenchain that stays cancelable as a unit. by animator(...)makes a property animate itself on assignment.- Use
autoCancelingto avoid two animations fighting over the same value, and cancel inshutdown().
Next: on to Drag & Drop for moving data between Views and applications.