Skip to main content

repose_ui/
anim_ext.rs

1use std::cell::RefCell;
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4
5use crate::{Box, ViewExt, ZStack};
6use repose_core::*;
7
8use crate::anim::animate_f32_from;
9use crate::anim::animate_vec2_from;
10
11/// Vertical/horizontal alignment for expand/shrink transitions (0 = start, 1 = end).
12pub type ExpandFrom = f32;
13
14/// Describes how content enters the screen.
15#[derive(Clone, Debug)]
16pub enum EnterTransition {
17    /// Fade from alpha 0 to 1.
18    FadeIn,
19    /// Slide from the given offset (dx, dy in [`Dp`]) to position (0,0).
20    SlideIn { offset_x: Dp, offset_y: Dp },
21    /// Scale from initial to 1.0 combined with fade from 0 to 1.
22    ScaleIn { initial: f32 },
23    /// Animate layout height 0 -> full and clip (Compose `expandVertically`).
24    /// Participates in layout: siblings reflow as the height animates.
25    ExpandVertically {
26        /// Clip content to the animated bounds (Compose default: true).
27        clip: bool,
28        /// 0.0 = top, 0.5 = center, 1.0 = bottom.
29        expand_from: ExpandFrom,
30    },
31    /// Animate layout width 0 -> full and clip (Compose `expandHorizontally`).
32    ExpandHorizontally {
33        clip: bool,
34        /// 0.0 = start/left, 1.0 = end/right.
35        expand_from: ExpandFrom,
36    },
37    /// Expand both axes (Compose `expandIn`).
38    ExpandIn { clip: bool },
39    /// Multiple enter transitions applied together (Compose `+`).
40    Composite(Vec<EnterTransition>),
41}
42
43impl Default for EnterTransition {
44    fn default() -> Self {
45        Self::fade_in().and(Self::expand_vertically())
46    }
47}
48
49impl EnterTransition {
50    pub fn fade_in() -> Self {
51        Self::FadeIn
52    }
53    pub fn expand_vertically() -> Self {
54        Self::ExpandVertically {
55            clip: true,
56            expand_from: 0.0,
57        }
58    }
59    pub fn expand_horizontally() -> Self {
60        Self::ExpandHorizontally {
61            clip: true,
62            expand_from: 0.0,
63        }
64    }
65    pub fn expand_in() -> Self {
66        Self::ExpandIn { clip: true }
67    }
68    pub fn slide_in(offset_x: Dp, offset_y: Dp) -> Self {
69        Self::SlideIn { offset_x, offset_y }
70    }
71    pub fn scale_in(initial: f32) -> Self {
72        Self::ScaleIn { initial }
73    }
74    /// Compose-style `this + other`.
75    pub fn and(self, other: Self) -> Self {
76        match (self, other) {
77            (Self::Composite(mut a), Self::Composite(b)) => {
78                a.extend(b);
79                Self::Composite(a)
80            }
81            (Self::Composite(mut a), b) => {
82                a.push(b);
83                Self::Composite(a)
84            }
85            (a, Self::Composite(mut b)) => {
86                let mut v = vec![a];
87                v.append(&mut b);
88                Self::Composite(v)
89            }
90            (a, b) => Self::Composite(vec![a, b]),
91        }
92    }
93}
94
95/// Describes how content exits the screen.
96#[derive(Clone, Debug)]
97pub enum ExitTransition {
98    /// Fade from alpha 1 to 0.
99    FadeOut,
100    /// Slide from position (0,0) to the given offset (dx, dy in [`Dp`]).
101    SlideOut { offset_x: Dp, offset_y: Dp },
102    /// Scale from 1.0 to target combined with fade from 1 to 0.
103    ScaleOut { target: f32 },
104    /// Animate layout height full -> 0 and clip (Compose `shrinkVertically`).
105    ShrinkVertically {
106        clip: bool,
107        /// 0.0 = toward top, 1.0 = toward bottom.
108        shrink_towards: ExpandFrom,
109    },
110    /// Animate layout width full -> 0 and clip (Compose `shrinkHorizontally`).
111    ShrinkHorizontally {
112        clip: bool,
113        shrink_towards: ExpandFrom,
114    },
115    /// Shrink both axes (Compose `shrinkOut`).
116    ShrinkOut { clip: bool },
117    /// Multiple exit transitions applied together (Compose `+`).
118    Composite(Vec<ExitTransition>),
119}
120
121impl Default for ExitTransition {
122    fn default() -> Self {
123        Self::fade_out().and(Self::shrink_vertically())
124    }
125}
126
127impl ExitTransition {
128    pub fn fade_out() -> Self {
129        Self::FadeOut
130    }
131    pub fn shrink_vertically() -> Self {
132        Self::ShrinkVertically {
133            clip: true,
134            shrink_towards: 0.0,
135        }
136    }
137    pub fn shrink_horizontally() -> Self {
138        Self::ShrinkHorizontally {
139            clip: true,
140            shrink_towards: 0.0,
141        }
142    }
143    pub fn shrink_out() -> Self {
144        Self::ShrinkOut { clip: true }
145    }
146    pub fn slide_out(offset_x: Dp, offset_y: Dp) -> Self {
147        Self::SlideOut { offset_x, offset_y }
148    }
149    pub fn scale_out(target: f32) -> Self {
150        Self::ScaleOut { target }
151    }
152    /// Compose-style `this + other`.
153    pub fn and(self, other: Self) -> Self {
154        match (self, other) {
155            (Self::Composite(mut a), Self::Composite(b)) => {
156                a.extend(b);
157                Self::Composite(a)
158            }
159            (Self::Composite(mut a), b) => {
160                a.push(b);
161                Self::Composite(a)
162            }
163            (a, Self::Composite(mut b)) => {
164                let mut v = vec![a];
165                v.append(&mut b);
166                Self::Composite(v)
167            }
168            (a, b) => Self::Composite(vec![a, b]),
169        }
170    }
171}
172
173#[derive(Clone)]
174pub struct CrossfadeConfig {
175    pub key: String,
176    pub spec: AnimationSpec,
177}
178
179impl Default for CrossfadeConfig {
180    fn default() -> Self {
181        Self {
182            key: "crossfade".into(),
183            spec: AnimationSpec::default(),
184        }
185    }
186}
187
188/// Crossfades between two pieces of content when `target` changes.
189///
190/// When the target state changes, the old content fades out while the new
191/// content fades in, with no other transforms applied.
192pub fn Crossfade<T, F>(target: T, config: CrossfadeConfig, content: F) -> View
193where
194    T: PartialEq + Clone + 'static,
195    F: Fn(T) -> View + 'static,
196{
197    let key = config.key;
198    let spec = config.spec;
199
200    let prev = remember_with_key(format!("cf_prev:{key}"), || RefCell::new(target.clone()));
201    let old_content =
202        remember_with_key(format!("cf_old_view:{key}"), || RefCell::new(None::<View>));
203    let version = remember_with_key(format!("cf_version:{key}"), || RefCell::new(0u64));
204
205    let is_new = *prev.borrow() != target;
206    if is_new {
207        let _old_ver = *version.borrow();
208        let mut prev_view = content(prev.borrow().clone());
209        prev_view.scope_key = None;
210        prev_view.modifier.repaint_boundary = true;
211        old_content.borrow_mut().replace(prev_view);
212        prev.borrow_mut().clone_from(&target);
213        *version.borrow_mut() += 1;
214    }
215
216    let v = *version.borrow();
217    let mut new_view = content(target.clone());
218    new_view.scope_key = None;
219    new_view.modifier.repaint_boundary = true;
220
221    // Exit: versioned key ensures fresh animation state per transition.
222    let old_view = {
223        let mut oc = old_content.borrow_mut();
224        if let Some(ref ov) = *oc {
225            let exit_alpha = animate_f32_from(format!("cf_exit:{key}:v{v}"), 1.0, 0.0, spec);
226            if exit_alpha > 0.005 {
227                let mut exit_box = Box(Modifier::new()
228                    .fill_max_size()
229                    .alpha(exit_alpha)
230                    .hit_passthrough())
231                .child(ov.clone());
232                exit_box.modifier.key = Some(transition_child_key(&key, v, "cf_exit"));
233                Some(exit_box)
234            } else {
235                *oc = None;
236                None
237            }
238        } else {
239            None
240        }
241    };
242
243    // Enter: versioned key ensures fade-in starts at 0 on each transition.
244    let enter_alpha = animate_f32_from(format!("cf_enter:{key}:v{v}"), 0.0, 1.0, spec);
245    let mut enter_box = Box(Modifier::new().fill_max_size().alpha(enter_alpha)).child(new_view);
246    enter_box.modifier.key = Some(transition_child_key(&key, v, "cf_enter"));
247
248    match old_view {
249        Some(ov) => ZStack(Modifier::new().fill_max_size()).child((ov, enter_box)),
250        None => enter_box,
251    }
252}
253
254#[derive(Clone)]
255pub struct AnimatedContentConfig {
256    pub key: String,
257    pub spec: AnimationSpec,
258    pub enter: EnterTransition,
259    pub exit: ExitTransition,
260}
261
262impl Default for AnimatedContentConfig {
263    fn default() -> Self {
264        Self {
265            key: "anim_content".into(),
266            spec: AnimationSpec::default(),
267            enter: EnterTransition::FadeIn,
268            exit: ExitTransition::FadeOut,
269        }
270    }
271}
272
273/// Stable child key for tree reconciliation during animated transitions.
274fn transition_child_key(key: &str, version: u64, tag: &str) -> u64 {
275    let mut h = DefaultHasher::new();
276    key.hash(&mut h);
277    version.hash(&mut h);
278    tag.hash(&mut h);
279    h.finish()
280}
281
282/// In-flow wrapper modifier for enter/exit transitions. Never fills height
283/// unconditionally: that would fight the parent `Column` (fill-max height on a
284/// flex child makes it grow to consume leftover space). The wrapper mirrors
285/// the wrapped view's own height fill instead, so `fill_max_size` overlays
286/// keep their parent's height rather than degrading to content height under
287/// a hug-height wrapper.
288fn flow_mod(content: &View) -> Modifier {
289    let m = &content.modifier;
290    let mut modifier = Modifier::new().fill_max_width();
291    let explicit_height = m.height.is_some() || m.size.is_some() || m.required_size.is_some();
292    if !explicit_height && let Some(frac) = m.fill_max_h.or(m.fill_max) {
293        modifier = modifier.fill_max_height_frac(frac);
294    }
295    modifier
296}
297
298/// Hit tests must pass through so a closing overlay can never steal clicks
299/// from content that appears beneath it.
300fn exit_mod_fill() -> Modifier {
301    Modifier::new().fill_max_size().hit_passthrough()
302}
303
304/// In-flow variant for exiting content in a Column/Row.
305fn exit_mod_flow(content: &View) -> Modifier {
306    flow_mod(content).hit_passthrough()
307}
308
309/// Which axis an expand/shrink transition animates.
310#[derive(Clone, Copy)]
311enum SizeAxis {
312    Vertical,
313    Horizontal,
314    Both,
315}
316
317/// Layout-true expand/shrink: the outer node reports an animated size (so
318/// siblings in the parent `Column` reflow smoothly) while the content keeps its
319/// natural measured size and is clipped to the animated bounds.
320///
321/// The natural size is measured via `on_size_changed` the first time the content
322/// is composed at full size; afterwards it is remembered (`measure_key`) and
323/// reused by both enter and exit animations.
324#[allow(clippy::too_many_arguments)]
325fn apply_size_fraction(
326    measure_key: &str,
327    anim_key: &str,
328    axis: SizeAxis,
329    from: f32,
330    to: f32,
331    clip: bool,
332    align: f32,
333    spec: AnimationSpec,
334    view: View,
335) -> View {
336    let full_w = remember_mutable_with_key(format!("{measure_key}:w"), || 0.0f32);
337    let full_h = remember_mutable_with_key(format!("{measure_key}:h"), || 0.0f32);
338
339    let have = match axis {
340        SizeAxis::Vertical => *full_h.get() > 0.5,
341        SizeAxis::Horizontal => *full_w.get() > 0.5,
342        SizeAxis::Both => *full_w.get() > 0.5 && *full_h.get() > 0.5,
343    };
344
345    let progress = if have {
346        animate_f32_from(anim_key, from, to, spec)
347    } else {
348        from
349    };
350
351    let capture = {
352        let fw = full_w.clone();
353        let fh = full_h.clone();
354        move |sz: Vec2| {
355            if sz.x > 0.5 {
356                fw.set_neq(sz.x);
357            }
358            if sz.y > 0.5 {
359                fh.set_neq(sz.y);
360            }
361        }
362    };
363
364    let settled = from < to && progress >= 0.999;
365
366    let full_w = *full_w.get();
367    let full_h = *full_h.get();
368
369    let shown_w = match axis {
370        SizeAxis::Vertical => full_w,
371        SizeAxis::Horizontal | SizeAxis::Both => full_w * progress,
372    };
373    let shown_h = match axis {
374        SizeAxis::Horizontal => full_h,
375        SizeAxis::Vertical | SizeAxis::Both => full_h * progress,
376    };
377
378    let mut outer = Modifier::new().fill_max_width().flex_shrink(0.0);
379    if !have || settled {
380        // Not measured yet (first composition): lay out at natural size so
381        // `on_size_changed` can capture the true size, staying clipped. The
382        // companion fade keeps this frame invisible. When settled (enter done),
383        // release the fixed size and keep refreshing the measure while open.
384        outer = outer.on_size_changed(capture);
385    } else {
386        match axis {
387            SizeAxis::Vertical => {
388                outer = outer.height(Dp(shown_h.max(0.0)));
389            }
390            SizeAxis::Horizontal => {
391                outer = outer.width(Dp(shown_w.max(0.0)));
392            }
393            SizeAxis::Both => {
394                outer = outer.size(Dp(shown_w.max(0.0)), Dp(shown_h.max(0.0)));
395            }
396        }
397    }
398    if clip {
399        outer = outer.overflow(repose_core::Overflow::Clip);
400    }
401
402    // Inner keeps its full natural size and must not shrink into the animating
403    // window; the outer bounds clip hides the overflow. This is what makes the
404    // content get *clipped* (Compose `expandVertically`) instead of squashing.
405    let align = align.clamp(0.0, 1.0);
406    let ox = match axis {
407        SizeAxis::Vertical => 0.0,
408        SizeAxis::Horizontal | SizeAxis::Both => (shown_w - full_w) * align,
409    };
410    let oy = match axis {
411        SizeAxis::Horizontal => 0.0,
412        SizeAxis::Vertical | SizeAxis::Both => (shown_h - full_h) * align,
413    };
414    let mut inner = Modifier::new().fill_max_width().flex_shrink(0.0);
415    if have && !settled {
416        match axis {
417            SizeAxis::Vertical => {
418                inner = inner.height(Dp(full_h));
419            }
420            SizeAxis::Horizontal => {
421                inner = inner.width(Dp(full_w));
422            }
423            SizeAxis::Both => {
424                inner = inner.size(Dp(full_w), Dp(full_h));
425            }
426        }
427    }
428    if ox.abs() > 0.01 || oy.abs() > 0.01 {
429        inner = inner.translate(Dp(ox).to_px().0, Dp(oy).to_px().0);
430    }
431
432    Box(outer).child(Box(inner).child(view))
433}
434
435/// Animates between different content based on the `target_state`, with
436/// configurable enter and exit transitions.
437///
438/// When the target state changes, the old content animates out using the
439/// `exit` transition while the new content animates in using the `enter`
440/// transition. During the transition both are stacked on top of each other.
441pub fn AnimatedContent<T, F>(target_state: T, content: F, config: AnimatedContentConfig) -> View
442where
443    T: PartialEq + Clone + 'static,
444    F: Fn(T) -> View + 'static,
445{
446    let key = config.key;
447    let spec = config.spec;
448    let enter = config.enter;
449    let exit = config.exit;
450
451    let prev = remember_with_key(format!("ac_prev:{key}"), || {
452        RefCell::new(target_state.clone())
453    });
454    let old_content =
455        remember_with_key(format!("ac_old_view:{key}"), || RefCell::new(None::<View>));
456    let version = remember_with_key(format!("ac_version:{key}"), || RefCell::new(0u64));
457
458    let is_new = *prev.borrow() != target_state;
459    if is_new {
460        let _old_ver = *version.borrow();
461        let mut prev_view = content(prev.borrow().clone());
462        prev_view.scope_key = None;
463        prev_view.modifier.repaint_boundary = true;
464        old_content.borrow_mut().replace(prev_view);
465        prev.borrow_mut().clone_from(&target_state);
466        *version.borrow_mut() += 1;
467    }
468
469    let v = *version.borrow();
470    let mut new_view = content(target_state.clone());
471    new_view.scope_key = None;
472    new_view.modifier.repaint_boundary = true;
473    let mut new_view = apply_enter(&key, v, &enter, &spec, new_view);
474    new_view.modifier.key = Some(transition_child_key(&key, v, "ac_enter"));
475
476    let old_view = {
477        let mut oc = old_content.borrow_mut();
478        if let Some(ref ov) = *oc {
479            // Check if exit is already done (read-only, no side effects).
480            if exit_animation_done(&key, v, &exit) {
481                *oc = None;
482                None
483            } else {
484                let mut exit_view = apply_exit(&key, v, &exit, &spec, ov.clone());
485                exit_view.modifier.key = Some(transition_child_key(&key, v, "ac_exit"));
486                Some(exit_view)
487            }
488        } else {
489            None
490        }
491    };
492
493    match old_view {
494        Some(ov) => ZStack(Modifier::new().fill_max_size()).child((ov, new_view)),
495        None => new_view,
496    }
497}
498
499fn apply_enter(
500    key: &str,
501    version: u64,
502    enter: &EnterTransition,
503    spec: &AnimationSpec,
504    view: View,
505) -> View {
506    match enter {
507        EnterTransition::FadeIn => {
508            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
509            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
510        }
511        EnterTransition::SlideIn { offset_x, offset_y } => {
512            let offset = animate_vec2_from(
513                format!("{key}:v{version}:enter:slide"),
514                Vec2 {
515                    x: offset_x.to_px().0,
516                    y: offset_y.to_px().0,
517                },
518                Vec2::default(),
519                *spec,
520            );
521            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
522        }
523        EnterTransition::ScaleIn { initial } => {
524            let s = animate_f32_from(
525                format!("{key}:v{version}:enter:scale"),
526                *initial,
527                1.0,
528                *spec,
529            );
530            let a = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
531            Box(Modifier::new()
532                .fill_max_size()
533                .transform_origin(0.5, 0.5)
534                .scale(s)
535                .alpha(a))
536            .child(view)
537        }
538        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
539            &format!("{key}:meas"),
540            &format!("{key}:v{version}:enter:expand_v"),
541            SizeAxis::Vertical,
542            0.0,
543            1.0,
544            *clip,
545            *expand_from,
546            *spec,
547            view,
548        ),
549        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
550            &format!("{key}:meas"),
551            &format!("{key}:v{version}:enter:expand_h"),
552            SizeAxis::Horizontal,
553            0.0,
554            1.0,
555            *clip,
556            *expand_from,
557            *spec,
558            view,
559        ),
560        EnterTransition::ExpandIn { clip } => apply_size_fraction(
561            &format!("{key}:meas"),
562            &format!("{key}:v{version}:enter:expand_in"),
563            SizeAxis::Both,
564            0.0,
565            1.0,
566            *clip,
567            0.0,
568            *spec,
569            view,
570        ),
571        EnterTransition::Composite(transitions) => {
572            let mut v = view;
573            for t in transitions {
574                v = apply_enter_single(key, version, t, spec, v);
575            }
576            v
577        }
578    }
579}
580
581fn apply_enter_single(
582    key: &str,
583    version: u64,
584    enter: &EnterTransition,
585    spec: &AnimationSpec,
586    view: View,
587) -> View {
588    match enter {
589        EnterTransition::FadeIn => {
590            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
591            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
592        }
593        EnterTransition::SlideIn { offset_x, offset_y } => {
594            let offset = animate_vec2_from(
595                format!("{key}:v{version}:enter:slide"),
596                Vec2 {
597                    x: offset_x.to_px().0,
598                    y: offset_y.to_px().0,
599                },
600                Vec2::default(),
601                *spec,
602            );
603            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
604        }
605        EnterTransition::ScaleIn { initial } => {
606            let s = animate_f32_from(
607                format!("{key}:v{version}:enter:scale"),
608                *initial,
609                1.0,
610                *spec,
611            );
612            Box(Modifier::new()
613                .fill_max_size()
614                .transform_origin(0.5, 0.5)
615                .scale(s))
616            .child(view)
617        }
618        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
619            &format!("{key}:meas"),
620            &format!("{key}:v{version}:enter:expand_v"),
621            SizeAxis::Vertical,
622            0.0,
623            1.0,
624            *clip,
625            *expand_from,
626            *spec,
627            view,
628        ),
629        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
630            &format!("{key}:meas"),
631            &format!("{key}:v{version}:enter:expand_h"),
632            SizeAxis::Horizontal,
633            0.0,
634            1.0,
635            *clip,
636            *expand_from,
637            *spec,
638            view,
639        ),
640        EnterTransition::ExpandIn { clip } => apply_size_fraction(
641            &format!("{key}:meas"),
642            &format!("{key}:v{version}:enter:expand_in"),
643            SizeAxis::Both,
644            0.0,
645            1.0,
646            *clip,
647            0.0,
648            *spec,
649            view,
650        ),
651        EnterTransition::Composite(inner) => {
652            let mut v = view;
653            for t in inner {
654                v = apply_enter_single(key, version, t, spec, v);
655            }
656            v
657        }
658    }
659}
660
661fn apply_exit(
662    key: &str,
663    version: u64,
664    exit: &ExitTransition,
665    spec: &AnimationSpec,
666    view: View,
667) -> View {
668    match exit {
669        ExitTransition::FadeOut => {
670            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
671            Box(exit_mod_fill().alpha(val)).child(view)
672        }
673        ExitTransition::SlideOut { offset_x, offset_y } => {
674            let offset = animate_vec2_from(
675                format!("{key}:v{version}:exit:slide"),
676                Vec2::default(),
677                Vec2 {
678                    x: offset_x.to_px().0,
679                    y: offset_y.to_px().0,
680                },
681                *spec,
682            );
683            Box(exit_mod_fill().translate_vec2(offset)).child(view)
684        }
685        ExitTransition::ScaleOut { target } => {
686            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
687            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
688            Box(exit_mod_fill().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
689        }
690        ExitTransition::ShrinkVertically {
691            clip,
692            shrink_towards,
693        } => apply_size_fraction(
694            &format!("{key}:meas"),
695            &format!("{key}:v{version}:exit:expand_v"),
696            SizeAxis::Vertical,
697            1.0,
698            0.0,
699            *clip,
700            *shrink_towards,
701            *spec,
702            view,
703        ),
704        ExitTransition::ShrinkHorizontally {
705            clip,
706            shrink_towards,
707        } => apply_size_fraction(
708            &format!("{key}:meas"),
709            &format!("{key}:v{version}:exit:expand_h"),
710            SizeAxis::Horizontal,
711            1.0,
712            0.0,
713            *clip,
714            *shrink_towards,
715            *spec,
716            view,
717        ),
718        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
719            &format!("{key}:meas"),
720            &format!("{key}:v{version}:exit:expand_in"),
721            SizeAxis::Both,
722            1.0,
723            0.0,
724            *clip,
725            0.0,
726            *spec,
727            view,
728        ),
729        ExitTransition::Composite(transitions) => {
730            let mut v = view;
731            for t in transitions {
732                v = apply_exit_single(key, version, t, spec, v);
733            }
734            v
735        }
736    }
737}
738
739fn apply_exit_single(
740    key: &str,
741    version: u64,
742    exit: &ExitTransition,
743    spec: &AnimationSpec,
744    view: View,
745) -> View {
746    match exit {
747        ExitTransition::FadeOut => {
748            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
749            Box(exit_mod_fill().alpha(val)).child(view)
750        }
751        ExitTransition::SlideOut { offset_x, offset_y } => {
752            let offset = animate_vec2_from(
753                format!("{key}:v{version}:exit:slide"),
754                Vec2::default(),
755                Vec2 {
756                    x: offset_x.to_px().0,
757                    y: offset_y.to_px().0,
758                },
759                *spec,
760            );
761            Box(exit_mod_fill().translate_vec2(offset)).child(view)
762        }
763        ExitTransition::ScaleOut { target } => {
764            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
765            Box(exit_mod_fill().transform_origin(0.5, 0.5).scale(s)).child(view)
766        }
767        ExitTransition::ShrinkVertically {
768            clip,
769            shrink_towards,
770        } => apply_size_fraction(
771            &format!("{key}:meas"),
772            &format!("{key}:v{version}:exit:expand_v"),
773            SizeAxis::Vertical,
774            1.0,
775            0.0,
776            *clip,
777            *shrink_towards,
778            *spec,
779            view,
780        ),
781        ExitTransition::ShrinkHorizontally {
782            clip,
783            shrink_towards,
784        } => apply_size_fraction(
785            &format!("{key}:meas"),
786            &format!("{key}:v{version}:exit:expand_h"),
787            SizeAxis::Horizontal,
788            1.0,
789            0.0,
790            *clip,
791            *shrink_towards,
792            *spec,
793            view,
794        ),
795        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
796            &format!("{key}:meas"),
797            &format!("{key}:v{version}:exit:expand_in"),
798            SizeAxis::Both,
799            1.0,
800            0.0,
801            *clip,
802            0.0,
803            *spec,
804            view,
805        ),
806        ExitTransition::Composite(inner) => {
807            let mut v = view;
808            for t in inner {
809                v = apply_exit_single(key, version, t, spec, v);
810            }
811            v
812        }
813    }
814}
815
816/// In-flow variant of `apply_enter`: fade/slide/scale wrappers use
817/// `fill_max_width` instead of `fill_max_size` so the entering view participates
818/// in its parent `Column`/`Row` instead of trying to fill it.
819fn apply_enter_inflow(
820    key: &str,
821    version: u64,
822    enter: &EnterTransition,
823    spec: &AnimationSpec,
824    view: View,
825) -> View {
826    match enter {
827        EnterTransition::FadeIn => {
828            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
829            Box(flow_mod(&view).alpha(val)).child(view)
830        }
831        EnterTransition::SlideIn { offset_x, offset_y } => {
832            let offset = animate_vec2_from(
833                format!("{key}:v{version}:enter:slide"),
834                Vec2 {
835                    x: offset_x.to_px().0,
836                    y: offset_y.to_px().0,
837                },
838                Vec2::default(),
839                *spec,
840            );
841            Box(flow_mod(&view).translate_vec2(offset)).child(view)
842        }
843        EnterTransition::ScaleIn { initial } => {
844            let s = animate_f32_from(
845                format!("{key}:v{version}:enter:scale"),
846                *initial,
847                1.0,
848                *spec,
849            );
850            let a = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
851            Box(flow_mod(&view).transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
852        }
853        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
854            &format!("{key}:meas"),
855            &format!("{key}:v{version}:enter:expand_v"),
856            SizeAxis::Vertical,
857            0.0,
858            1.0,
859            *clip,
860            *expand_from,
861            *spec,
862            view,
863        ),
864        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
865            &format!("{key}:meas"),
866            &format!("{key}:v{version}:enter:expand_h"),
867            SizeAxis::Horizontal,
868            0.0,
869            1.0,
870            *clip,
871            *expand_from,
872            *spec,
873            view,
874        ),
875        EnterTransition::ExpandIn { clip } => apply_size_fraction(
876            &format!("{key}:meas"),
877            &format!("{key}:v{version}:enter:expand_in"),
878            SizeAxis::Both,
879            0.0,
880            1.0,
881            *clip,
882            0.0,
883            *spec,
884            view,
885        ),
886        EnterTransition::Composite(transitions) => {
887            let mut v = view;
888            for t in transitions {
889                v = apply_enter_inflow_single(key, version, t, spec, v);
890            }
891            v
892        }
893    }
894}
895
896fn apply_enter_inflow_single(
897    key: &str,
898    version: u64,
899    enter: &EnterTransition,
900    spec: &AnimationSpec,
901    view: View,
902) -> View {
903    match enter {
904        EnterTransition::FadeIn => {
905            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
906            Box(flow_mod(&view).alpha(val)).child(view)
907        }
908        EnterTransition::SlideIn { offset_x, offset_y } => {
909            let offset = animate_vec2_from(
910                format!("{key}:v{version}:enter:slide"),
911                Vec2 {
912                    x: offset_x.to_px().0,
913                    y: offset_y.to_px().0,
914                },
915                Vec2::default(),
916                *spec,
917            );
918            Box(flow_mod(&view).translate_vec2(offset)).child(view)
919        }
920        EnterTransition::ScaleIn { initial } => {
921            let s = animate_f32_from(
922                format!("{key}:v{version}:enter:scale"),
923                *initial,
924                1.0,
925                *spec,
926            );
927            Box(flow_mod(&view).transform_origin(0.5, 0.5).scale(s)).child(view)
928        }
929        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
930            &format!("{key}:meas"),
931            &format!("{key}:v{version}:enter:expand_v"),
932            SizeAxis::Vertical,
933            0.0,
934            1.0,
935            *clip,
936            *expand_from,
937            *spec,
938            view,
939        ),
940        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
941            &format!("{key}:meas"),
942            &format!("{key}:v{version}:enter:expand_h"),
943            SizeAxis::Horizontal,
944            0.0,
945            1.0,
946            *clip,
947            *expand_from,
948            *spec,
949            view,
950        ),
951        EnterTransition::ExpandIn { clip } => apply_size_fraction(
952            &format!("{key}:meas"),
953            &format!("{key}:v{version}:enter:expand_in"),
954            SizeAxis::Both,
955            0.0,
956            1.0,
957            *clip,
958            0.0,
959            *spec,
960            view,
961        ),
962        EnterTransition::Composite(inner) => {
963            let mut v = view;
964            for t in inner {
965                v = apply_enter_inflow_single(key, version, t, spec, v);
966            }
967            v
968        }
969    }
970}
971
972fn apply_exit_inflow(
973    key: &str,
974    version: u64,
975    exit: &ExitTransition,
976    spec: &AnimationSpec,
977    view: View,
978) -> View {
979    match exit {
980        ExitTransition::FadeOut => {
981            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
982            Box(exit_mod_flow(&view).alpha(val)).child(view)
983        }
984        ExitTransition::SlideOut { offset_x, offset_y } => {
985            let offset = animate_vec2_from(
986                format!("{key}:v{version}:exit:slide"),
987                Vec2::default(),
988                Vec2 {
989                    x: offset_x.to_px().0,
990                    y: offset_y.to_px().0,
991                },
992                *spec,
993            );
994            Box(exit_mod_flow(&view).translate_vec2(offset)).child(view)
995        }
996        ExitTransition::ScaleOut { target } => {
997            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
998            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
999            Box(exit_mod_flow(&view)
1000                .transform_origin(0.5, 0.5)
1001                .scale(s)
1002                .alpha(a))
1003            .child(view)
1004        }
1005        ExitTransition::ShrinkVertically {
1006            clip,
1007            shrink_towards,
1008        } => apply_size_fraction(
1009            &format!("{key}:meas"),
1010            &format!("{key}:v{version}:exit:expand_v"),
1011            SizeAxis::Vertical,
1012            1.0,
1013            0.0,
1014            *clip,
1015            *shrink_towards,
1016            *spec,
1017            view,
1018        ),
1019        ExitTransition::ShrinkHorizontally {
1020            clip,
1021            shrink_towards,
1022        } => apply_size_fraction(
1023            &format!("{key}:meas"),
1024            &format!("{key}:v{version}:exit:expand_h"),
1025            SizeAxis::Horizontal,
1026            1.0,
1027            0.0,
1028            *clip,
1029            *shrink_towards,
1030            *spec,
1031            view,
1032        ),
1033        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
1034            &format!("{key}:meas"),
1035            &format!("{key}:v{version}:exit:expand_in"),
1036            SizeAxis::Both,
1037            1.0,
1038            0.0,
1039            *clip,
1040            0.0,
1041            *spec,
1042            view,
1043        ),
1044        ExitTransition::Composite(transitions) => {
1045            let mut v = view;
1046            for t in transitions {
1047                v = apply_exit_inflow_single(key, version, t, spec, v);
1048            }
1049            v
1050        }
1051    }
1052}
1053
1054fn apply_exit_inflow_single(
1055    key: &str,
1056    version: u64,
1057    exit: &ExitTransition,
1058    spec: &AnimationSpec,
1059    view: View,
1060) -> View {
1061    match exit {
1062        ExitTransition::FadeOut => {
1063            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
1064            Box(exit_mod_flow(&view).alpha(val)).child(view)
1065        }
1066        ExitTransition::SlideOut { offset_x, offset_y } => {
1067            let offset = animate_vec2_from(
1068                format!("{key}:v{version}:exit:slide"),
1069                Vec2::default(),
1070                Vec2 {
1071                    x: offset_x.to_px().0,
1072                    y: offset_y.to_px().0,
1073                },
1074                *spec,
1075            );
1076            Box(exit_mod_flow(&view).translate_vec2(offset)).child(view)
1077        }
1078        ExitTransition::ScaleOut { target } => {
1079            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
1080            Box(exit_mod_flow(&view).transform_origin(0.5, 0.5).scale(s)).child(view)
1081        }
1082        ExitTransition::ShrinkVertically {
1083            clip,
1084            shrink_towards,
1085        } => apply_size_fraction(
1086            &format!("{key}:meas"),
1087            &format!("{key}:v{version}:exit:expand_v"),
1088            SizeAxis::Vertical,
1089            1.0,
1090            0.0,
1091            *clip,
1092            *shrink_towards,
1093            *spec,
1094            view,
1095        ),
1096        ExitTransition::ShrinkHorizontally {
1097            clip,
1098            shrink_towards,
1099        } => apply_size_fraction(
1100            &format!("{key}:meas"),
1101            &format!("{key}:v{version}:exit:expand_h"),
1102            SizeAxis::Horizontal,
1103            1.0,
1104            0.0,
1105            *clip,
1106            *shrink_towards,
1107            *spec,
1108            view,
1109        ),
1110        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
1111            &format!("{key}:meas"),
1112            &format!("{key}:v{version}:exit:expand_in"),
1113            SizeAxis::Both,
1114            1.0,
1115            0.0,
1116            *clip,
1117            0.0,
1118            *spec,
1119            view,
1120        ),
1121        ExitTransition::Composite(inner) => {
1122            let mut v = view;
1123            for t in inner {
1124                v = apply_exit_inflow_single(key, version, t, spec, v);
1125            }
1126            v
1127        }
1128    }
1129}
1130
1131/// Read the current value of an `animate_f32` animation without advancing it.
1132/// Returns `None` if the animation is still running or the entry doesn't exist.
1133fn read_anim_value(key: &str, default: f32) -> Option<f32> {
1134    let anim = remember_state_with_key::<AnimatedValue<f32>>(format!("anim:f32:{key}"), || {
1135        AnimatedValue::new(default, AnimationSpec::default())
1136    });
1137    let a = anim.borrow();
1138    if a.is_animating() {
1139        None
1140    } else {
1141        Some(*a.get())
1142    }
1143}
1144
1145fn read_anim_vec2_value(key: &str, default: Vec2) -> Option<Vec2> {
1146    let anim = remember_state_with_key::<AnimatedValue<Vec2>>(format!("anim:vec2:{key}"), || {
1147        AnimatedValue::new(default, AnimationSpec::default())
1148    });
1149    let a = anim.borrow();
1150    if a.is_animating() {
1151        None
1152    } else {
1153        Some(*a.get())
1154    }
1155}
1156
1157/// Check whether an exit animation has completed (read-only, no advancement).
1158fn exit_animation_done(key: &str, version: u64, exit: &ExitTransition) -> bool {
1159    match exit {
1160        ExitTransition::FadeOut => read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1161            .map(|v| v < 0.005)
1162            .unwrap_or(false),
1163        ExitTransition::SlideOut { offset_x, offset_y } => {
1164            let target = Vec2 {
1165                x: offset_x.to_px().0,
1166                y: offset_y.to_px().0,
1167            };
1168            read_anim_vec2_value(&format!("{key}:v{version}:exit:slide"), Vec2::default())
1169                .map(|v| (v.x - target.x).abs() < 0.5 && (v.y - target.y).abs() < 0.5)
1170                .unwrap_or(false)
1171        }
1172        ExitTransition::ScaleOut { target } => {
1173            let done_scale = read_anim_value(&format!("{key}:v{version}:exit:scale"), 1.0)
1174                .map(|v| (v - target).abs() < 0.005)
1175                .unwrap_or(false);
1176            let done_fade = read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1177                .map(|v| v < 0.005)
1178                .unwrap_or(false);
1179            done_scale && done_fade
1180        }
1181        ExitTransition::ShrinkVertically { .. } => {
1182            read_anim_value(&format!("{key}:v{version}:exit:expand_v"), 1.0)
1183                .map(|v| v < 0.005)
1184                .unwrap_or(false)
1185        }
1186        ExitTransition::ShrinkHorizontally { .. } => {
1187            read_anim_value(&format!("{key}:v{version}:exit:expand_h"), 1.0)
1188                .map(|v| v < 0.005)
1189                .unwrap_or(false)
1190        }
1191        ExitTransition::ShrinkOut { .. } => {
1192            read_anim_value(&format!("{key}:v{version}:exit:expand_in"), 1.0)
1193                .map(|v| v < 0.005)
1194                .unwrap_or(false)
1195        }
1196        ExitTransition::Composite(ts) => ts.iter().all(|t| exit_animation_done(key, version, t)),
1197    }
1198}
1199
1200#[derive(Clone)]
1201pub struct AnimatedVisibilityConfig {
1202    pub key: String,
1203    pub spec: AnimationSpec,
1204    pub enter: EnterTransition,
1205    pub exit: ExitTransition,
1206}
1207
1208impl Default for AnimatedVisibilityConfig {
1209    fn default() -> Self {
1210        Self {
1211            key: "anim_vis".into(),
1212            spec: AnimationSpec::default(),
1213            enter: EnterTransition::default(),
1214            exit: ExitTransition::default(),
1215        }
1216    }
1217}
1218
1219impl AnimatedVisibilityConfig {
1220    pub fn with_key(key: impl Into<String>) -> Self {
1221        Self {
1222            key: key.into(),
1223            ..Default::default()
1224        }
1225    }
1226}
1227
1228/// Shows or hides content with animated enter/exit transitions.
1229///
1230/// When `visible` becomes `true`, the content enters using the specified `enter`
1231/// transition. When it becomes `false`, the content exits using the specified `exit`
1232/// transition.
1233pub fn AnimatedVisibility(visible: bool, content: View, config: AnimatedVisibilityConfig) -> View {
1234    let key = config.key;
1235    let spec = config.spec;
1236    let enter = config.enter;
1237    let exit = config.exit;
1238
1239    let old_content = remember_with_key(format!("av_old:{key}"), || RefCell::new(None::<View>));
1240    let version = remember_with_key(format!("av_ver:{key}"), || RefCell::new(0u64));
1241    let prev = remember_with_key(format!("av_prev:{key}"), || RefCell::new(visible));
1242
1243    // Detect transition
1244    if *prev.borrow() != visible {
1245        if !visible {
1246            // Going hidden: capture current content for exit animation
1247            let mut captured = content.clone();
1248            captured.scope_key = None;
1249            captured.modifier.repaint_boundary = true;
1250            old_content.borrow_mut().replace(captured);
1251        } else {
1252            // Re-opening: drop any pending exit snapshot so enter starts clean.
1253            *old_content.borrow_mut() = None;
1254        }
1255        *version.borrow_mut() += 1;
1256        prev.borrow_mut().clone_from(&visible);
1257    }
1258
1259    let v = *version.borrow();
1260
1261    // Handle exiting old content
1262    let exiting = {
1263        let mut oc = old_content.borrow_mut();
1264        if let Some(ref old) = *oc {
1265            if exit_animation_done(&key, v, &exit) {
1266                *oc = None;
1267                None
1268            } else {
1269                let mut exit_view = apply_exit_inflow(&key, v, &exit, &spec, old.clone());
1270                exit_view.modifier.key = Some(transition_child_key(&key, v, "av_exit"));
1271                Some(exit_view)
1272            }
1273        } else {
1274            None
1275        }
1276    };
1277
1278    if visible {
1279        let mut content = content;
1280        content.scope_key = None;
1281        content.modifier.repaint_boundary = true;
1282        let mut entering = if v > 0 {
1283            apply_enter_inflow(&key, v, &enter, &spec, content)
1284        } else {
1285            content
1286        };
1287        entering.modifier.key = Some(transition_child_key(&key, v, "av_enter"));
1288        entering
1289    } else {
1290        exiting.unwrap_or_else(|| Box(Modifier::new().height(Dp::ZERO)))
1291    }
1292}