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

Motion

At the end of this chapter, a panel springs between two widths, and toasts slide in and out.

The messages

Nothing new here — a toggle, a notify, and a dismiss that names a toast:

//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion

use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Toggle,
    Notify,
    Dismiss { id: u64 },
}

#[derive(Default)]
struct Motion {
    wide: bool,
    toasts: Vec<(u64, String)>,
    next_id: u64,
}

impl App for Motion {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                ui.with(NodeSpec::row().gap(8.0), |ui| {
                    widgets::button(ui, "Toggle width", Msg::Toggle);
                    widgets::button(ui, "Notify", Msg::Notify);
                });

                // `transition(ms)`: when a value on this node changes
                // between frames, the core eases from the old to the new
                // over that long. The view just declares the target.
                ui.with_keyed(
                    "panel",
                    NodeSpec::column()
                        .width(if self.wide { 360.0 } else { 160.0 })
                        .height(80.0)
                        .center()
                        .bg(t.accent_soft)
                        .radius(10.0)
                        .transition(400.0)
                        .easing(Easing::Spring),
                    |ui| {
                        ui.text(
                            if self.wide { "wide" } else { "narrow" },
                            TextStyle::new(14.0).color(t.accent),
                        );
                    },
                );

                // A toast enters from the right and leaves the same way.
                // The key is what makes this work: the core matches this
                // frame's `toast N` to last frame's and eases between
                // them. On the frame it is gone, `exit` replays it out.
                ui.with(
                    NodeSpec::column().gap(6.0).cross_align(Align::Start),
                    |ui| {
                        for (id, text) in &self.toasts {
                            ui.with_indexed(
                                *id,
                                NodeSpec::row()
                                    .pad_xy(12.0, 8.0)
                                    .gap(8.0)
                                    .cross_align(Align::Center)
                                    .bg(t.raised)
                                    .radius(8.0)
                                    .border(1.0, t.border)
                                    .transition(250.0)
                                    .enter(Enter::from(240.0, 0.0))
                                    .exit(Enter::from(240.0, 0.0))
                                    .on_click(Msg::Dismiss { id: *id }),
                                |ui| {
                                    ui.text(text, TextStyle::new(13.0).color(t.fg));
                                    ui.text(
                                        "click to dismiss",
                                        TextStyle::new(11.0).color(t.faint),
                                    );
                                },
                            );
                        }
                    },
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Toggle) => self.wide = !self.wide,
            Some(Msg::Notify) => {
                self.next_id += 1;
                self.toasts
                    .push((self.next_id, format!("Saved #{}", self.next_id)));
            }
            Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Motion")
        .size(480.0, 360.0)
        .run(Motion::default())
}

You declare the target, kui eases

There is no animation API to call. A node says .transition(ms), and from then on any value on it that changes between frames eases from the old value to the new one over that long. Width, colour, position, opacity — whatever changed.

//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion

use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Toggle,
    Notify,
    Dismiss { id: u64 },
}

#[derive(Default)]
struct Motion {
    wide: bool,
    toasts: Vec<(u64, String)>,
    next_id: u64,
}

impl App for Motion {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                ui.with(NodeSpec::row().gap(8.0), |ui| {
                    widgets::button(ui, "Toggle width", Msg::Toggle);
                    widgets::button(ui, "Notify", Msg::Notify);
                });

                // `transition(ms)`: when a value on this node changes
                // between frames, the core eases from the old to the new
                // over that long. The view just declares the target.
                ui.with_keyed(
                    "panel",
                    NodeSpec::column()
                        .width(if self.wide { 360.0 } else { 160.0 })
                        .height(80.0)
                        .center()
                        .bg(t.accent_soft)
                        .radius(10.0)
                        .transition(400.0)
                        .easing(Easing::Spring),
                    |ui| {
                        ui.text(
                            if self.wide { "wide" } else { "narrow" },
                            TextStyle::new(14.0).color(t.accent),
                        );
                    },
                );

                // A toast enters from the right and leaves the same way.
                // The key is what makes this work: the core matches this
                // frame's `toast N` to last frame's and eases between
                // them. On the frame it is gone, `exit` replays it out.
                ui.with(
                    NodeSpec::column().gap(6.0).cross_align(Align::Start),
                    |ui| {
                        for (id, text) in &self.toasts {
                            ui.with_indexed(
                                *id,
                                NodeSpec::row()
                                    .pad_xy(12.0, 8.0)
                                    .gap(8.0)
                                    .cross_align(Align::Center)
                                    .bg(t.raised)
                                    .radius(8.0)
                                    .border(1.0, t.border)
                                    .transition(250.0)
                                    .enter(Enter::from(240.0, 0.0))
                                    .exit(Enter::from(240.0, 0.0))
                                    .on_click(Msg::Dismiss { id: *id }),
                                |ui| {
                                    ui.text(text, TextStyle::new(13.0).color(t.fg));
                                    ui.text(
                                        "click to dismiss",
                                        TextStyle::new(11.0).color(t.faint),
                                    );
                                },
                            );
                        }
                    },
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Toggle) => self.wide = !self.wide,
            Some(Msg::Notify) => {
                self.next_id += 1;
                self.toasts
                    .push((self.next_id, format!("Saved #{}", self.next_id)));
            }
            Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Motion")
        .size(480.0, 360.0)
        .run(Motion::default())
}

The view says the width is 160 or 360. Nothing else. .easing(..) picks the curve: EaseOut is the default. The springs keep their momentum when the target moves mid-flight, and they differ in one thing, how far they overshoot: Smooth not at all, Snappy a trace, Spring a little, Bouncy more. That amount is the spring’s bounce, 0 to 0.9, and .bounce(..) sets any other — with the duration, it is all a spring takes. No stiffness or damping to tune.

This only works because the panel has a key. kui matches this frame’s panel to last frame’s panel and sees the width changed. Without a stable key, there is nothing to ease from.

Entering and leaving

A row that appears this frame has no last frame to ease from. .enter says where it comes from. A row that is gone this frame cannot be drawn — it is not declared — so .exit says where it goes, and kui replays the last frame’s copy of it on the way out.

//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion

use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Toggle,
    Notify,
    Dismiss { id: u64 },
}

#[derive(Default)]
struct Motion {
    wide: bool,
    toasts: Vec<(u64, String)>,
    next_id: u64,
}

impl App for Motion {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                ui.with(NodeSpec::row().gap(8.0), |ui| {
                    widgets::button(ui, "Toggle width", Msg::Toggle);
                    widgets::button(ui, "Notify", Msg::Notify);
                });

                // `transition(ms)`: when a value on this node changes
                // between frames, the core eases from the old to the new
                // over that long. The view just declares the target.
                ui.with_keyed(
                    "panel",
                    NodeSpec::column()
                        .width(if self.wide { 360.0 } else { 160.0 })
                        .height(80.0)
                        .center()
                        .bg(t.accent_soft)
                        .radius(10.0)
                        .transition(400.0)
                        .easing(Easing::Spring),
                    |ui| {
                        ui.text(
                            if self.wide { "wide" } else { "narrow" },
                            TextStyle::new(14.0).color(t.accent),
                        );
                    },
                );

                // A toast enters from the right and leaves the same way.
                // The key is what makes this work: the core matches this
                // frame's `toast N` to last frame's and eases between
                // them. On the frame it is gone, `exit` replays it out.
                ui.with(
                    NodeSpec::column().gap(6.0).cross_align(Align::Start),
                    |ui| {
                        for (id, text) in &self.toasts {
                            ui.with_indexed(
                                *id,
                                NodeSpec::row()
                                    .pad_xy(12.0, 8.0)
                                    .gap(8.0)
                                    .cross_align(Align::Center)
                                    .bg(t.raised)
                                    .radius(8.0)
                                    .border(1.0, t.border)
                                    .transition(250.0)
                                    .enter(Enter::from(240.0, 0.0))
                                    .exit(Enter::from(240.0, 0.0))
                                    .on_click(Msg::Dismiss { id: *id }),
                                |ui| {
                                    ui.text(text, TextStyle::new(13.0).color(t.fg));
                                    ui.text(
                                        "click to dismiss",
                                        TextStyle::new(11.0).color(t.faint),
                                    );
                                },
                            );
                        }
                    },
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Toggle) => self.wide = !self.wide,
            Some(Msg::Notify) => {
                self.next_id += 1;
                self.toasts
                    .push((self.next_id, format!("Saved #{}", self.next_id)));
            }
            Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Motion")
        .size(480.0, 360.0)
        .run(Motion::default())
}

Enter::from(dx, dy) is an offset in pixels. The toast starts 240px to the right and eases to its place; on the frame it is removed, it eases back out.

Keys again: each toast is with_indexed(id, ..). Dismiss the first and the second keeps its key, so it slides up into the gap instead of being mistaken for the first.

Frames while animating

While something is easing, kui asks the window for frames on its own. Your view runs each time, declares the same target, and the ease continues. When everything is still, the loop sleeps until the next event. You never request frames for motion.

Try this

  • Replace Easing::Spring with Easing::Linear. Then Bouncy.
  • Keep Spring and add .bounce(0.6), then .bounce(0.0): the same spring, overshooting more, then not at all.
  • Add .opacity(0.0) to the Enter (it is a builder: Enter::from(240.0, 0.0).opacity(0.0)) so toasts fade as they slide.
  • Remove the id and use ui.with(..). Dismiss the first toast and watch the second one jump.

Where this is decided

  • Every animation prop (transition, easing, bounce, enter, exit, keyframes, slide): docs/props.md.
  • A frame that removes more than 4096 nodes declaring exit animates none of them and warns with exit-budget: put exit on the list, not on every row.
  • The reference examples: features/transition.rs, features/spring.rs (a spring’s duration and bounce on two sliders), features/enter_exit.rs.