1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
mod layout;
mod node;
mod reconcile;
pub(crate) use self::layout::measure_animated;
pub use self::node::AnimatedNode;
pub(crate) use self::reconcile::reconcile_animated;
use std::hash::Hash;
use crate::animation::{ExitAnimation, TransitionConfig};
use crate::callback::Callback;
use crate::core::element::{Element, ElementKind};
use crate::layout::hash::LayoutHash;
use crate::style::{Color, LayoutConstraints, Length};
use crate::widgets::Spacer;
/// Animate child opacity, revealed height, colors, and optional visual position changes.
#[derive(Clone)]
pub struct Animated {
pub(crate) child: Box<Element>,
pub(crate) opacity: f32,
pub(crate) opacity_fg_only: bool,
pub(crate) opacity_target: Option<Color>,
pub(crate) fg: Option<Color>,
pub(crate) bg: Option<Color>,
pub(crate) transition: TransitionConfig,
pub(crate) height: Option<Length>,
pub(crate) layout_height: Option<Length>,
pub(crate) position_transition: bool,
pub(crate) auto_exit: Option<ExitAnimation>,
pub(crate) on_opacity_transition_end: Option<Callback<()>>,
pub(crate) on_height_transition_end: Option<Callback<()>>,
pub(crate) on_position_transition_end: Option<Callback<()>>,
}
impl Default for Animated {
fn default() -> Self {
Self {
child: Box::new(Spacer::new().into()),
opacity: 1.0,
opacity_fg_only: false,
opacity_target: None,
auto_exit: None,
fg: None,
bg: None,
transition: TransitionConfig::default(),
height: None,
layout_height: None,
position_transition: false,
on_opacity_transition_end: None,
on_height_transition_end: None,
on_position_transition_end: None,
}
}
}
impl Animated {
/// Create an animated wrapper around `child`.
pub fn new(child: impl Into<Element>) -> Self {
Self {
child: Box::new(child.into()),
..Self::default()
}
}
/// Set wrapped child content.
pub fn child(mut self, child: impl Into<Element>) -> Self {
self.child = Box::new(child.into());
self
}
/// Set target opacity (`0.0` transparent, `1.0` fully visible).
pub fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity.clamp(0.0, 1.0);
self
}
/// When true, [`Animated::opacity`] only scales foreground alpha; cell backgrounds are unchanged.
pub fn opacity_fg_only(mut self, fg_only: bool) -> Self {
self.opacity_fg_only = fg_only;
self
}
/// When set, the opacity post-pass blends toward this color instead of the terminal backdrop.
///
/// Only [`Animated::opacity`] is animated; changing this target mid-transition snaps immediately.
/// Composes with [`Animated::fg`] / [`Animated::bg`] (they set the base colors that the wash runs on)
/// and with [`Animated::opacity_fg_only`] (restricts the wash to foreground cells).
pub fn opacity_target(mut self, color: Color) -> Self {
self.opacity_target = Some(color);
self
}
/// Set target animated foreground color.
pub fn fg(mut self, color: Color) -> Self {
self.fg = Some(color);
self
}
/// Set target animated background color.
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
/// Configure transition timing for this wrapper.
pub fn transition(mut self, transition: TransitionConfig) -> Self {
self.transition = transition;
self
}
/// Configure transition duration in milliseconds.
pub fn duration(mut self, ms: u64) -> Self {
self.transition.duration = std::time::Duration::from_millis(ms);
self
}
/// Configure transition easing.
pub fn easing(mut self, easing: crate::animation::Easing) -> Self {
self.transition.easing = easing;
self
}
/// Set optional animated height target.
///
/// - `None`: wrapper height follows parent allocation.
/// - `Some(Length::Auto)`: uses measured child natural height.
/// - `Some(Length::Px(_))`: uses explicit pixel target.
pub fn height(mut self, height: Length) -> Self {
self.height = Some(height);
self
}
/// Override the height used for stack measurement and gap math while [`Animated::height`] still
/// drives the animated target.
///
/// Use while collapsing so parents keep reserving natural height until
/// [`Animated::on_height_transition_end`] fires, then clear (`None`) so layout matches the final
/// target.
pub fn layout_height(mut self, height: Option<Length>) -> Self {
self.layout_height = height;
self
}
/// Enable or disable visual position transitions for this wrapper.
///
/// When enabled on an existing keyed `Animated` node, layout rect changes animate visually from
/// the previous origin to the new final origin while hit-testing and layout use the final rect
/// immediately. Initial mount does not animate.
pub fn position_transition(mut self, enabled: bool) -> Self {
self.position_transition = enabled;
self
}
/// Called once when a height transition reaches its target (including zero-duration jumps).
pub fn on_height_transition_end(mut self, cb: Callback<()>) -> Self {
self.on_height_transition_end = Some(cb);
self
}
/// Called once when an opacity transition reaches its target (including zero-duration jumps).
pub fn on_opacity_transition_end(mut self, cb: Callback<()>) -> Self {
self.on_opacity_transition_end = Some(cb);
self
}
/// Called once when a position transition reaches its final layout origin.
///
/// This also fires for zero-duration position transitions that snap immediately.
pub fn on_position_transition_end(mut self, cb: Callback<()>) -> Self {
self.on_position_transition_end = Some(cb);
self
}
/// Fade and collapse helper for mount/unmount transitions.
///
/// Sets opacity, animated height, and duration in one call to drive the
/// standard "appear / disappear" animation. Pair with
/// [`Animated::on_exit_complete`] to be notified when the disappearance
/// finishes so the parent can actually drop the element from state.
///
/// - `visible == true`: opacity `1.0`, height `Length::Auto`.
/// - `visible == false`: opacity `0.0`, height `Length::Px(0)`.
///
/// Both directions use `duration_ms` and the wrapper's currently configured
/// easing (defaults to `EaseOutQuad`; override with [`Animated::easing`]).
///
/// ```ignore
/// // state.visible: bool, state.removed: bool
/// if !state.removed {
/// Animated::new(child)
/// .exit(state.visible, 200)
/// .on_exit_complete(ctx.link().callback(|_| Msg::Removed))
/// }
/// ```
pub fn exit(mut self, visible: bool, duration_ms: u64) -> Self {
self.opacity = if visible { 1.0 } else { 0.0 };
self.height = Some(if visible { Length::Auto } else { Length::Px(0) });
self.transition.duration = std::time::Duration::from_millis(duration_ms);
self
}
/// Play an exit animation automatically when this element is removed.
///
/// [`Animated::exit`] requires the parent to keep the element in its own state until
/// [`Animated::on_exit_complete`] fires, because the reconciler frees any node that is not
/// re-described during `view()`. `auto_exit` moves that bookkeeping into the framework: the
/// element can simply stop being described, and its container retains the already-rendered
/// subtree, animates it out, and drops it.
///
/// Takes anything that converts into an [`ExitAnimation`]. A bare duration is the common case
/// and means "fade out over this many milliseconds":
///
/// ```ignore
/// // No `removed` flag, no on_exit_complete plumbing: dropping it from the list is enough.
/// VStack::new().children(state.rows.iter().map(|row| {
/// Animated::new(row_view(row)).auto_exit(200).key(row.id)
/// }))
///
/// // Or say what leaving should look like.
/// Animated::new(toast)
/// .auto_exit(ExitAnimation::slide(180, 0, -1).with_collapse(true))
/// .key(id)
/// ```
///
/// # Requirements
///
/// The element must carry a [`Key`](crate::Key) and sit directly in a `VStack`, `HStack`,
/// `ZStack`, or `Canvas`. Keys are how the container recognizes that a specific child left
/// rather than that the list merely reordered. Debug builds log when either is missing.
///
/// # What the container decides
///
/// Everything visual comes from the [`ExitAnimation`]. The one thing it does not control is
/// whether height collapses, because that is a layout question the parent owns:
///
/// - A **`VStack` or `HStack`** always collapses, whatever the exit says. The collapse is what
/// lets siblings reflow into the vacated space, so it is part of the container's contract.
/// - A **`Canvas` or `ZStack`** collapses only if the exit asked for it with
/// [`ExitAnimation::with_collapse`]. Nothing reflows around a positioned child, so there is
/// no space to reclaim and the collapse is a pure effect.
///
/// A `Canvas` additionally draws exiting children *beneath* every live one, so a departing
/// element can never cover something the application is still describing.
///
/// # Lifecycle and disposal
///
/// A retained subtree is a **snapshot**, not a living element. The container keeps the node it
/// already reconciled; the element itself stopped being described, so on that same frame its
/// component state, hooks, command registrations, and scroll state were all disposed by the
/// ordinary sweep. Only the resolved node data survives, which is exactly enough to keep
/// painting it.
///
/// The framework enforces what follows from that, so an exit cannot reach into a dropped
/// scope:
///
/// - The subtree is **inert**: skipped for hit-testing, focus, and key routing. It cannot be
/// clicked, cannot take focus, and receives no keys.
/// - Transition-end callbacks (`on_opacity_transition_end` and friends) do **not** fire during
/// an automatic exit.
/// - Nothing re-runs `view()`, so no effect, command, or state read happens on its behalf.
///
/// Retention also ends on a deadline derived from the exit duration, so a container that stops
/// being rendered mid-exit cannot hold the subtree indefinitely. Re-adding the same key before
/// the exit finishes cancels it and hands the live element back.
///
/// Use [`Animated::exit`] with [`ExitQueue`](crate::animation::ExitQueue) instead when the app
/// needs to own the lifecycle, or when the exit has to change where the element's *children*
/// sit: a retained subtree is never re-laid out, so scaling and reflowing are out of reach.
/// See [`ExitAnimation`] for that boundary in full.
pub fn auto_exit(mut self, exit: impl Into<ExitAnimation>) -> Self {
// Deliberately touches neither `height` nor `transition`. Opting into an exit must not
// change how the element looks or lays out while it is alive; the exit carries its own
// duration and easing, and the collapse reads the node's real rectangle rather than a
// resolved `Length`.
self.auto_exit = Some(exit.into());
self
}
/// Callback fired once when an [`Animated::exit`]-style collapse finishes,
/// i.e. when the height transition reaches its final target.
///
/// This is an alias for [`Animated::on_height_transition_end`] —
/// `exit(false, ..)` settles height last, so this fires when the element
/// has fully collapsed and is safe to remove from state.
pub fn on_exit_complete(self, cb: Callback<()>) -> Self {
self.on_height_transition_end(cb)
}
}
impl From<Animated> for Element {
fn from(value: Animated) -> Self {
let (min_w, min_h) = measure_animated(&value, None, None);
let mut layout = LayoutConstraints::default().min_width(Length::Px(min_w));
if value.height.is_none() {
layout = layout.min_height(Length::Px(min_h));
}
Element::new(ElementKind::Animated(value)).with_layout(layout)
}
}
impl LayoutHash for Animated {
fn layout_hash(
&self,
hasher: &mut impl std::hash::Hasher,
recurse: &dyn Fn(&Element) -> Option<u64>,
) -> Option<()> {
self.opacity.to_bits().hash(hasher);
self.opacity_fg_only.hash(hasher);
self.opacity_target.hash(hasher);
self.transition.duration.hash(hasher);
self.transition.easing.hash(hasher);
self.height.hash(hasher);
self.layout_height.hash(hasher);
self.position_transition.hash(hasher);
recurse(self.child.as_ref())?.hash(hasher);
Some(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::Spacer;
#[test]
fn exit_visible_sets_full_opacity_and_auto_height() {
let a = Animated::new(Spacer::new()).exit(true, 200);
assert_eq!(a.opacity, 1.0);
assert_eq!(a.height, Some(Length::Auto));
assert_eq!(a.transition.duration.as_millis(), 200);
}
#[test]
fn exit_hidden_sets_zero_opacity_and_zero_height() {
let a = Animated::new(Spacer::new()).exit(false, 150);
assert_eq!(a.opacity, 0.0);
assert_eq!(a.height, Some(Length::Px(0)));
assert_eq!(a.transition.duration.as_millis(), 150);
}
#[test]
fn on_exit_complete_aliases_height_transition_end() {
let cb = Callback::new(|_: ()| {});
let a = Animated::new(Spacer::new()).on_exit_complete(cb);
assert!(a.on_height_transition_end.is_some());
}
}