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