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.
You will need to add the Animation
library to your app's dependencies.
- Kotlin
- Groovy
build.gradle.kts
dependencies {
implementation ("io.nacular.doodle:animation:$doodleVersion")
}
build.gradle
//sampleStart
dependencies {
implementation "io.nacular.doodle:animation:$doodle_version"
}
//sampleEnd
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.
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
}
There is no requirement that the animations define within the block have the same duration or are even of the same type.
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
}
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.
- Point
- Size
- Rect
- Color
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
}
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.tweenSize
import io.nacular.doodle.geometry.Size
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun size(animate: Animator, easing: EasingFunction, duration: Measure<Time>) {
//sampleStart
animate(Size(100) to Size(100, 200), using = tweenSize(easing, duration)) {
// ...
}
//sampleEnd
}
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.tweenRect
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Rectangle.Companion.Empty
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun rect(animate: Animator, easing: EasingFunction, duration: Measure<Time>) {
//sampleStart
animate(Empty to Rectangle(100, 200), using = tweenRect(easing, duration)) {
// ...
}
//sampleEnd
}
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.tweenColor
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun color(animate: Animator, easing: EasingFunction, duration: Measure<Time>) {
//sampleStart
animate(Red to Green, using = tweenColor(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.
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
}
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*
.
- Point
- Size
- Rect
- Color
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
}
package animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.keyFramesSize
import io.nacular.doodle.geometry.Size
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun sizeKeyframe(animate: Animator, duration: Measure<Time>) {
//sampleStart
animate(Size(100) to Size(100, 200), using = keyFramesSize(duration) {
// key frame definition
}) {
// animation block
}
//sampleEnd
}
package animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.keyFramesRect
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Rectangle.Companion.Empty
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun rectKeyframe(animate: Animator, duration: Measure<Time>) {
//sampleStart
animate(Empty to Rectangle(100, 200), using = keyFramesRect(duration) {
// key frame definition
}) {
// animation block
}
//sampleEnd
}
package animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.keyFramesColor
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time
fun colorKeyframe(animate: Animator, duration: Measure<Time>) {
//sampleStart
animate(Red to Green, using = keyFramesColor(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.invoke(0f to 1f, 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)) {
// ...
}
//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.
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.