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

features/transition.rs

//! Motion as data: a node declares what it eases and how, and the core
//! tweens whatever changes between frames. Six things, each one prop:
//!
//! - `transition(ms)`: a value that changes eases to its new one over that
//!   long — the bar's width follows the buttons;
//! - `easing`: the curve it takes, one racer per `Easing`, racing on a
//!   click — the four springs are named bounces, from `smooth`, which
//!   glides in, to `bouncy`, which overshoots most, and the last racer
//!   sets its own with `bounce`;
//! - `slide`: a float whose *position* eases too, so a card jumps between
//!   two anchors along a path rather than appearing at the other;
//! - `keyframes`: a cycle of stops the node walks by itself — width, colour,
//!   radius, opacity — with `repeat` saying which way (normal, reverse,
//!   alternate) and `delay` holding siblings out of phase, which is what a
//!   chase light is;
//!
//! Nothing here calls an animation; each frame declares the value it wants
//! and the core is between the last frame and this one.
//!
//! Run: cargo run -p kui-native --example transition [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::{
    Align, App, Core, Easing, FloatConfig, Keyframe, NodeSpec, Repeat, Sizing, TextStyle, Ui,
    UiEvent, Value,
};

/// Each racer's easing, and the bounce it gives the spring in place of the
/// easing's own.
const EASINGS: [(&str, Easing, Option<f32>); 9] = [
    ("linear", Easing::Linear, None),
    ("ease-out", Easing::EaseOut, None),
    ("ease-in", Easing::EaseIn, None),
    ("ease-in-out", Easing::EaseInOut, None),
    ("smooth", Easing::Smooth, None),
    ("snappy", Easing::Snappy, None),
    ("spring", Easing::Spring, None),
    ("bouncy", Easing::Bouncy, None),
    ("bounce 0.7", Easing::Spring, Some(0.7)),
];

#[derive(Default)]
struct Motion {
    /// The bar's width, as a fraction: what the buttons set and the
    /// transition follows.
    level: f32,
    /// Whether the racers are at the far end.
    far: bool,
    /// Which anchor the sliding card sits at.
    right: bool,
}

impl App for Motion {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(24.0)
                .gap(18.0)
                .cross_align(Align::Start)
                .scroll_y(),
            |ui| {
                // transition: the width follows the level, eased.
                ui.text("transition · the bar eases to whatever the buttons set", TextStyle::new(12.0).color(t.muted));
                ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
                    for (label, level) in [("0%", 0.0), ("40%", 0.4), ("100%", 1.0)] {
                        kui_native::widgets::button(
                            ui,
                            label,
                            Value::map([("kind", Value::str("level")), ("to", Value::Float(level))]),
                        );
                    }
                });
                ui.with(
                    NodeSpec::row()
                        .size(420.0, 14.0)
                        .bg(t.sunken)
                        .radius(7.0),
                    |ui| {
                        ui.leaf_keyed(
                            "bar",
                            NodeSpec::row()
                                .width(Sizing::Percent(self.level))
                                .grow_height()
                                .bg(t.accent)
                                .radius(7.0)
                                .transition(600.0));
                    },
                );

                // easing: the same travel on five curves.
                ui.text("easing · click a lane and the six race on their own curves", TextStyle::new(12.0).color(t.muted));
                ui.with_keyed(
                    "lanes",
                    NodeSpec::column()
                        .width(420.0)
                        .gap(6.0)
                        .pad(8.0)
                        .bg(t.surface)
                        .radius(8.0)
                        .border(1.0, t.border)
                        .on_click("race"),
                    |ui| {
                        for (name, easing, bounce) in EASINGS {
                            ui.with(NodeSpec::row().grow_width().gap(8.0).cross_align(Align::Center), |ui| {
                                ui.text_in(NodeSpec::row().width(80.0), name, TextStyle::new(11.0).color(t.muted));
                                ui.with(
                                    NodeSpec::row().grow_width().height(16.0),
                                    |ui| {
                                        // The racer floats inside its lane; `slide`
                                        // is what makes its *position* ease.
                                        let racer = NodeSpec::row()
                                            .float(
                                                FloatConfig::parent()
                                                    .inside(if self.far { Align::End } else { Align::Start }, Align::Center),
                                            )
                                            .size(16.0, 16.0)
                                            .radius(8.0)
                                            .bg(t.accent)
                                            .transition(900.0)
                                            .easing(easing)
                                            .slide();
                                        ui.leaf_keyed(
                                            name,
                                            match bounce {
                                                Some(b) => racer.bounce(b),
                                                None => racer,
                                            },
                                        );
                                    },
                                );
                            });
                        }
                    },
                );

                // slide: a float that eases between two anchors.
                ui.text("slide · the card eases between its two anchors instead of appearing at the other", TextStyle::new(12.0).color(t.muted));
                ui.with_keyed(
                    "stage",
                    NodeSpec::row()
                        .size(420.0, 64.0)
                        .bg(t.sunken)
                        .radius(8.0)
                        .on_click("flip"),
                    |ui| {
                        let side = if self.right { Align::End } else { Align::Start };
                        ui.text_in_keyed("card", NodeSpec::column()
                                .float(FloatConfig::parent().inside(side, Align::Center).offset(0.0, 0.0))
                                .pad_xy(14.0, 10.0)
                                .bg(t.raised)
                                .radius(8.0)
                                .border(1.0, t.border)
                                .transition(500.0)
                                .easing(Easing::EaseInOut)
                                .slide(), "click the stage", TextStyle::new(13.0));
                    },
                );

                // keyframes, repeat, delay: a cycle each node walks alone.
                ui.text("keyframes · repeat · delay — a cycle, a direction, and siblings out of phase", TextStyle::new(12.0).color(t.muted));
                ui.with(NodeSpec::row().gap(10.0).cross_align(Align::End), |ui| {
                    for (name, repeat) in [
                        ("normal", Repeat::Normal),
                        ("reverse", Repeat::Reverse),
                        ("alternate", Repeat::Alternate),
                        ("alt-reverse", Repeat::AlternateReverse),
                    ]
                    .into_iter()
                    {
                        ui.with(NodeSpec::column().gap(4.0).cross_align(Align::Center), |ui| {
                            ui.leaf_keyed(
                                name,
                                NodeSpec::row()
                                    .size(48.0, 48.0)
                                    .bg(t.accent)
                                    .radius(6.0)
                                    .transition(1200.0)
                                    .repeat(repeat)
                                    .keyframes(vec![
                                        Keyframe::default().at(0.0).bg(t.accent).radius(6.0).opacity(1.0),
                                        Keyframe::default().at(0.5).bg(t.success).radius(24.0).opacity(0.6),
                                        Keyframe::default().at(1.0).bg(t.danger).radius(6.0).opacity(1.0),
                                    ]));
                            ui.text(name, TextStyle::new(10.0).color(t.muted));
                        });
                    }
                    // The chase: one cycle, five delays.
                    ui.with(NodeSpec::row().gap(4.0).cross_align(Align::Center), |ui| {
                        for i in 0..5 {
                            ui.leaf_keyed(
                                &format!("chase{i}"),
                                NodeSpec::row()
                                    .size(12.0, 12.0)
                                    .radius(6.0)
                                    .bg(t.faint)
                                    .transition(800.0)
                                    .delay(i as f32 * 160.0)
                                    .keyframes(vec![
                                        Keyframe::default().at(0.0).bg(t.faint).opacity(0.4),
                                        Keyframe::default().at(0.5).bg(t.accent).opacity(1.0),
                                        Keyframe::default().at(1.0).bg(t.faint).opacity(0.4),
                                    ]));
                        }
                    });
                });
                ui.text(
                    "every value above is what this frame declared; the core is between the last frame and this one",
                    TextStyle::new(12.0).color(t.faint),
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.payload.as_str() {
            Some("race") => self.far = !self.far,
            Some("flip") => self.right = !self.right,
            _ => {
                if let Some(to) = ev.payload.get_float("to") {
                    self.level = to as f32;
                }
            }
        }
    }
}

impl Example for Motion {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("click a lane", "race the easings"),
        ("click the stage", "slide the card"),
    ];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(520.0, 630.0)
    }

    /// The bar's width is tweened: one frame after the level changes the
    /// core is animating on its account, and a click that changes nothing
    /// starts nothing. (The keyframe cycles run forever, so "settled" is
    /// not a state this frame has; a tween's end is checked in
    /// `kui-core`'s own tests.)
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 520.0, 630.0);
        d.frame(self);
        d.frame(self);
        let full = d.key_of("100%").ok_or("no 100% button")?;
        d.click_key(self, full);
        d.check(self.level == 1.0, "the click sets the level")?;
        d.advance(0.016);
        d.frame(self);
        d.check(d.core.animating(), "one frame in, the bar is still moving")?;
        let stage = d.key_of("stage").ok_or("no stage")?;
        d.click_key(self, stage);
        d.frame(self);
        d.check(self.right, "the stage flips the card's anchor")?;
        d.check(
            d.core.animating(),
            "and the float slides rather than jumping",
        )
    }
}

kui_devtools::main!(Motion::default());