Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Keyframes

Every other animation in guido moves towards something: a property gets a new value and a transition carries it there. That is the whole of what a state change looks like, and none of what a sequence looks like — a shake, a flash, a bounce, anything that has to pass through somewhere on its way and end where it started.

A timeline is the other shape. It has no target: it plays. Declare it with .timeline(..), on the value the property rests at between plays:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0);
container()
    .rotate(0.0.timeline(
        Keyframes::new(320.0)
            .at(0.0, 0.0)
            .at(0.15, 2.0)
            .at(0.40, -1.6)
            .at(0.65, 0.9)
            .at(1.0, 0.0)
            .played_by(rejections),
    ))
;
}

It reaches every animatable property, not only the transform components — a Keyframes<Color> on a background is a flash:

extern crate guido;
use guido::prelude::*;
fn main() {
let errors = create_signal(0);
let surface = Color::rgb(0.15, 0.15, 0.2);
container()
    .background(surface.timeline(
        Keyframes::new(240.0)
            .at(0.0, surface)
            .at(0.3, Color::rgb(0.8, 0.2, 0.2))
            .at(1.0, surface)
            .played_by(errors),
    ))
;
}

The resting value

The value .timeline(..) is called on is what the property is whenever nothing is playing. A shake returns to where it began, which makes it look redundant — but a sequence that ends somewhere else snaps back to it, and a sequence resting on a live signal has nowhere else to put its expression:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0);
let spin = create_signal(0u32);
let shake = || Keyframes::new(320.0).at(0.0, 0.0).at(0.5, 2.0).at(1.0, 0.0);
// A glyph that spins on one signal, and shakes on another.
container()
    .rotate((move || spin.get() as f32 * 90.0).timeline(shake().played_by(rejections)))
;
}

A timeline is for something that happens and is over. A change that should persist is a transition.

What plays it

A sequence plays as soon as the widget carrying it exists. That is what a wait looks like: a spinner should turn from the moment it is on screen, and there is nothing to ask it.

played_by(trigger) makes it wait instead, and then every change to that signal plays it once. A count rather than a flag, because two refusals in a row are two events and a signal that stays equal notifies nobody — the second wrong password has to shake as loudly as the first. SwiftUI’s keyframe animator takes its trigger the same way.

A sequence with a trigger plays nothing on the first frame: the container remembers what the signal held when it was built. Played again while it is still running, a sequence starts over from the top rather than continuing — half a shake is not what a second refusal means.

How long it runs

repeat(Repeat::Times(n)) plays the whole run n times and then rests, and never fewer than once — a computed n of zero is a single run. Repeat::Forever never stops:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(true);
fn spin() -> Keyframes<f32> {
    Keyframes::new(1000.0).at(1.0, 360.0).repeat(Repeat::Forever)
}

fn spinner() -> Container {
    container().rotate(0.0.timeline(spin())).child(text("*"))
}

// On screen only while the work is outstanding.
container().child(move || busy.get().then(spinner))
;
}

There is no way to stop an endless sequence other than removing the widget that carries it, and that is the point. The widget being on screen is the animation running, so showing and hiding it is the only switch to keep in step — nothing to call off, and no way to leave a spinner turning after the work is done. Compose reaches the same place from the other direction: an infinite transition there runs for as long as the composable is in composition.

Starting late, or out of step

delay(ms) holds the first value for ms and then plays. It is paid once each time the sequence plays, not once per run: a repeat goes straight on into the next one, as animation-delay does in CSS.

phase(f) starts the first run f of the way through its cycle, which is what lets several elements share one looping definition and still move out of step — a row of equaliser bars, or loading dots:

extern crate guido;
use guido::prelude::*;
fn main() {
fn bounce(phase: f32) -> Keyframes<Scale> {
    Keyframes::new(900.0)
        .at_with(0.0, Scale::uniform(0.4), TimingFunction::EaseInOut)
        .at_with(0.5, Scale::uniform(1.0), TimingFunction::EaseInOut)
        .at(1.0, Scale::uniform(0.4))
        .phase(phase)
        .repeat(Repeat::Forever)
}

let bar = |phase| container().scale(Scale::NONE.timeline(bounce(phase)));
container()
    .layout(Flex::row().spacing(4.0))
    .children([bar(0.0), bar(0.33), bar(0.66)])
;
}

A phase is a negative animation-delay: the part of the cycle it skips is already behind it, so a sequence with a count ends that much sooner, on its last stop. It is read round the cycle — 1.25 is 0.25 — and with both, the delay holds the value the phased run begins from, so the end of the wait is not a jump.

Offsets and easing

An offset is a fraction of one run, 0.0 to 1.0, and duration is how long one run takes. The easing declared at a stop governs the segment that starts there, which is CSS’s rule for a timing function written inside a keyframe:

extern crate guido;
use guido::prelude::*;
fn main() {
Keyframes::new(360.0)
    .at_with(0.0, Translate::NONE, TimingFunction::EaseIn)
    .at_with(0.35, Translate::new(0.0, 14.0), TimingFunction::EaseOut)
    .at(1.0, Translate::NONE)
;
}

Before the first stop and after the last one the nearest stop holds — a timeline whose first stop is at 0.25 is not a timeline that starts undefined.

What it does to the declared value

A running timeline replaces it. For as long as the sequence plays, it is what the property is; when it ends, the property goes back to whatever is declared now — including a value that changed while it was playing. This is the rule CSS settled on, where an animation outranks a normal declaration for as long as it runs and hands the property back afterwards.

A timeline belongs to the one component it moves, so a card can hover and shake at the same time without the two meeting at all:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0u32);
fn shake() -> Keyframes<f32> {
    Keyframes::new(300.0).at(0.25, 6.0).at(0.75, -6.0).at(1.0, 0.0)
}
container()
    .when_hovered(|s| s.scale(1.03))
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::SNAPPY)))
    .rotate(0.0.timeline(shake().played_by(rejections)))
;
}

The hover is on scale and the shake on rotate, so both are drawn throughout: the card grows under the pointer while it is still shaking its head.

The replacing rule applies when a timeline and a declaration are on the same component:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0u32);
fn shake() -> Keyframes<f32> {
    Keyframes::new(300.0).at(0.25, 6.0).at(0.75, -6.0).at(1.0, 0.0)
}
container()
    .when_hovered(|s| s.rotate(3.0))
    .rotate(0.0.timeline(shake().played_by(rejections)))
;
}

Here the hover tilt is still declared while the shake runs; it simply is not being drawn. When the sequence ends the property is handed back from wherever the sequence left it, rather than the card jumping to the tilted angle on the sequence’s last frame.

A property carries one motion: a value is declared either with a transition or with a timeline, and a second declaration of the same property replaces the whole thing — the value included. Where a card wants both a spring and a sequence, they go on two components, as in the example above.

What a segment cannot be eased with

A spring. A keyframe segment is a fixed slice of a fixed duration, which is exactly what a spring has not got — it settles when it settles. Passing one to at_with substitutes EaseInOut and says so in debug builds, rather than quietly playing the segment as a straight line.

extern crate guido;
use guido::prelude::*;
fn main() {
Keyframes::new(320.0)
    .at_with(0.0, 0.0, TimingFunction::EaseOut)
    .at(1.0, 2.0)
;
}

When not to use them

A timeline is a choreography, and most motion is not. If what you want is “go there, springily”, a spring already does it — and since a spring keeps its momentum when it is retargeted, out-and-back is a wobble without a timeline at all. Reach for keyframes when the shape of the motion is the point, not the destination.

cargo run --example keyframes_example plays both a shake and a nod from the same trigger, over a hover that stays declared throughout.