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: f32, offset_y: f32 },
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: f32, offset_y: f32) -> 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: f32, offset_y: f32 },
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: f32, offset_y: f32) -> 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 = Some(format!("cf_{key}_old_v{old_ver}"));
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 = Some(format!("cf_{key}_v{v}"));
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. Intentionally does NOT
283/// `fill_max_size()`: that would fight the parent `Column` (fill-max height on a
284/// flex child makes it grow to consume leftover space).
285fn flow_mod() -> Modifier {
286    Modifier::new().fill_max_width()
287}
288
289/// Hit tests must pass through so a closing overlay can never steal clicks
290/// from content that appears beneath it.
291fn exit_mod_fill() -> Modifier {
292    Modifier::new().fill_max_size().hit_passthrough()
293}
294
295/// In-flow variant for exiting content in a Column/Row.
296fn exit_mod_flow() -> Modifier {
297    Modifier::new().fill_max_width().hit_passthrough()
298}
299
300/// Which axis an expand/shrink transition animates.
301#[derive(Clone, Copy)]
302enum SizeAxis {
303    Vertical,
304    Horizontal,
305    Both,
306}
307
308/// Layout-true expand/shrink: the outer node reports an animated size (so
309/// siblings in the parent `Column` reflow smoothly) while the content keeps its
310/// natural measured size and is clipped to the animated bounds.
311///
312/// The natural size is measured via `on_size_changed` the first time the content
313/// is composed at full size; afterwards it is remembered (`measure_key`) and
314/// reused by both enter and exit animations.
315#[allow(clippy::too_many_arguments)]
316fn apply_size_fraction(
317    measure_key: &str,
318    anim_key: &str,
319    axis: SizeAxis,
320    from: f32,
321    to: f32,
322    clip: bool,
323    align: f32,
324    spec: AnimationSpec,
325    view: View,
326) -> View {
327    let full_w = remember_mutable_with_key(format!("{measure_key}:w"), || 0.0f32);
328    let full_h = remember_mutable_with_key(format!("{measure_key}:h"), || 0.0f32);
329
330    let have = match axis {
331        SizeAxis::Vertical => *full_h.get() > 0.5,
332        SizeAxis::Horizontal => *full_w.get() > 0.5,
333        SizeAxis::Both => *full_w.get() > 0.5 && *full_h.get() > 0.5,
334    };
335
336    let progress = if have {
337        animate_f32_from(anim_key, from, to, spec)
338    } else {
339        from
340    };
341
342    let capture = {
343        let fw = full_w.clone();
344        let fh = full_h.clone();
345        move |sz: Vec2| {
346            if sz.x > 0.5 {
347                fw.set_neq(sz.x);
348            }
349            if sz.y > 0.5 {
350                fh.set_neq(sz.y);
351            }
352        }
353    };
354
355    let settled = from < to && progress >= 0.999;
356
357    let full_w = *full_w.get();
358    let full_h = *full_h.get();
359
360    let shown_w = match axis {
361        SizeAxis::Vertical => full_w,
362        SizeAxis::Horizontal | SizeAxis::Both => full_w * progress,
363    };
364    let shown_h = match axis {
365        SizeAxis::Horizontal => full_h,
366        SizeAxis::Vertical | SizeAxis::Both => full_h * progress,
367    };
368
369    let mut outer = Modifier::new().fill_max_width().flex_shrink(0.0);
370    if !have || settled {
371        // Not measured yet (first composition): lay out at natural size so
372        // `on_size_changed` can capture the true size, staying clipped. The
373        // companion fade keeps this frame invisible. When settled (enter done),
374        // release the fixed size and keep refreshing the measure while open.
375        outer = outer.on_size_changed(capture);
376    } else {
377        match axis {
378            SizeAxis::Vertical => {
379                outer = outer.height(shown_h.max(0.0));
380            }
381            SizeAxis::Horizontal => {
382                outer = outer.width(shown_w.max(0.0));
383            }
384            SizeAxis::Both => {
385                outer = outer.size(shown_w.max(0.0), shown_h.max(0.0));
386            }
387        }
388    }
389    if clip {
390        outer = outer.overflow(repose_core::Overflow::Clip);
391    }
392
393    // Inner keeps its full natural size and must not shrink into the animating
394    // window; the outer bounds clip hides the overflow. This is what makes the
395    // content get *clipped* (Compose `expandVertically`) instead of squashing.
396    let align = align.clamp(0.0, 1.0);
397    let ox = match axis {
398        SizeAxis::Vertical => 0.0,
399        SizeAxis::Horizontal | SizeAxis::Both => (shown_w - full_w) * align,
400    };
401    let oy = match axis {
402        SizeAxis::Horizontal => 0.0,
403        SizeAxis::Vertical | SizeAxis::Both => (shown_h - full_h) * align,
404    };
405    let mut inner = Modifier::new().fill_max_width().flex_shrink(0.0);
406    if have && !settled {
407        match axis {
408            SizeAxis::Vertical => {
409                inner = inner.height(full_h);
410            }
411            SizeAxis::Horizontal => {
412                inner = inner.width(full_w);
413            }
414            SizeAxis::Both => {
415                inner = inner.size(full_w, full_h);
416            }
417        }
418    }
419    if ox.abs() > 0.01 || oy.abs() > 0.01 {
420        inner = inner.translate(dp_to_px(ox), dp_to_px(oy));
421    }
422
423    Box(outer).child(Box(inner).child(view))
424}
425
426/// Animates between different content based on the `target_state`, with
427/// configurable enter and exit transitions.
428///
429/// When the target state changes, the old content animates out using the
430/// `exit` transition while the new content animates in using the `enter`
431/// transition. During the transition both are stacked on top of each other.
432pub fn AnimatedContent<T, F>(target_state: T, content: F, config: AnimatedContentConfig) -> View
433where
434    T: PartialEq + Clone + 'static,
435    F: Fn(T) -> View + 'static,
436{
437    let key = config.key;
438    let spec = config.spec;
439    let enter = config.enter;
440    let exit = config.exit;
441
442    let prev = remember_with_key(format!("ac_prev:{key}"), || {
443        RefCell::new(target_state.clone())
444    });
445    let old_content =
446        remember_with_key(format!("ac_old_view:{key}"), || RefCell::new(None::<View>));
447    let version = remember_with_key(format!("ac_version:{key}"), || RefCell::new(0u64));
448
449    let is_new = *prev.borrow() != target_state;
450    if is_new {
451        let old_ver = *version.borrow();
452        let mut prev_view = content(prev.borrow().clone());
453        prev_view.scope_key = Some(format!("ac_{key}_old_v{old_ver}"));
454        prev_view.modifier.repaint_boundary = true;
455        old_content.borrow_mut().replace(prev_view);
456        prev.borrow_mut().clone_from(&target_state);
457        *version.borrow_mut() += 1;
458    }
459
460    let v = *version.borrow();
461    let mut new_view = content(target_state.clone());
462    new_view.scope_key = Some(format!("ac_{key}_v{v}"));
463    new_view.modifier.repaint_boundary = true;
464    let mut new_view = apply_enter(&key, v, &enter, &spec, new_view);
465    new_view.modifier.key = Some(transition_child_key(&key, v, "ac_enter"));
466
467    let old_view = {
468        let mut oc = old_content.borrow_mut();
469        if let Some(ref ov) = *oc {
470            // Check if exit is already done (read-only, no side effects).
471            if exit_animation_done(&key, v, &exit) {
472                *oc = None;
473                None
474            } else {
475                let mut exit_view = apply_exit(&key, v, &exit, &spec, ov.clone());
476                exit_view.modifier.key = Some(transition_child_key(&key, v, "ac_exit"));
477                Some(exit_view)
478            }
479        } else {
480            None
481        }
482    };
483
484    match old_view {
485        Some(ov) => ZStack(Modifier::new().fill_max_size()).child((ov, new_view)),
486        None => new_view,
487    }
488}
489
490fn apply_enter(
491    key: &str,
492    version: u64,
493    enter: &EnterTransition,
494    spec: &AnimationSpec,
495    view: View,
496) -> View {
497    match enter {
498        EnterTransition::FadeIn => {
499            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
500            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
501        }
502        EnterTransition::SlideIn { offset_x, offset_y } => {
503            let offset = animate_vec2_from(
504                format!("{key}:v{version}:enter:slide"),
505                Vec2 {
506                    x: dp_to_px(*offset_x),
507                    y: dp_to_px(*offset_y),
508                },
509                Vec2::default(),
510                *spec,
511            );
512            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
513        }
514        EnterTransition::ScaleIn { initial } => {
515            let s = animate_f32_from(
516                format!("{key}:v{version}:enter:scale"),
517                *initial,
518                1.0,
519                *spec,
520            );
521            let a = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
522            Box(Modifier::new()
523                .fill_max_size()
524                .transform_origin(0.5, 0.5)
525                .scale(s)
526                .alpha(a))
527            .child(view)
528        }
529        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
530            &format!("{key}:meas"),
531            &format!("{key}:v{version}:enter:expand_v"),
532            SizeAxis::Vertical,
533            0.0,
534            1.0,
535            *clip,
536            *expand_from,
537            *spec,
538            view,
539        ),
540        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
541            &format!("{key}:meas"),
542            &format!("{key}:v{version}:enter:expand_h"),
543            SizeAxis::Horizontal,
544            0.0,
545            1.0,
546            *clip,
547            *expand_from,
548            *spec,
549            view,
550        ),
551        EnterTransition::ExpandIn { clip } => apply_size_fraction(
552            &format!("{key}:meas"),
553            &format!("{key}:v{version}:enter:expand_in"),
554            SizeAxis::Both,
555            0.0,
556            1.0,
557            *clip,
558            0.0,
559            *spec,
560            view,
561        ),
562        EnterTransition::Composite(transitions) => {
563            let mut v = view;
564            for t in transitions {
565                v = apply_enter_single(key, version, t, spec, v);
566            }
567            v
568        }
569    }
570}
571
572fn apply_enter_single(
573    key: &str,
574    version: u64,
575    enter: &EnterTransition,
576    spec: &AnimationSpec,
577    view: View,
578) -> View {
579    match enter {
580        EnterTransition::FadeIn => {
581            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
582            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
583        }
584        EnterTransition::SlideIn { offset_x, offset_y } => {
585            let offset = animate_vec2_from(
586                format!("{key}:v{version}:enter:slide"),
587                Vec2 {
588                    x: dp_to_px(*offset_x),
589                    y: dp_to_px(*offset_y),
590                },
591                Vec2::default(),
592                *spec,
593            );
594            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
595        }
596        EnterTransition::ScaleIn { initial } => {
597            let s = animate_f32_from(
598                format!("{key}:v{version}:enter:scale"),
599                *initial,
600                1.0,
601                *spec,
602            );
603            Box(Modifier::new()
604                .fill_max_size()
605                .transform_origin(0.5, 0.5)
606                .scale(s))
607            .child(view)
608        }
609        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
610            &format!("{key}:meas"),
611            &format!("{key}:v{version}:enter:expand_v"),
612            SizeAxis::Vertical,
613            0.0,
614            1.0,
615            *clip,
616            *expand_from,
617            *spec,
618            view,
619        ),
620        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
621            &format!("{key}:meas"),
622            &format!("{key}:v{version}:enter:expand_h"),
623            SizeAxis::Horizontal,
624            0.0,
625            1.0,
626            *clip,
627            *expand_from,
628            *spec,
629            view,
630        ),
631        EnterTransition::ExpandIn { clip } => apply_size_fraction(
632            &format!("{key}:meas"),
633            &format!("{key}:v{version}:enter:expand_in"),
634            SizeAxis::Both,
635            0.0,
636            1.0,
637            *clip,
638            0.0,
639            *spec,
640            view,
641        ),
642        EnterTransition::Composite(inner) => {
643            let mut v = view;
644            for t in inner {
645                v = apply_enter_single(key, version, t, spec, v);
646            }
647            v
648        }
649    }
650}
651
652fn apply_exit(
653    key: &str,
654    version: u64,
655    exit: &ExitTransition,
656    spec: &AnimationSpec,
657    view: View,
658) -> View {
659    match exit {
660        ExitTransition::FadeOut => {
661            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
662            Box(exit_mod_fill().alpha(val)).child(view)
663        }
664        ExitTransition::SlideOut { offset_x, offset_y } => {
665            let offset = animate_vec2_from(
666                format!("{key}:v{version}:exit:slide"),
667                Vec2::default(),
668                Vec2 {
669                    x: dp_to_px(*offset_x),
670                    y: dp_to_px(*offset_y),
671                },
672                *spec,
673            );
674            Box(exit_mod_fill().translate_vec2(offset)).child(view)
675        }
676        ExitTransition::ScaleOut { target } => {
677            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
678            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
679            Box(exit_mod_fill().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
680        }
681        ExitTransition::ShrinkVertically {
682            clip,
683            shrink_towards,
684        } => apply_size_fraction(
685            &format!("{key}:meas"),
686            &format!("{key}:v{version}:exit:expand_v"),
687            SizeAxis::Vertical,
688            1.0,
689            0.0,
690            *clip,
691            *shrink_towards,
692            *spec,
693            view,
694        ),
695        ExitTransition::ShrinkHorizontally {
696            clip,
697            shrink_towards,
698        } => apply_size_fraction(
699            &format!("{key}:meas"),
700            &format!("{key}:v{version}:exit:expand_h"),
701            SizeAxis::Horizontal,
702            1.0,
703            0.0,
704            *clip,
705            *shrink_towards,
706            *spec,
707            view,
708        ),
709        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
710            &format!("{key}:meas"),
711            &format!("{key}:v{version}:exit:expand_in"),
712            SizeAxis::Both,
713            1.0,
714            0.0,
715            *clip,
716            0.0,
717            *spec,
718            view,
719        ),
720        ExitTransition::Composite(transitions) => {
721            let mut v = view;
722            for t in transitions {
723                v = apply_exit_single(key, version, t, spec, v);
724            }
725            v
726        }
727    }
728}
729
730fn apply_exit_single(
731    key: &str,
732    version: u64,
733    exit: &ExitTransition,
734    spec: &AnimationSpec,
735    view: View,
736) -> View {
737    match exit {
738        ExitTransition::FadeOut => {
739            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
740            Box(exit_mod_fill().alpha(val)).child(view)
741        }
742        ExitTransition::SlideOut { offset_x, offset_y } => {
743            let offset = animate_vec2_from(
744                format!("{key}:v{version}:exit:slide"),
745                Vec2::default(),
746                Vec2 {
747                    x: dp_to_px(*offset_x),
748                    y: dp_to_px(*offset_y),
749                },
750                *spec,
751            );
752            Box(exit_mod_fill().translate_vec2(offset)).child(view)
753        }
754        ExitTransition::ScaleOut { target } => {
755            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
756            Box(exit_mod_fill().transform_origin(0.5, 0.5).scale(s)).child(view)
757        }
758        ExitTransition::ShrinkVertically {
759            clip,
760            shrink_towards,
761        } => apply_size_fraction(
762            &format!("{key}:meas"),
763            &format!("{key}:v{version}:exit:expand_v"),
764            SizeAxis::Vertical,
765            1.0,
766            0.0,
767            *clip,
768            *shrink_towards,
769            *spec,
770            view,
771        ),
772        ExitTransition::ShrinkHorizontally {
773            clip,
774            shrink_towards,
775        } => apply_size_fraction(
776            &format!("{key}:meas"),
777            &format!("{key}:v{version}:exit:expand_h"),
778            SizeAxis::Horizontal,
779            1.0,
780            0.0,
781            *clip,
782            *shrink_towards,
783            *spec,
784            view,
785        ),
786        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
787            &format!("{key}:meas"),
788            &format!("{key}:v{version}:exit:expand_in"),
789            SizeAxis::Both,
790            1.0,
791            0.0,
792            *clip,
793            0.0,
794            *spec,
795            view,
796        ),
797        ExitTransition::Composite(inner) => {
798            let mut v = view;
799            for t in inner {
800                v = apply_exit_single(key, version, t, spec, v);
801            }
802            v
803        }
804    }
805}
806
807/// In-flow variant of `apply_enter`: fade/slide/scale wrappers use
808/// `fill_max_width` instead of `fill_max_size` so the entering view participates
809/// in its parent `Column`/`Row` instead of trying to fill it.
810fn apply_enter_inflow(
811    key: &str,
812    version: u64,
813    enter: &EnterTransition,
814    spec: &AnimationSpec,
815    view: View,
816) -> View {
817    match enter {
818        EnterTransition::FadeIn => {
819            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
820            Box(flow_mod().alpha(val)).child(view)
821        }
822        EnterTransition::SlideIn { offset_x, offset_y } => {
823            let offset = animate_vec2_from(
824                format!("{key}:v{version}:enter:slide"),
825                Vec2 {
826                    x: dp_to_px(*offset_x),
827                    y: dp_to_px(*offset_y),
828                },
829                Vec2::default(),
830                *spec,
831            );
832            Box(flow_mod().translate_vec2(offset)).child(view)
833        }
834        EnterTransition::ScaleIn { initial } => {
835            let s = animate_f32_from(
836                format!("{key}:v{version}:enter:scale"),
837                *initial,
838                1.0,
839                *spec,
840            );
841            let a = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
842            Box(flow_mod().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
843        }
844        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
845            &format!("{key}:meas"),
846            &format!("{key}:v{version}:enter:expand_v"),
847            SizeAxis::Vertical,
848            0.0,
849            1.0,
850            *clip,
851            *expand_from,
852            *spec,
853            view,
854        ),
855        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
856            &format!("{key}:meas"),
857            &format!("{key}:v{version}:enter:expand_h"),
858            SizeAxis::Horizontal,
859            0.0,
860            1.0,
861            *clip,
862            *expand_from,
863            *spec,
864            view,
865        ),
866        EnterTransition::ExpandIn { clip } => apply_size_fraction(
867            &format!("{key}:meas"),
868            &format!("{key}:v{version}:enter:expand_in"),
869            SizeAxis::Both,
870            0.0,
871            1.0,
872            *clip,
873            0.0,
874            *spec,
875            view,
876        ),
877        EnterTransition::Composite(transitions) => {
878            let mut v = view;
879            for t in transitions {
880                v = apply_enter_inflow_single(key, version, t, spec, v);
881            }
882            v
883        }
884    }
885}
886
887fn apply_enter_inflow_single(
888    key: &str,
889    version: u64,
890    enter: &EnterTransition,
891    spec: &AnimationSpec,
892    view: View,
893) -> View {
894    match enter {
895        EnterTransition::FadeIn => {
896            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
897            Box(flow_mod().alpha(val)).child(view)
898        }
899        EnterTransition::SlideIn { offset_x, offset_y } => {
900            let offset = animate_vec2_from(
901                format!("{key}:v{version}:enter:slide"),
902                Vec2 {
903                    x: dp_to_px(*offset_x),
904                    y: dp_to_px(*offset_y),
905                },
906                Vec2::default(),
907                *spec,
908            );
909            Box(flow_mod().translate_vec2(offset)).child(view)
910        }
911        EnterTransition::ScaleIn { initial } => {
912            let s = animate_f32_from(
913                format!("{key}:v{version}:enter:scale"),
914                *initial,
915                1.0,
916                *spec,
917            );
918            Box(flow_mod().transform_origin(0.5, 0.5).scale(s)).child(view)
919        }
920        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
921            &format!("{key}:meas"),
922            &format!("{key}:v{version}:enter:expand_v"),
923            SizeAxis::Vertical,
924            0.0,
925            1.0,
926            *clip,
927            *expand_from,
928            *spec,
929            view,
930        ),
931        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
932            &format!("{key}:meas"),
933            &format!("{key}:v{version}:enter:expand_h"),
934            SizeAxis::Horizontal,
935            0.0,
936            1.0,
937            *clip,
938            *expand_from,
939            *spec,
940            view,
941        ),
942        EnterTransition::ExpandIn { clip } => apply_size_fraction(
943            &format!("{key}:meas"),
944            &format!("{key}:v{version}:enter:expand_in"),
945            SizeAxis::Both,
946            0.0,
947            1.0,
948            *clip,
949            0.0,
950            *spec,
951            view,
952        ),
953        EnterTransition::Composite(inner) => {
954            let mut v = view;
955            for t in inner {
956                v = apply_enter_inflow_single(key, version, t, spec, v);
957            }
958            v
959        }
960    }
961}
962
963fn apply_exit_inflow(
964    key: &str,
965    version: u64,
966    exit: &ExitTransition,
967    spec: &AnimationSpec,
968    view: View,
969) -> View {
970    match exit {
971        ExitTransition::FadeOut => {
972            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
973            Box(exit_mod_flow().alpha(val)).child(view)
974        }
975        ExitTransition::SlideOut { offset_x, offset_y } => {
976            let offset = animate_vec2_from(
977                format!("{key}:v{version}:exit:slide"),
978                Vec2::default(),
979                Vec2 {
980                    x: dp_to_px(*offset_x),
981                    y: dp_to_px(*offset_y),
982                },
983                *spec,
984            );
985            Box(exit_mod_flow().translate_vec2(offset)).child(view)
986        }
987        ExitTransition::ScaleOut { target } => {
988            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
989            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
990            Box(exit_mod_flow().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
991        }
992        ExitTransition::ShrinkVertically {
993            clip,
994            shrink_towards,
995        } => apply_size_fraction(
996            &format!("{key}:meas"),
997            &format!("{key}:v{version}:exit:expand_v"),
998            SizeAxis::Vertical,
999            1.0,
1000            0.0,
1001            *clip,
1002            *shrink_towards,
1003            *spec,
1004            view,
1005        ),
1006        ExitTransition::ShrinkHorizontally {
1007            clip,
1008            shrink_towards,
1009        } => apply_size_fraction(
1010            &format!("{key}:meas"),
1011            &format!("{key}:v{version}:exit:expand_h"),
1012            SizeAxis::Horizontal,
1013            1.0,
1014            0.0,
1015            *clip,
1016            *shrink_towards,
1017            *spec,
1018            view,
1019        ),
1020        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
1021            &format!("{key}:meas"),
1022            &format!("{key}:v{version}:exit:expand_in"),
1023            SizeAxis::Both,
1024            1.0,
1025            0.0,
1026            *clip,
1027            0.0,
1028            *spec,
1029            view,
1030        ),
1031        ExitTransition::Composite(transitions) => {
1032            let mut v = view;
1033            for t in transitions {
1034                v = apply_exit_inflow_single(key, version, t, spec, v);
1035            }
1036            v
1037        }
1038    }
1039}
1040
1041fn apply_exit_inflow_single(
1042    key: &str,
1043    version: u64,
1044    exit: &ExitTransition,
1045    spec: &AnimationSpec,
1046    view: View,
1047) -> View {
1048    match exit {
1049        ExitTransition::FadeOut => {
1050            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
1051            Box(exit_mod_flow().alpha(val)).child(view)
1052        }
1053        ExitTransition::SlideOut { offset_x, offset_y } => {
1054            let offset = animate_vec2_from(
1055                format!("{key}:v{version}:exit:slide"),
1056                Vec2::default(),
1057                Vec2 {
1058                    x: dp_to_px(*offset_x),
1059                    y: dp_to_px(*offset_y),
1060                },
1061                *spec,
1062            );
1063            Box(exit_mod_flow().translate_vec2(offset)).child(view)
1064        }
1065        ExitTransition::ScaleOut { target } => {
1066            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
1067            Box(exit_mod_flow().transform_origin(0.5, 0.5).scale(s)).child(view)
1068        }
1069        ExitTransition::ShrinkVertically {
1070            clip,
1071            shrink_towards,
1072        } => apply_size_fraction(
1073            &format!("{key}:meas"),
1074            &format!("{key}:v{version}:exit:expand_v"),
1075            SizeAxis::Vertical,
1076            1.0,
1077            0.0,
1078            *clip,
1079            *shrink_towards,
1080            *spec,
1081            view,
1082        ),
1083        ExitTransition::ShrinkHorizontally {
1084            clip,
1085            shrink_towards,
1086        } => apply_size_fraction(
1087            &format!("{key}:meas"),
1088            &format!("{key}:v{version}:exit:expand_h"),
1089            SizeAxis::Horizontal,
1090            1.0,
1091            0.0,
1092            *clip,
1093            *shrink_towards,
1094            *spec,
1095            view,
1096        ),
1097        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
1098            &format!("{key}:meas"),
1099            &format!("{key}:v{version}:exit:expand_in"),
1100            SizeAxis::Both,
1101            1.0,
1102            0.0,
1103            *clip,
1104            0.0,
1105            *spec,
1106            view,
1107        ),
1108        ExitTransition::Composite(inner) => {
1109            let mut v = view;
1110            for t in inner {
1111                v = apply_exit_inflow_single(key, version, t, spec, v);
1112            }
1113            v
1114        }
1115    }
1116}
1117
1118/// Read the current value of an `animate_f32` animation without advancing it.
1119/// Returns `None` if the animation is still running or the entry doesn't exist.
1120fn read_anim_value(key: &str, default: f32) -> Option<f32> {
1121    let anim = remember_state_with_key::<AnimatedValue<f32>>(format!("anim:f32:{key}"), || {
1122        AnimatedValue::new(default, AnimationSpec::default())
1123    });
1124    let a = anim.borrow();
1125    if a.is_animating() {
1126        None
1127    } else {
1128        Some(*a.get())
1129    }
1130}
1131
1132fn read_anim_vec2_value(key: &str, default: Vec2) -> Option<Vec2> {
1133    let anim = remember_state_with_key::<AnimatedValue<Vec2>>(format!("anim:vec2:{key}"), || {
1134        AnimatedValue::new(default, AnimationSpec::default())
1135    });
1136    let a = anim.borrow();
1137    if a.is_animating() {
1138        None
1139    } else {
1140        Some(*a.get())
1141    }
1142}
1143
1144/// Check whether an exit animation has completed (read-only, no advancement).
1145fn exit_animation_done(key: &str, version: u64, exit: &ExitTransition) -> bool {
1146    match exit {
1147        ExitTransition::FadeOut => read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1148            .map(|v| v < 0.005)
1149            .unwrap_or(false),
1150        ExitTransition::SlideOut { offset_x, offset_y } => {
1151            let target = Vec2 {
1152                x: dp_to_px(*offset_x),
1153                y: dp_to_px(*offset_y),
1154            };
1155            read_anim_vec2_value(&format!("{key}:v{version}:exit:slide"), Vec2::default())
1156                .map(|v| (v.x - target.x).abs() < 0.5 && (v.y - target.y).abs() < 0.5)
1157                .unwrap_or(false)
1158        }
1159        ExitTransition::ScaleOut { target } => {
1160            let done_scale = read_anim_value(&format!("{key}:v{version}:exit:scale"), 1.0)
1161                .map(|v| (v - target).abs() < 0.005)
1162                .unwrap_or(false);
1163            let done_fade = read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1164                .map(|v| v < 0.005)
1165                .unwrap_or(false);
1166            done_scale && done_fade
1167        }
1168        ExitTransition::ShrinkVertically { .. } => {
1169            read_anim_value(&format!("{key}:v{version}:exit:expand_v"), 1.0)
1170                .map(|v| v < 0.005)
1171                .unwrap_or(false)
1172        }
1173        ExitTransition::ShrinkHorizontally { .. } => {
1174            read_anim_value(&format!("{key}:v{version}:exit:expand_h"), 1.0)
1175                .map(|v| v < 0.005)
1176                .unwrap_or(false)
1177        }
1178        ExitTransition::ShrinkOut { .. } => {
1179            read_anim_value(&format!("{key}:v{version}:exit:expand_in"), 1.0)
1180                .map(|v| v < 0.005)
1181                .unwrap_or(false)
1182        }
1183        ExitTransition::Composite(ts) => ts.iter().all(|t| exit_animation_done(key, version, t)),
1184    }
1185}
1186
1187#[derive(Clone)]
1188pub struct AnimatedVisibilityConfig {
1189    pub key: String,
1190    pub spec: AnimationSpec,
1191    pub enter: EnterTransition,
1192    pub exit: ExitTransition,
1193}
1194
1195impl Default for AnimatedVisibilityConfig {
1196    fn default() -> Self {
1197        Self {
1198            key: "anim_vis".into(),
1199            spec: AnimationSpec::default(),
1200            enter: EnterTransition::default(),
1201            exit: ExitTransition::default(),
1202        }
1203    }
1204}
1205
1206impl AnimatedVisibilityConfig {
1207    pub fn with_key(key: impl Into<String>) -> Self {
1208        Self {
1209            key: key.into(),
1210            ..Default::default()
1211        }
1212    }
1213}
1214
1215/// Shows or hides content with animated enter/exit transitions.
1216///
1217/// When `visible` becomes `true`, the content enters using the specified `enter`
1218/// transition. When it becomes `false`, the content exits using the specified `exit`
1219/// transition.
1220pub fn AnimatedVisibility(visible: bool, content: View, config: AnimatedVisibilityConfig) -> View {
1221    let key = config.key;
1222    let spec = config.spec;
1223    let enter = config.enter;
1224    let exit = config.exit;
1225
1226    let old_content = remember_with_key(format!("av_old:{key}"), || RefCell::new(None::<View>));
1227    let version = remember_with_key(format!("av_ver:{key}"), || RefCell::new(0u64));
1228    let prev = remember_with_key(format!("av_prev:{key}"), || RefCell::new(visible));
1229
1230    // Detect transition
1231    if *prev.borrow() != visible {
1232        if !visible {
1233            // Going hidden: capture current content for exit animation
1234            let mut captured = content.clone();
1235            captured.scope_key = Some(format!("av_{key}_old"));
1236            captured.modifier.repaint_boundary = true;
1237            old_content.borrow_mut().replace(captured);
1238        } else {
1239            // Re-opening: drop any pending exit snapshot so enter starts clean.
1240            *old_content.borrow_mut() = None;
1241        }
1242        *version.borrow_mut() += 1;
1243        prev.borrow_mut().clone_from(&visible);
1244    }
1245
1246    let v = *version.borrow();
1247
1248    // Handle exiting old content
1249    let exiting = {
1250        let mut oc = old_content.borrow_mut();
1251        if let Some(ref old) = *oc {
1252            if exit_animation_done(&key, v, &exit) {
1253                *oc = None;
1254                None
1255            } else {
1256                let mut exit_view = apply_exit_inflow(&key, v, &exit, &spec, old.clone());
1257                exit_view.modifier.key = Some(transition_child_key(&key, v, "av_exit"));
1258                Some(exit_view)
1259            }
1260        } else {
1261            None
1262        }
1263    };
1264
1265    if visible {
1266        let mut content = content;
1267        content.scope_key = Some(format!("av_{key}_content"));
1268        content.modifier.repaint_boundary = true;
1269        let mut entering = if v > 0 {
1270            apply_enter_inflow(&key, v, &enter, &spec, content)
1271        } else {
1272            content
1273        };
1274        entering.modifier.key = Some(transition_child_key(&key, v, "av_enter"));
1275        entering
1276    } else {
1277        exiting.unwrap_or_else(|| Box(Modifier::new().height(0.0)))
1278    }
1279}