T11c motion: transitions, stagger, presence that folds, flip that glides, drag to reorder; motion keeps each row's identity

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 18:01:29 -04:00
1 parent ce31c7fb06
commit 8e7e7ccace
10 files changed
+1156 -10

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
use std::{collections::HashMap, rc::Rc, time::Instant};
use gpui::{
AnyElement, App, ElementId, Entity, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, Styled, Window, canvas, div,
};
use super::{BASE, duration, ease_in_out_cubic, ease_out_cubic};
use crate::theme::{ActiveTheme, Elevation};
/// Where a row sits, where it glides from, and since when.
#[derive(Clone, Copy)]
struct Place {
height: Pixels,
top: Pixels,
from: Pixels,
since: Instant,
turn: usize,
}
impl Place {
fn now(&self, length: std::time::Duration) -> Pixels {
let share =
(self.since.elapsed().as_secs_f32() / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
self.from + (self.top - self.from) * ease_in_out_cubic(share)
}
}
/// Measures a row's height as it draws; a change asks for the next frame.
fn measure<T: 'static>(
state: &Entity<HashMap<SharedString, T>>,
key: SharedString,
height: impl Fn(&mut T) -> &mut Pixels + 'static,
) -> impl IntoElement {
let state = state.clone();
canvas(
move |bounds, window, cx| {
let changed = state.update(cx, |rows, _| {
let Some(row) = rows.get_mut(&key) else {
return false;
};
let slot = height(row);
let changed = *slot != bounds.size.height;
*slot = bounds.size.height;
changed
});
if changed {
state.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// A column whose rows glide to their new places when the order changes: first, last, invert, play. Rows are keyed so each is followed.
#[derive(IntoElement)]
pub struct Flip {
id: ElementId,
rows: Vec<(SharedString, AnyElement)>,
lifted: Option<(SharedString, Pixels)>,
}
impl Flip {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
lifted: None,
}
}
/// A row in display order; its key stays with it as the order changes.
pub fn row(mut self, key: impl Into<SharedString>, row: impl IntoElement) -> Self {
self.rows.push((key.into(), row.into_any_element()));
self
}
/// Draws one row raised at `top`, free of its slot, as a drag holds it.
pub(super) fn lifted(mut self, key: SharedString, top: Pixels) -> Self {
self.lifted = Some((key, top));
self
}
}
impl RenderOnce for Flip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state((self.id.clone(), "places"), cx, |_, _| {
HashMap::<SharedString, Place>::new()
});
let length = duration(BASE, cx);
let keys: Vec<SharedString> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let places = state.update(cx, |places, _| {
places.retain(|key, _| keys.contains(key));
let measured = keys.iter().all(|key| {
places
.get(key)
.is_some_and(|place| place.height > Pixels::ZERO)
});
let mut top = Pixels::ZERO;
for key in &keys {
let place = places.entry(key.clone()).or_insert(Place {
height: Pixels::ZERO,
top,
from: top,
since: Instant::now(),
turn: 0,
});
if place.top != top {
place.from = if measured { place.now(length) } else { top };
place.top = top;
place.since = Instant::now();
place.turn += 1;
}
top += place.height;
}
(places.clone(), top)
});
let (places, total) = places;
if places
.values()
.any(|place| place.turn > 0 && place.since.elapsed() < length)
{
window.request_animation_frame();
}
let shadow = cx.theme().elevation(Elevation::Floating);
let rows = self.rows.into_iter().map(|(key, row)| {
let place = places[&key];
let slot = div()
.absolute()
.left_0()
.right_0()
.child(row)
.child(measure(&state, key.clone(), |place: &mut Place| {
&mut place.height
}));
match &self.lifted {
Some((lifted, top)) if *lifted == key => (
true,
slot.top(*top).shadow(shadow.clone()).into_any_element(),
),
_ => (false, slot.top(place.now(length)).into_any_element()),
}
});
let (mut rest, mut raised): (Vec<_>, Vec<_>) = (Vec::new(), Vec::new());
for (lifted, row) in rows {
if lifted {
raised.push(row)
} else {
rest.push(row)
}
}
rest.append(&mut raised);
div().relative().w_full().h(total).children(rest)
}
}
/// Whether a row shows, how tall it is, and when it last turned.
#[derive(Clone, Copy)]
struct Presence {
height: Pixels,
shown: bool,
since: Instant,
turn: usize,
told: bool,
}
type OnGone = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A hidden row is gone once its fold ends, or at once if it was never shown here.
fn gone(presence: &Presence, length: std::time::Duration) -> bool {
presence.turn == 0 || presence.since.elapsed() >= length
}
/// Rows that fold open and fade in when they arrive, and fold away when the owner marks them gone. `on_gone` says when a row may be dropped. Rows there at first stay still.
#[derive(IntoElement)]
pub struct AnimatePresence {
id: ElementId,
rows: Vec<(SharedString, bool, AnyElement)>,
on_gone: Option<OnGone>,
}
impl AnimatePresence {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
on_gone: None,
}
}
/// A row, and whether it should show; keep passing it while it leaves.
pub fn row(mut self, key: impl Into<SharedString>, shown: bool, row: impl IntoElement) -> Self {
self.rows.push((key.into(), shown, row.into_any_element()));
self
}
pub fn on_gone(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_gone = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AnimatePresence {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let first = window.use_keyed_state((self.id.clone(), "first"), cx, |_, _| true);
let state = window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| {
HashMap::<SharedString, Presence>::new()
});
let still = *first.read(cx);
first.update(cx, |first, _| *first = false);
let length = duration(BASE, cx);
let on_gone = self.on_gone;
let mut rows = Vec::new();
for (key, shown, row) in self.rows {
let presence = state.update(cx, |rows, _| {
let presence = rows.entry(key.clone()).or_insert(Presence {
height: Pixels::ZERO,
shown,
since: Instant::now(),
turn: usize::from(!still && shown),
told: false,
});
if presence.shown != shown {
presence.shown = shown;
presence.since = Instant::now();
presence.turn += 1;
presence.told = false;
}
let copy = *presence;
if !shown && gone(presence, length) {
presence.told = true;
}
copy
});
let settled = presence.since.elapsed() >= length;
if !shown && gone(&presence, length) {
if let Some(on_gone) = on_gone.clone().filter(|_| !presence.told) {
let key = key.clone();
window.defer(cx, move |window, cx| on_gone(&key, window, cx));
}
continue;
}
if !settled && presence.turn > 0 {
window.request_animation_frame();
}
let frame = div().relative().overflow_hidden().child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.child(row)
.child(measure(&state, key.clone(), |row: &mut Presence| {
&mut row.height
})),
);
let share = (presence.since.elapsed().as_secs_f32()
/ length.as_secs_f32().max(f32::EPSILON))
.min(1.0);
let open = if presence.turn == 0 {
1.0
} else {
ease_out_cubic(if shown { share } else { 1.0 - share })
};
rows.push(
frame
.h(presence.height * open)
.opacity(open)
.into_any_element(),
);
}
div().flex().flex_col().children(rows)
}
}
+6
View File
@@ -1,18 +1,24 @@
mod changes;
mod curve;
mod list;
mod overlay;
mod progress;
mod reorder;
mod skeleton;
mod slide;
mod spinner;
mod transition;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub(crate) use changes::changes;
pub use curve::*;
pub use list::{AnimatePresence, Flip};
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
pub use progress::{ProgressBar, ProgressRing};
pub use reorder::Reorder;
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
pub use spinner::{Spinner, SpinnerStyle};
pub use transition::{Entrance, Stagger, Transition};
+226
View File
@@ -0,0 +1,226 @@
use std::{collections::HashMap, rc::Rc};
use gpui::{
AnyElement, App, AppContext as _, ElementId, EmptyView, EntityId, InteractiveElement,
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div,
};
use super::Flip;
/// A row on its way, by key, and whose list it belongs to.
struct RowDrag {
owner: EntityId,
key: SharedString,
}
/// The row held, by key, where it would land, its top, and where the pointer took it.
#[derive(Clone)]
struct Hold {
key: SharedString,
target: usize,
top: Pixels,
grab: Pixels,
}
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Rows reordered by dragging: the held row follows the pointer, the others glide aside, and on drop `on_reorder(from, to)` asks the owner to move row `from` to place `to`.
#[derive(IntoElement)]
pub struct Reorder {
id: ElementId,
rows: Vec<(SharedString, AnyElement)>,
on_reorder: Option<OnReorder>,
}
impl Reorder {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
on_reorder: None,
}
}
pub fn row(mut self, key: impl Into<SharedString>, row: impl IntoElement) -> Self {
self.rows.push((key.into(), row.into_any_element()));
self
}
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
/// Where the held row lands: past every other row whose middle is above its own.
fn landing(heights: &[Pixels], from: usize, top: Pixels) -> usize {
let middle = top + heights[from] / 2.0;
let mut y = Pixels::ZERO;
let mut target = 0;
let others = heights
.iter()
.enumerate()
.filter(|(ix, _)| *ix != from)
.map(|(_, height)| height);
for height in others {
if y + *height / 2.0 < middle {
target += 1;
}
y += *height;
}
target
}
impl RenderOnce for Reorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let hold = window.use_keyed_state((self.id.clone(), "hold"), cx, |_, _| None::<Hold>);
let sizes = window.use_keyed_state((self.id.clone(), "heights"), cx, |_, _| {
HashMap::<SharedString, Pixels>::new()
});
let owner = hold.entity_id();
if hold.read(cx).is_some() && !cx.has_active_drag() {
log::debug!("reorder {:?}: the drag ended elsewhere", self.id);
hold.update(cx, |hold, _| *hold = None);
}
let keys: Vec<SharedString> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let held = hold.read(cx).clone().and_then(|held| {
let from = keys.iter().position(|key| *key == held.key);
if from.is_none() {
log::info!("reorder: the held row {} left the list", held.key);
}
from.map(|from| (from, held.target.min(keys.len() - 1), held.top))
});
if held.is_none() && hold.read(cx).is_some() {
hold.update(cx, |hold, _| *hold = None);
}
let heights: Vec<Pixels> = keys
.iter()
.map(|key| sizes.read(cx).get(key).copied().unwrap_or_default())
.collect();
let mut order: Vec<usize> = (0..keys.len()).collect();
if let Some((from, target, _)) = held {
let row = order.remove(from);
order.insert(target, row);
}
let mut rows: Vec<Option<AnyElement>> =
self.rows.into_iter().map(|(_, row)| Some(row)).collect();
let mut flip = Flip::new((self.id.clone(), "flip"));
for ix in order {
let key = keys[ix].clone();
let (grab, measured) = (hold.clone(), sizes.clone());
let row = div()
.id((self.id.clone(), key.clone()))
.relative()
.child(rows[ix].take().expect("each row once"))
.child(
canvas(
{
let key = key.clone();
move |bounds, window, cx| {
if measured.read(cx).get(&key) != Some(&bounds.size.height) {
measured.update(cx, |sizes, cx| {
sizes.insert(key.clone(), bounds.size.height);
cx.notify();
});
window.request_animation_frame();
}
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.on_drag(
RowDrag {
owner,
key: key.clone(),
},
move |drag, offset, _, cx| {
log::info!("reorder: row {} lifted", drag.key);
grab.update(cx, |hold, cx| {
*hold = Some(Hold {
key: drag.key.clone(),
target: ix,
top: Pixels::ZERO,
grab: offset.y,
});
cx.notify();
});
cx.new(|_| EmptyView)
},
);
flip = flip.row(key, row);
}
if let Some((from, _, top)) = held {
flip = flip.lifted(keys[from].clone(), top);
}
let total = heights
.iter()
.fold(Pixels::ZERO, |total, height| total + *height);
let (moves, drops, on_reorder) = (hold.clone(), hold, self.on_reorder);
let dropped = keys.clone();
div()
.id(self.id)
.child(flip)
.on_drag_move::<RowDrag>(move |event, _, cx| {
if event.drag(cx).owner != owner {
return;
}
let pointer = event.event.position.y - event.bounds.top();
moves.update(cx, |hold, cx| {
let Some(hold) = hold else { return };
let Some(from) = keys.iter().position(|key| *key == hold.key) else {
return;
};
let reach = total - heights[from];
hold.top = (pointer - hold.grab).clamp(Pixels::ZERO, reach.max(Pixels::ZERO));
hold.target = landing(&heights, from, hold.top);
cx.notify();
});
})
.on_drop(move |drag: &RowDrag, window, cx| {
if drag.owner != owner {
return;
}
let Some(held) = drops.update(cx, |hold, cx| {
cx.notify();
hold.take()
}) else {
return;
};
let Some(from) = dropped.iter().position(|key| *key == held.key) else {
return;
};
let target = held.target.min(dropped.len() - 1);
if from != target {
log::info!("reorder: row {from} to {target}");
if let Some(on_reorder) = &on_reorder {
on_reorder(from, target, window, cx);
}
}
})
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::landing;
#[test]
fn a_held_row_lands_past_the_middles_above_it() {
let heights = [px(20.0); 5];
assert_eq!(landing(&heights, 0, px(40.0)), 2);
assert_eq!(landing(&heights, 0, px(0.0)), 0);
assert_eq!(landing(&heights, 4, px(0.0)), 0);
assert_eq!(landing(&heights, 2, px(70.0)), 4);
}
}
+189
View File
@@ -150,3 +150,192 @@ fn a_sweeping_bar_keeps_its_thickness_in_a_tall_column(cx: &mut TestAppContext)
});
assert_eq!(after.get(), thickness, "the bar took only its thickness");
}
/// A transition, a presence list and a reorderable list, recording what they report.
struct Stage {
shown: bool,
rows: Vec<(&'static str, bool)>,
order: Vec<&'static str>,
/// The top of what sits below the transition.
drawn: Rc<Cell<usize>>,
/// Each reorder row's keyed state, by row.
seen: Rc<std::cell::RefCell<std::collections::HashMap<&'static str, gpui::EntityId>>>,
log: Vec<String>,
}
/// A 20px row that keeps keyed state and says which entity holds it.
#[derive(IntoElement)]
struct Marker {
key: &'static str,
seen: Rc<std::cell::RefCell<std::collections::HashMap<&'static str, gpui::EntityId>>>,
}
impl gpui::RenderOnce for Marker {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let state = window.use_keyed_state(self.key, cx, |_, _| ());
self.seen.borrow_mut().insert(self.key, state.entity_id());
div().h(px(20.0)).child(self.key)
}
}
impl Render for Stage {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let drawn = self.drawn.clone();
let (gone, moved) = (cx.entity(), cx.entity());
let presence = self.rows.iter().fold(
super::AnimatePresence::new("presence").on_gone(move |key, _, cx| {
gone.update(cx, |stage, _| stage.log.push(format!("gone {key}")))
}),
|list, (key, shown)| list.row(*key, *shown, div().h(px(20.0)).child(*key)),
);
let reorder = self.order.iter().fold(
super::Reorder::new("reorder").on_reorder(move |from, to, _, cx| {
moved.update(cx, |stage, cx| {
let row = stage.order.remove(from);
stage.order.insert(to, row);
stage.log.push(format!("moved {from} to {to}"));
cx.notify();
})
}),
|list, key| {
list.row(
*key,
Marker {
key,
seen: self.seen.clone(),
},
)
},
);
div()
.w(px(300.0))
.child(reorder)
.child(super::Transition::new("fade", self.shown).child(div().h(px(10.0))))
.child(
crate::primitives::Measure::new("below", move |bounds, _, _| {
drawn.set(f32::from(bounds.origin.y) as usize)
})
.h(px(1.0)),
)
.child(presence)
}
}
fn stage(cx: &mut TestAppContext) -> (gpui::Entity<Stage>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let (view, cx) = cx.add_window_view(|_, _| Stage {
shown: true,
rows: vec![("a", true), ("b", true)],
order: vec!["one", "two", "three", "four", "five"],
drawn: Rc::new(Cell::new(0)),
seen: Rc::default(),
log: Vec::new(),
});
settle(cx);
(view, cx)
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(2));
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
#[gpui::test]
fn a_hidden_transition_leaves_then_stops_drawing(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let below = view.read_with(cx, |stage, _| stage.drawn.clone());
assert_eq!(below.get(), 110, "the reorder list, then the shown content");
view.update(cx, |stage, cx| {
stage.shown = false;
cx.notify();
});
settle(cx);
assert_eq!(below.get(), 100, "the content left the layout");
}
#[gpui::test]
fn a_row_marked_gone_is_reported_once(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
view.update(cx, |stage, cx| {
stage.rows[1].1 = false;
cx.notify();
});
settle(cx);
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert_eq!(log, ["gone b"]);
}
#[gpui::test]
fn dragging_a_row_down_moves_it_past_the_rows_it_passes(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let at = |y: f32| point(px(100.0), px(y));
cx.simulate_mouse_move(at(10.0), None, Modifiers::none());
cx.simulate_mouse_down(at(10.0), gpui::MouseButton::Left, Modifiers::none());
for y in [16.0, 30.0, 45.0, 50.0] {
cx.simulate_mouse_move(at(y), Some(gpui::MouseButton::Left), Modifiers::none());
cx.run_until_parked();
}
cx.simulate_mouse_up(at(50.0), gpui::MouseButton::Left, Modifiers::none());
settle(cx);
let (order, log) = view.read_with(cx, |stage, _| (stage.order.clone(), stage.log.clone()));
assert_eq!(log, ["moved 0 to 2"]);
assert_eq!(order, ["two", "three", "one", "four", "five"]);
}
#[gpui::test]
fn a_moved_row_keeps_its_state(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let seen = view.read_with(cx, |stage, _| stage.seen.clone());
let before = seen.borrow()["one"];
view.update(cx, |stage, cx| {
stage.order.reverse();
cx.notify();
});
settle(cx);
assert_eq!(
seen.borrow()["one"],
before,
"the same state after the move"
);
}
#[gpui::test]
fn a_list_that_shrinks_under_a_drag_lets_it_go(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let at = |y: f32| point(px(100.0), px(y));
cx.simulate_mouse_move(at(90.0), None, Modifiers::none());
cx.simulate_mouse_down(at(90.0), gpui::MouseButton::Left, Modifiers::none());
for y in [84.0, 60.0] {
cx.simulate_mouse_move(at(y), Some(gpui::MouseButton::Left), Modifiers::none());
cx.run_until_parked();
}
view.update(cx, |stage, cx| {
stage.order.truncate(2);
cx.notify();
});
settle(cx);
cx.simulate_mouse_up(at(20.0), gpui::MouseButton::Left, Modifiers::none());
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert!(log.is_empty(), "no move for a row that left: {log:?}");
}
#[gpui::test]
fn a_row_hidden_from_the_start_is_gone_at_once(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = false));
view.update(cx, |stage, cx| {
stage.rows.push(("c", false));
cx.notify();
});
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert_eq!(log, ["gone c"], "no fold to wait for");
}
+169
View File
@@ -0,0 +1,169 @@
use std::time::{Duration, Instant};
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce,
Styled, Window, div,
};
use smallvec::SmallVec;
use super::{BASE, NUDGE, duration, ease_out_cubic};
use crate::theme::ActiveTheme;
/// How content comes and goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Entrance {
#[default]
Fade,
/// Fades while rising from a little below.
Rise,
/// Fades while sliding in from the left, and out the same way.
Slide,
}
/// Where a transition stands: shown or not, since when, and how many times it turned.
struct Turns {
shown: bool,
since: Instant,
turn: usize,
}
/// Shows or hides its content with a fade, a rise or a slide. It keeps drawing the content while it leaves, so pass the content either way.
#[derive(IntoElement)]
pub struct Transition {
id: ElementId,
shown: bool,
entrance: Entrance,
body: SmallVec<[AnyElement; 2]>,
}
impl Transition {
pub fn new(id: impl Into<ElementId>, shown: bool) -> Self {
Self {
id: id.into(),
shown,
entrance: Entrance::default(),
body: SmallVec::new(),
}
}
pub fn entrance(mut self, entrance: Entrance) -> Self {
self.entrance = entrance;
self
}
}
impl ParentElement for Transition {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
/// Moves `element` a share `t` of the way in: 0 is gone, 1 is in place.
pub(super) fn arrive<E: Styled>(element: E, entrance: Entrance, t: f32) -> E {
let t = ease_out_cubic(t);
match entrance {
Entrance::Fade => element.opacity(t),
Entrance::Rise => element.opacity(t).mt(NUDGE * 2.0 * (1.0 - t)),
Entrance::Slide => element.opacity(t).ml(NUDGE * -4.0 * (1.0 - t)),
}
}
impl RenderOnce for Transition {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let shown = self.shown;
let state = window.use_keyed_state((self.id.clone(), "turns"), cx, |_, _| Turns {
shown,
since: Instant::now(),
turn: 0,
});
if state.read(cx).shown != shown {
state.update(cx, |turns, _| {
turns.shown = shown;
turns.since = Instant::now();
turns.turn += 1;
});
}
let (since, turn) = (state.read(cx).since, state.read(cx).turn);
let length = duration(BASE, cx);
let leaving = !shown && turn > 0 && since.elapsed() < length;
if !shown && !leaving {
return div().into_any_element();
}
let share =
(since.elapsed().as_secs_f32() / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
if turn > 0 && share < 1.0 {
window.request_animation_frame();
}
let content = div().children(self.body);
match turn {
0 => content.into_any_element(),
_ => arrive(
content,
self.entrance,
if shown { share } else { 1.0 - share },
)
.into_any_element(),
}
}
}
/// Children that enter one after another, `step` apart, each arriving once.
#[derive(IntoElement)]
pub struct Stagger {
id: ElementId,
step: Duration,
entrance: Entrance,
body: SmallVec<[AnyElement; 4]>,
}
impl Stagger {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
step: Duration::from_millis(60),
entrance: Entrance::Rise,
body: SmallVec::new(),
}
}
/// The gap between two children's starts.
pub fn step(mut self, step: Duration) -> Self {
self.step = step;
self
}
pub fn entrance(mut self, entrance: Entrance) -> Self {
self.entrance = entrance;
self
}
}
impl ParentElement for Stagger {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for Stagger {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let length = duration(BASE, cx);
let step = if cx.theme().reduced_motion {
Duration::ZERO
} else {
self.step
};
let (id, entrance) = (self.id, self.entrance);
div().children(self.body.into_iter().enumerate().map(move |(ix, child)| {
let lead = step * ix as u32;
let total = lead + length;
div().child(child).with_animation(
(id.clone(), format!("child-{ix}")),
Animation::new(total),
move |child, t| {
let at = (t * total.as_secs_f32() - lead.as_secs_f32()) / length.as_secs_f32();
arrive(child, entrance, at.clamp(0.0, 1.0))
},
)
}))
}
}