T11c motion: transitions, stagger, presence that folds, flip that glides, drag to reorder; motion keeps each row's identity
This commit is contained in:
10 files changed
+1156
-10
No files matched your search
@@ -67,6 +67,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again.
|
||||
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar.
|
||||
- `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike.
|
||||
- Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself.
|
||||
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
|
||||
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
|
||||
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus.
|
||||
|
||||
@@ -40,7 +40,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T10b Feedback · States
|
||||
- [x] T11a Loading · Spinners & skeletons — `tasks/ch11-20.md`
|
||||
- [x] T11b Loading · Progress & states
|
||||
- [ ] T11c Motion · Presence & order
|
||||
- [x] T11c Motion · Presence & order
|
||||
- [ ] T11d Motion · Effects
|
||||
- [ ] T12 Data Display
|
||||
- [ ] T13 Lists & Trees
|
||||
@@ -173,3 +173,6 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T11a | 3 | PASS | |
|
||||
| T11b | 1 | FAIL | the sweeping bar grew in a tall flex column, the overlay's veil ignored rounded corners, the fail demo could not start a load, and two UploadList docs said more than the rows show |
|
||||
| T11b | 2 | PASS | |
|
||||
| T11c | 1 | FAIL | a drag kept a stale row index and panicked when the list shrank, a move changed each row's animation id and rebuilt its state, and repeated demo tags shared a key |
|
||||
| T11c | 2 | FAIL | a row first seen hidden drew open and asked for no frame, so it lingered until something else redrew |
|
||||
| T11c | 3 | PASS | |
|
||||
@@ -1,4 +1,5 @@
|
||||
mod loading;
|
||||
mod moves;
|
||||
mod progress;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
@@ -42,6 +43,16 @@ const SCRIPT: &[Step] = &[
|
||||
Step::Click("uploads-tick"),
|
||||
Step::Wait(400),
|
||||
Step::Shot("uploads"),
|
||||
Step::Click("transition-toggle"),
|
||||
Step::Wait(80),
|
||||
Step::Shot("transition-leaving"),
|
||||
Step::Click("transition-toggle"),
|
||||
Step::Click("presence-add"),
|
||||
Step::Wait(80),
|
||||
Step::Shot("presence-arriving"),
|
||||
Step::Click("flip-shuffle"),
|
||||
Step::Wait(100),
|
||||
Step::Shot("flip-gliding"),
|
||||
Step::Rest,
|
||||
];
|
||||
|
||||
@@ -58,5 +69,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(progress::suspense(window, cx))
|
||||
.child(progress::refresh(window, cx))
|
||||
.child(progress::uploads(window, cx))
|
||||
.child(moves::transitions(window, cx))
|
||||
.child(moves::stagger(window, cx))
|
||||
.child(moves::presence(window, cx))
|
||||
.child(moves::flip(window, cx))
|
||||
.child(moves::reorder(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
motion::{AnimatePresence, Entrance, Flip, Reorder, Stagger, Transition},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius},
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, Window, div};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, row, section, set, specimen, specimens},
|
||||
};
|
||||
|
||||
fn card(text: impl Into<SharedString>, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.child(text.into())
|
||||
}
|
||||
|
||||
/// A row in a list demo: a hairline under it.
|
||||
fn line(text: impl Into<SharedString>, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.child(text.into())
|
||||
}
|
||||
|
||||
pub fn transitions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let shown = keep("transition-shown", || true, window, cx);
|
||||
let (now, toggle) = (*shown.read(cx), shown.clone());
|
||||
let entrances = [
|
||||
(Entrance::Fade, "fade"),
|
||||
(Entrance::Rise, "rise"),
|
||||
(Entrance::Slide, "slide"),
|
||||
];
|
||||
section(
|
||||
"Transition",
|
||||
"Content that fades, rises or slides in, and leaves the way it came.",
|
||||
cx,
|
||||
)
|
||||
.child(specimens().children(entrances.map(|(entrance, name)| {
|
||||
specimen(
|
||||
name,
|
||||
div().w_48().h_12().child(
|
||||
Transition::new(SharedString::from(format!("transition-{name}")), now)
|
||||
.entrance(entrance)
|
||||
.child(card("Hello", cx)),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})))
|
||||
.child(
|
||||
row().child(probe(
|
||||
"transition-toggle",
|
||||
Button::new("transition-toggle", if now { "Hide" } else { "Show" })
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| set(&toggle, !now, cx)),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stagger(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let turn = keep("stagger-turn", || 0usize, window, cx);
|
||||
let (now, replay) = (*turn.read(cx), turn.clone());
|
||||
section(
|
||||
"Stagger",
|
||||
"Children arrive one after another, a few frames apart.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w_96().child(
|
||||
Stagger::new(SharedString::from(format!("stagger-{now}"))).children(
|
||||
["Inbox", "Drafts", "Sent", "Archive", "Trash"].map(|name| line(name, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
row().child(probe(
|
||||
"stagger-replay",
|
||||
Button::new("stagger-replay", "Replay")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| set(&replay, now + 1, cx)),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A tag in the presence demo: a stable number, its name, and whether it shows.
|
||||
type Tag = (usize, &'static str, bool);
|
||||
|
||||
pub fn presence(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
const MORE: [&str; 4] = ["Typography", "Color", "Layout", "Sound"];
|
||||
let tags = keep(
|
||||
"presence-tags",
|
||||
|| vec![(0, "Design", true), (1, "Motion", true), (2, "Rust", true)],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let list: Vec<Tag> = tags.read(cx).clone();
|
||||
let (add, gone) = (tags.clone(), tags.clone());
|
||||
let rows = list.iter().fold(
|
||||
AnimatePresence::new("presence").on_gone(move |key, _, cx| {
|
||||
let left: Vec<Tag> = gone
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter(|(number, _, _)| format!("tag-{number}") != key.as_ref())
|
||||
.copied()
|
||||
.collect();
|
||||
set(&gone, left, cx)
|
||||
}),
|
||||
|rows, (number, name, shown)| {
|
||||
let (hide, number) = (tags.clone(), *number);
|
||||
rows.row(
|
||||
SharedString::from(format!("tag-{number}")),
|
||||
*shown,
|
||||
line(*name, cx).justify_between().child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("presence-remove-{number}")),
|
||||
IconName::X,
|
||||
)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
let then: Vec<Tag> = hide
|
||||
.read(cx)
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
if tag.0 == number {
|
||||
(tag.0, tag.1, false)
|
||||
} else {
|
||||
*tag
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
set(&hide, then, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
);
|
||||
section(
|
||||
"AnimatePresence",
|
||||
"Rows fold open as they arrive and fold away as they go; the rest glide to close the gap.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w_96().child(rows))
|
||||
.child(
|
||||
row().child(probe(
|
||||
"presence-add",
|
||||
Button::new("presence-add", "Add a tag")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
let mut then = add.read(cx).clone();
|
||||
let number = then.iter().map(|tag| tag.0 + 1).max().unwrap_or(0);
|
||||
then.push((number, MORE[number % MORE.len()], true));
|
||||
set(&add, then, cx);
|
||||
}),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn flip(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let order = keep(
|
||||
"flip-order",
|
||||
|| vec!["Aurora", "Basalt", "Cinder", "Dune", "Ember"],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (now, shuffle) = (order.read(cx).clone(), order.clone());
|
||||
let list = now.iter().fold(Flip::new("flip"), |list, name| {
|
||||
list.row(*name, line(*name, cx))
|
||||
});
|
||||
section(
|
||||
"Flip",
|
||||
"When the order changes, each row glides from where it was to where it goes.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w_96().child(list))
|
||||
.child(
|
||||
row().child(probe(
|
||||
"flip-shuffle",
|
||||
Button::new("flip-shuffle", "Shuffle")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
let mut then = shuffle.read(cx).clone();
|
||||
then.rotate_left(2);
|
||||
then.swap(0, 3);
|
||||
set(&shuffle, then, cx)
|
||||
}),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reorder(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tasks = keep(
|
||||
"reorder-tasks",
|
||||
|| {
|
||||
vec![
|
||||
"Write the brief",
|
||||
"Sketch the flows",
|
||||
"Pick the type",
|
||||
"Build the prototype",
|
||||
"Test with five people",
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (now, moved) = (tasks.read(cx).clone(), tasks.clone());
|
||||
let muted = cx.theme().colors.fg_subtle;
|
||||
let list = now.iter().fold(
|
||||
Reorder::new("reorder").on_reorder(move |from, to, _, cx| {
|
||||
let mut then = moved.read(cx).clone();
|
||||
let task = then.remove(from);
|
||||
then.insert(to, task);
|
||||
set(&moved, then, cx)
|
||||
}),
|
||||
|list, task| {
|
||||
list.row(
|
||||
*task,
|
||||
line(*task, cx).child(div().flex_1()).child(
|
||||
Icon::new(IconName::GripVertical)
|
||||
.size(IconSize::Sm)
|
||||
.color(muted),
|
||||
),
|
||||
)
|
||||
},
|
||||
);
|
||||
section(
|
||||
"Reorder",
|
||||
"Drag a row: it follows the pointer and the others glide aside to make room.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w_96().child(list))
|
||||
.child(Caption::new(
|
||||
"Motion is gpui's with_animation, timed by motion::duration and eased by the curves in motion. Spring is motion::spring. CountUp is typography::AnimatedNumber::count_up. A collapse is layout::Collapsible. A scale entrance is blocked: gpui 0.2.2 transforms only icons.",
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
+12
-9
@@ -25,16 +25,19 @@
|
||||
- [x] RefreshIndicator → `motion::RefreshIndicator`
|
||||
- [x] TypingIndicator → `motion::TypingIndicator`
|
||||
|
||||
## T11c Motion
|
||||
## T11c Motion · Presence & order — `src/motion`
|
||||
|
||||
- [x] Transition — fade, slide, scale, collapse → `motion::Transition` fades, rises and slides; collapse is `layout::Collapsible`; scale is blocked: gpui 0.2.2 transforms only icons
|
||||
- [x] AnimatePresence → `motion::AnimatePresence`
|
||||
- [x] Motion → gpui's `with_animation`, timed by `motion::duration` and eased by the curves in `motion`
|
||||
- [x] Spring → `motion::spring`
|
||||
- [x] Stagger → `motion::Stagger`
|
||||
- [x] Reorder → `motion::Reorder`
|
||||
- [x] Flip → `motion::Flip`
|
||||
- [x] CountUp → `typography::AnimatedNumber`, counting up
|
||||
|
||||
## T11d Motion · Effects
|
||||
|
||||
- [ ] Transition — fade, slide, scale, collapse
|
||||
- [ ] AnimatePresence
|
||||
- [ ] Motion
|
||||
- [ ] Spring
|
||||
- [ ] Stagger
|
||||
- [ ] Reorder
|
||||
- [ ] Flip
|
||||
- [ ] CountUp → `typography::AnimatedNumber`
|
||||
- [ ] Marquee
|
||||
- [ ] Ripple
|
||||
- [ ] Confetti
|
||||
|
||||
Reference in new issue
Block a user