Skip to main content

Animations

Animations are key to making an app feel modern and interactive. Doodle helps you achieve this with a powerful yet simple set of APIs that let you animate a wide range of things into your app. Everything you need to build sophisticated animations is available via the Animator interface and its related components.

Library Required

You will need to add the Animation library to your app's dependencies.

build.gradle.kts

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

Requirements

Then you will need to include the Animator in the list of Kodein modules your app launches with. You can do this by defining a new Module, or by including a binding for it in an existing Module you are already installing.

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

Animating a single value

Doodle offers two main APIs to handle common animation use cases. The first allows you to animate a value from start to finish and handle each increment within a lambda. This is a very flexible API that can be used to accomplish most use cases.

These animations are of the following form. This examples animates a Float value between 0 and 1 using a linear easing that takes 250 ms.

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 }

The lambda provided to the Animator will be evaluated every time the animating value changes. This allows the app to take action in real time.

Notice that this API does not make any references to View or any other Doodle concept. This allows animations like these to operate on any compatible data.

info

The animating ball example above uses this approach to change the ball's vertical position.

Grouped animations

You can also animate a series of properties under a single Animation handle. This makes it easy to track and modify the behavior of several properties at once instead of having an animation for each.

This code below shows how the looping ball example animates 3 different properties. The resulting animation will manage all the underlying items as expected.

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.y = it } Size(100) to Size(75) using loop(tweenSize(easing, duration), type = Reverse).invoke { circle.size = it } } //sampleEnd }
tip

There is no requirement that the animations define within the block have the same duration or are even of the same type.

Chaining animations

Animations within a block can be chained together to create new sequential animations that have nice cancellation properties. This is helpful for cases where you'd like to start a follow-up animation after a previous one completes, while keeping the ability to cancel the chain if needed. Manually doing this via onComplete results in creating additional, independent animations, which requires more bookkeeping to cancel everything.

This app shows an example of animation chaining. The wheel bounces, then slides and rolls down the hill. The bounce animates first, then the slide and roll are done as a single chained block that begins when the bounce completes.

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 import io.nacular.doodle.drawing.Color.Companion.Black 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.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 diameter get() = 2 * radius val circumference get() = 2 * PI * radius var bottom get() = position + Point(radius, 2 * radius); set(new) { position = new - Point(radius, 2 * radius) } val center get() = position + Point(radius, 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), Stroke(Lightgray)) canvas.circle(c.inset( thickness / 2 ), Stroke(Lightgray)) canvas.rotate(around = Point(radius, radius), by = rotation) { circle( c, Stroke( color = Color.Blue, dashes = doubleArrayOf(d, d), thickness = thickness, ) )} } } private var animation: Animation<*>? by autoCanceling() private val wheel = Wheel().apply { size = 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() } ) } 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 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 wheel.bottom val rotations = rollLength / wheel.circumference //sampleStart wheel.bottom 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() } }

Tweens

Tween animations let you interpolate data between two values using a curve (or easing function). These animations work with data that can be converted to numeric (Double) representation. Doodle has several tween* functions, all with the form:

tween*(
easing : EasingFunction,
duration: Measure<Time>,
delay : Measure<Time>
)

Numeric data

Doodle supports tweens with any Number type and includes APIs for working with Float, Double and Int. This shows how you can animate a Float from 0 to 100 using a linear tween that lasts 250 ms, and starts immediately (no delay).

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.milliseconds import io.nacular.measured.units.times fun numeric(animate: Animator) { //sampleStart animate(0f to 1f, using = tweenFloat(linear, 250 * milliseconds)) { println(it) } //sampleEnd }
tip

You can also use tweenDouble and tweenInt to work with other number types.

Colors, Sizes, ...

Doodle also allows you to tween non-numeric data, as long as it can be converted to a numeric representation. This works really well for things like Point, Size, Rectangle, and Color. All of these data types are supported by built-in tween variants. So you can do any of the following.

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.transition.EasingFunction import io.nacular.doodle.animation.tweenPoint import io.nacular.doodle.geometry.Point import io.nacular.doodle.geometry.Point.Companion.Origin import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun point(animate: Animator, easing: EasingFunction, duration: Measure<Time>) { //sampleStart animate(Origin to Point(100, 200), using = tweenPoint(easing, duration)) { // ... } //sampleEnd }

This is an example of a Switch with a custom Behavior that draws a heart and animates between selection state using an arbitrary easing function.

Inspired by Tore Bernhoft

Easing functions

The tween* functions work with one or more EasingFunction, a duration of (Measure<Time>), and an optional delay (also a Measure<Time>). EasingFunction is simply a function that takes a Float between 0 and 1 and returns another Float scaled to 0 and 1. Doodle includes many common easing functions already.

But it is just as simple to create a custom one as well. Simply provide a function of the following form that maps inputs between 0 and 1 to a normalized output in (or slightly outside) that range.

CustomEasing

(Float) -> Float

Key frames

Doodle also supports key-frame animations. These let you specify intermediate values the animated property will have at specific times, and the easing curves between these values. The following example animates the ball's y position so it takes 1/3 of the total duration to get from one level to the next. But it provides 3 separate curves to animate between each level.

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

The default ease from the start to the first value in the key frames is linear. The example above overrides this by specifying the first key frame as the start, then it is able to specify how to ease from the beginning.

Key-frame animations are defined using the keyFrame* (i.e. keyFramesFloat) functions that work on data that can be mapped to numeric values like tween*. Similarly, Doodle provides built-in support for the same set of data types here as it does for tween*.

package animation import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.invoke import io.nacular.doodle.animation.keyFramesPoint import io.nacular.doodle.geometry.Point import io.nacular.doodle.geometry.Point.Companion.Origin import io.nacular.measured.units.Measure import io.nacular.measured.units.Time fun pointKeyframe(animate: Animator, duration: Measure<Time>) { //sampleStart animate(Origin to Point(100, 200), using = keyFramesPoint(duration) { // key frame definition }) { // animation block } //sampleEnd }

Repetition

Finite repeating

You can repeat animations like tween* or keyFrame* using the repeat wrapper. This wrapper takes an animationPlan, the number of times it should be repeated and the type of repetition (Restart or Reverse).

For example, you can repeat a linear tween from 0 to 1 as follows.

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)) { // ... } animate { 0f to 1f using (repeat(tweenFloat(easing, duration), times = 2, type = Reverse)) { // ... } } //sampleEnd }

The type determines what the animation will do at each repetition boundary. Restart will run the animation again as though it were just beginning. While Reverse will run the animation from its end to start.

Loops

Sometimes you'd like to run an animation that repeats indefinitely. The loop function makes this easy. It is just like repeat, except it takes no times value and continues "forever". The following app runs a looping animation (that reverses) to change the ball's y, color, and size based on an easing.

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)) { // ... } animate { 0f to 1f using (loop(tweenFloat(easing, duration), type = Reverse)) { // ... } } //sampleEnd }

Delays

All animations can be delayed using the after function. It takes a time to delay and executes the given animation after that delay has passed.

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))) { // ... } animate { 0f to 1f using (after(delay, tweenFloat(easing, duration))) { // ... } } //sampleEnd }

Animating properties

An Animator can also be used to create animatable properties for a class. These properties will then animate from their current value to a new one whenever they are changed. This is done as follows:

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

Doodle manages the animation lifecycle of these properties for you and optionally notifies you of changes throughout the animation. This makes it easy to react as the value changes. An existing animation will be canceled if a new value is set for the property, and the property will begin animating towards that new value.

info

An interruption in the animation will reset the elapsed time. So setting color in the above example to something new while it was in the middle of animating would require 250ms to get from the intermediate value to the new value specified.

Animation lifecycle

All animations result in an Animation instance being created to track their lifecycle. This type implements Completable, which means it notifies listeners when it is completed or canceled. You can register to be notified of these state changes as follows:

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

Doodle provides the autoCanceling property delegate to automatically cancel Completable instances when a new value is assigned to them. This is useful for animations as well.

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 }

Animating custom data

It is possible to animate custom data in addition to the built-in types that Doodle offers. The simplest case is when you have some data that can be represented numerically. These data types can be used directly in tween, keyFrame and other existing animation types as long as you provide logic to convert them to and from their numeric form.

Numeric conversion

You can animate data that is convertible to and from numbers using either SingleDataConverter or MultiDataConverter. These support single and multi-dimensional data respectively. These converters define how a type is mapped to and from a Double or Array<Double>.

Simply implement a custom converter and provide it to one of the generic animation builders as follows:

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 }

Doodle has several built-in converters that use this technique to animate several common data types.

Non-numeric data

The Animator interface actually works with a lower-level definition of an animation than those used for numeric data. This interface is the entry point for truly custom data that cannot be converted to numeric form. Doodle currently does not have any use cases like this, but the API is there in case applications have a need.

See the AnimationPlan for more details.