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::SpringwithEasing::Linear. ThenBouncy. - Keep
Springand add.bounce(0.6), then.bounce(0.0): the same spring, overshooting more, then not at all. - Add
.opacity(0.0)to theEnter(it is a builder:Enter::from(240.0, 0.0).opacity(0.0)) so toasts fade as they slide. - Remove the
idand useui.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
exitanimates none of them and warns withexit-budget: putexiton 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.