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().fill_max_size().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
509        }
510        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
511            &format!("{key}:meas"),
512            &format!("{key}:v{version}:enter:expand_v"),
513            SizeAxis::Vertical,
514            0.0,
515            1.0,
516            *clip,
517            *expand_from,
518            *spec,
519            view,
520        ),
521        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
522            &format!("{key}:meas"),
523            &format!("{key}:v{version}:enter:expand_h"),
524            SizeAxis::Horizontal,
525            0.0,
526            1.0,
527            *clip,
528            *expand_from,
529            *spec,
530            view,
531        ),
532        EnterTransition::ExpandIn { clip } => apply_size_fraction(
533            &format!("{key}:meas"),
534            &format!("{key}:v{version}:enter:expand_in"),
535            SizeAxis::Both,
536            0.0,
537            1.0,
538            *clip,
539            0.0,
540            *spec,
541            view,
542        ),
543        EnterTransition::Composite(transitions) => {
544            let mut v = view;
545            for t in transitions {
546                v = apply_enter_single(key, version, t, spec, v);
547            }
548            v
549        }
550    }
551}
552
553fn apply_enter_single(
554    key: &str,
555    version: u64,
556    enter: &EnterTransition,
557    spec: &AnimationSpec,
558    view: View,
559) -> View {
560    match enter {
561        EnterTransition::FadeIn => {
562            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
563            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
564        }
565        EnterTransition::SlideIn { offset_x, offset_y } => {
566            let offset = animate_vec2_from(
567                format!("{key}:v{version}:enter:slide"),
568                Vec2 {
569                    x: dp_to_px(*offset_x),
570                    y: dp_to_px(*offset_y),
571                },
572                Vec2::default(),
573                *spec,
574            );
575            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
576        }
577        EnterTransition::ScaleIn { initial } => {
578            let s = animate_f32_from(
579                format!("{key}:v{version}:enter:scale"),
580                *initial,
581                1.0,
582                *spec,
583            );
584            Box(Modifier::new().fill_max_size().transform_origin(0.5, 0.5).scale(s)).child(view)
585        }
586        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
587            &format!("{key}:meas"),
588            &format!("{key}:v{version}:enter:expand_v"),
589            SizeAxis::Vertical,
590            0.0,
591            1.0,
592            *clip,
593            *expand_from,
594            *spec,
595            view,
596        ),
597        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
598            &format!("{key}:meas"),
599            &format!("{key}:v{version}:enter:expand_h"),
600            SizeAxis::Horizontal,
601            0.0,
602            1.0,
603            *clip,
604            *expand_from,
605            *spec,
606            view,
607        ),
608        EnterTransition::ExpandIn { clip } => apply_size_fraction(
609            &format!("{key}:meas"),
610            &format!("{key}:v{version}:enter:expand_in"),
611            SizeAxis::Both,
612            0.0,
613            1.0,
614            *clip,
615            0.0,
616            *spec,
617            view,
618        ),
619        EnterTransition::Composite(inner) => {
620            let mut v = view;
621            for t in inner {
622                v = apply_enter_single(key, version, t, spec, v);
623            }
624            v
625        }
626    }
627}
628
629fn apply_exit(
630    key: &str,
631    version: u64,
632    exit: &ExitTransition,
633    spec: &AnimationSpec,
634    view: View,
635) -> View {
636    match exit {
637        ExitTransition::FadeOut => {
638            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
639            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
640        }
641        ExitTransition::SlideOut { offset_x, offset_y } => {
642            let offset = animate_vec2_from(
643                format!("{key}:v{version}:exit:slide"),
644                Vec2::default(),
645                Vec2 {
646                    x: dp_to_px(*offset_x),
647                    y: dp_to_px(*offset_y),
648                },
649                *spec,
650            );
651            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
652        }
653        ExitTransition::ScaleOut { target } => {
654            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
655            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
656            Box(Modifier::new().fill_max_size().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
657        }
658        ExitTransition::ShrinkVertically { clip, shrink_towards } => apply_size_fraction(
659            &format!("{key}:meas"),
660            &format!("{key}:v{version}:exit:expand_v"),
661            SizeAxis::Vertical,
662            1.0,
663            0.0,
664            *clip,
665            *shrink_towards,
666            *spec,
667            view,
668        ),
669        ExitTransition::ShrinkHorizontally { clip, shrink_towards } => apply_size_fraction(
670            &format!("{key}:meas"),
671            &format!("{key}:v{version}:exit:expand_h"),
672            SizeAxis::Horizontal,
673            1.0,
674            0.0,
675            *clip,
676            *shrink_towards,
677            *spec,
678            view,
679        ),
680        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
681            &format!("{key}:meas"),
682            &format!("{key}:v{version}:exit:expand_in"),
683            SizeAxis::Both,
684            1.0,
685            0.0,
686            *clip,
687            0.0,
688            *spec,
689            view,
690        ),
691        ExitTransition::Composite(transitions) => {
692            let mut v = view;
693            for t in transitions {
694                v = apply_exit_single(key, version, t, spec, v);
695            }
696            v
697        }
698    }
699}
700
701fn apply_exit_single(
702    key: &str,
703    version: u64,
704    exit: &ExitTransition,
705    spec: &AnimationSpec,
706    view: View,
707) -> View {
708    match exit {
709        ExitTransition::FadeOut => {
710            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
711            Box(Modifier::new().fill_max_size().alpha(val)).child(view)
712        }
713        ExitTransition::SlideOut { offset_x, offset_y } => {
714            let offset = animate_vec2_from(
715                format!("{key}:v{version}:exit:slide"),
716                Vec2::default(),
717                Vec2 {
718                    x: dp_to_px(*offset_x),
719                    y: dp_to_px(*offset_y),
720                },
721                *spec,
722            );
723            Box(Modifier::new().fill_max_size().translate_vec2(offset)).child(view)
724        }
725        ExitTransition::ScaleOut { target } => {
726            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
727            Box(Modifier::new().fill_max_size().transform_origin(0.5, 0.5).scale(s)).child(view)
728        }
729        ExitTransition::ShrinkVertically { clip, shrink_towards } => apply_size_fraction(
730            &format!("{key}:meas"),
731            &format!("{key}:v{version}:exit:expand_v"),
732            SizeAxis::Vertical,
733            1.0,
734            0.0,
735            *clip,
736            *shrink_towards,
737            *spec,
738            view,
739        ),
740        ExitTransition::ShrinkHorizontally { clip, shrink_towards } => apply_size_fraction(
741            &format!("{key}:meas"),
742            &format!("{key}:v{version}:exit:expand_h"),
743            SizeAxis::Horizontal,
744            1.0,
745            0.0,
746            *clip,
747            *shrink_towards,
748            *spec,
749            view,
750        ),
751        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
752            &format!("{key}:meas"),
753            &format!("{key}:v{version}:exit:expand_in"),
754            SizeAxis::Both,
755            1.0,
756            0.0,
757            *clip,
758            0.0,
759            *spec,
760            view,
761        ),
762        ExitTransition::Composite(inner) => {
763            let mut v = view;
764            for t in inner {
765                v = apply_exit_single(key, version, t, spec, v);
766            }
767            v
768        }
769    }
770}
771
772/// In-flow variant of `apply_enter`: fade/slide/scale wrappers use
773/// `fill_max_width` instead of `fill_max_size` so the entering view participates
774/// in its parent `Column`/`Row` instead of trying to fill it.
775fn apply_enter_inflow(
776    key: &str,
777    version: u64,
778    enter: &EnterTransition,
779    spec: &AnimationSpec,
780    view: View,
781) -> View {
782    match enter {
783        EnterTransition::FadeIn => {
784            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
785            Box(flow_mod().alpha(val)).child(view)
786        }
787        EnterTransition::SlideIn { offset_x, offset_y } => {
788            let offset = animate_vec2_from(
789                format!("{key}:v{version}:enter:slide"),
790                Vec2 {
791                    x: dp_to_px(*offset_x),
792                    y: dp_to_px(*offset_y),
793                },
794                Vec2::default(),
795                *spec,
796            );
797            Box(flow_mod().translate_vec2(offset)).child(view)
798        }
799        EnterTransition::ScaleIn { initial } => {
800            let s = animate_f32_from(
801                format!("{key}:v{version}:enter:scale"),
802                *initial,
803                1.0,
804                *spec,
805            );
806            let a = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
807            Box(flow_mod().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
808        }
809        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
810            &format!("{key}:meas"),
811            &format!("{key}:v{version}:enter:expand_v"),
812            SizeAxis::Vertical,
813            0.0,
814            1.0,
815            *clip,
816            *expand_from,
817            *spec,
818            view,
819        ),
820        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
821            &format!("{key}:meas"),
822            &format!("{key}:v{version}:enter:expand_h"),
823            SizeAxis::Horizontal,
824            0.0,
825            1.0,
826            *clip,
827            *expand_from,
828            *spec,
829            view,
830        ),
831        EnterTransition::ExpandIn { clip } => apply_size_fraction(
832            &format!("{key}:meas"),
833            &format!("{key}:v{version}:enter:expand_in"),
834            SizeAxis::Both,
835            0.0,
836            1.0,
837            *clip,
838            0.0,
839            *spec,
840            view,
841        ),
842        EnterTransition::Composite(transitions) => {
843            let mut v = view;
844            for t in transitions {
845                v = apply_enter_inflow_single(key, version, t, spec, v);
846            }
847            v
848        }
849    }
850}
851
852fn apply_enter_inflow_single(
853    key: &str,
854    version: u64,
855    enter: &EnterTransition,
856    spec: &AnimationSpec,
857    view: View,
858) -> View {
859    match enter {
860        EnterTransition::FadeIn => {
861            let val = animate_f32_from(format!("{key}:v{version}:enter:fade"), 0.0, 1.0, *spec);
862            Box(flow_mod().alpha(val)).child(view)
863        }
864        EnterTransition::SlideIn { offset_x, offset_y } => {
865            let offset = animate_vec2_from(
866                format!("{key}:v{version}:enter:slide"),
867                Vec2 {
868                    x: dp_to_px(*offset_x),
869                    y: dp_to_px(*offset_y),
870                },
871                Vec2::default(),
872                *spec,
873            );
874            Box(flow_mod().translate_vec2(offset)).child(view)
875        }
876        EnterTransition::ScaleIn { initial } => {
877            let s = animate_f32_from(
878                format!("{key}:v{version}:enter:scale"),
879                *initial,
880                1.0,
881                *spec,
882            );
883            Box(flow_mod().transform_origin(0.5, 0.5).scale(s)).child(view)
884        }
885        EnterTransition::ExpandVertically { clip, expand_from } => apply_size_fraction(
886            &format!("{key}:meas"),
887            &format!("{key}:v{version}:enter:expand_v"),
888            SizeAxis::Vertical,
889            0.0,
890            1.0,
891            *clip,
892            *expand_from,
893            *spec,
894            view,
895        ),
896        EnterTransition::ExpandHorizontally { clip, expand_from } => apply_size_fraction(
897            &format!("{key}:meas"),
898            &format!("{key}:v{version}:enter:expand_h"),
899            SizeAxis::Horizontal,
900            0.0,
901            1.0,
902            *clip,
903            *expand_from,
904            *spec,
905            view,
906        ),
907        EnterTransition::ExpandIn { clip } => apply_size_fraction(
908            &format!("{key}:meas"),
909            &format!("{key}:v{version}:enter:expand_in"),
910            SizeAxis::Both,
911            0.0,
912            1.0,
913            *clip,
914            0.0,
915            *spec,
916            view,
917        ),
918        EnterTransition::Composite(inner) => {
919            let mut v = view;
920            for t in inner {
921                v = apply_enter_inflow_single(key, version, t, spec, v);
922            }
923            v
924        }
925    }
926}
927
928fn apply_exit_inflow(
929    key: &str,
930    version: u64,
931    exit: &ExitTransition,
932    spec: &AnimationSpec,
933    view: View,
934) -> View {
935    match exit {
936        ExitTransition::FadeOut => {
937            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
938            Box(flow_mod().alpha(val)).child(view)
939        }
940        ExitTransition::SlideOut { offset_x, offset_y } => {
941            let offset = animate_vec2_from(
942                format!("{key}:v{version}:exit:slide"),
943                Vec2::default(),
944                Vec2 {
945                    x: dp_to_px(*offset_x),
946                    y: dp_to_px(*offset_y),
947                },
948                *spec,
949            );
950            Box(flow_mod().translate_vec2(offset)).child(view)
951        }
952        ExitTransition::ScaleOut { target } => {
953            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
954            let a = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
955            Box(flow_mod().transform_origin(0.5, 0.5).scale(s).alpha(a)).child(view)
956        }
957        ExitTransition::ShrinkVertically { clip, shrink_towards } => apply_size_fraction(
958            &format!("{key}:meas"),
959            &format!("{key}:v{version}:exit:expand_v"),
960            SizeAxis::Vertical,
961            1.0,
962            0.0,
963            *clip,
964            *shrink_towards,
965            *spec,
966            view,
967        ),
968        ExitTransition::ShrinkHorizontally { clip, shrink_towards } => apply_size_fraction(
969            &format!("{key}:meas"),
970            &format!("{key}:v{version}:exit:expand_h"),
971            SizeAxis::Horizontal,
972            1.0,
973            0.0,
974            *clip,
975            *shrink_towards,
976            *spec,
977            view,
978        ),
979        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
980            &format!("{key}:meas"),
981            &format!("{key}:v{version}:exit:expand_in"),
982            SizeAxis::Both,
983            1.0,
984            0.0,
985            *clip,
986            0.0,
987            *spec,
988            view,
989        ),
990        ExitTransition::Composite(transitions) => {
991            let mut v = view;
992            for t in transitions {
993                v = apply_exit_inflow_single(key, version, t, spec, v);
994            }
995            v
996        }
997    }
998}
999
1000fn apply_exit_inflow_single(
1001    key: &str,
1002    version: u64,
1003    exit: &ExitTransition,
1004    spec: &AnimationSpec,
1005    view: View,
1006) -> View {
1007    match exit {
1008        ExitTransition::FadeOut => {
1009            let val = animate_f32_from(format!("{key}:v{version}:exit:fade"), 1.0, 0.0, *spec);
1010            Box(flow_mod().alpha(val)).child(view)
1011        }
1012        ExitTransition::SlideOut { offset_x, offset_y } => {
1013            let offset = animate_vec2_from(
1014                format!("{key}:v{version}:exit:slide"),
1015                Vec2::default(),
1016                Vec2 {
1017                    x: dp_to_px(*offset_x),
1018                    y: dp_to_px(*offset_y),
1019                },
1020                *spec,
1021            );
1022            Box(flow_mod().translate_vec2(offset)).child(view)
1023        }
1024        ExitTransition::ScaleOut { target } => {
1025            let s = animate_f32_from(format!("{key}:v{version}:exit:scale"), 1.0, *target, *spec);
1026            Box(flow_mod().transform_origin(0.5, 0.5).scale(s)).child(view)
1027        }
1028        ExitTransition::ShrinkVertically { clip, shrink_towards } => apply_size_fraction(
1029            &format!("{key}:meas"),
1030            &format!("{key}:v{version}:exit:expand_v"),
1031            SizeAxis::Vertical,
1032            1.0,
1033            0.0,
1034            *clip,
1035            *shrink_towards,
1036            *spec,
1037            view,
1038        ),
1039        ExitTransition::ShrinkHorizontally { clip, shrink_towards } => apply_size_fraction(
1040            &format!("{key}:meas"),
1041            &format!("{key}:v{version}:exit:expand_h"),
1042            SizeAxis::Horizontal,
1043            1.0,
1044            0.0,
1045            *clip,
1046            *shrink_towards,
1047            *spec,
1048            view,
1049        ),
1050        ExitTransition::ShrinkOut { clip } => apply_size_fraction(
1051            &format!("{key}:meas"),
1052            &format!("{key}:v{version}:exit:expand_in"),
1053            SizeAxis::Both,
1054            1.0,
1055            0.0,
1056            *clip,
1057            0.0,
1058            *spec,
1059            view,
1060        ),
1061        ExitTransition::Composite(inner) => {
1062            let mut v = view;
1063            for t in inner {
1064                v = apply_exit_inflow_single(key, version, t, spec, v);
1065            }
1066            v
1067        }
1068    }
1069}
1070
1071/// Read the current value of an `animate_f32` animation without advancing it.
1072/// Returns `None` if the animation is still running or the entry doesn't exist.
1073fn read_anim_value(key: &str, default: f32) -> Option<f32> {
1074    let anim = remember_state_with_key::<AnimatedValue<f32>>(format!("anim:f32:{key}"), || {
1075        AnimatedValue::new(default, AnimationSpec::default())
1076    });
1077    let a = anim.borrow();
1078    if a.is_animating() {
1079        None
1080    } else {
1081        Some(*a.get())
1082    }
1083}
1084
1085fn read_anim_vec2_value(key: &str, default: Vec2) -> Option<Vec2> {
1086    let anim = remember_state_with_key::<AnimatedValue<Vec2>>(format!("anim:vec2:{key}"), || {
1087        AnimatedValue::new(default, AnimationSpec::default())
1088    });
1089    let a = anim.borrow();
1090    if a.is_animating() {
1091        None
1092    } else {
1093        Some(*a.get())
1094    }
1095}
1096
1097/// Check whether an exit animation has completed (read-only, no advancement).
1098fn exit_animation_done(key: &str, version: u64, exit: &ExitTransition) -> bool {
1099    match exit {
1100        ExitTransition::FadeOut => read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1101            .map(|v| v < 0.005)
1102            .unwrap_or(false),
1103        ExitTransition::SlideOut { offset_x, offset_y } => {
1104            let target = Vec2 {
1105                x: dp_to_px(*offset_x),
1106                y: dp_to_px(*offset_y),
1107            };
1108            read_anim_vec2_value(&format!("{key}:v{version}:exit:slide"), Vec2::default())
1109                .map(|v| (v.x - target.x).abs() < 0.5 && (v.y - target.y).abs() < 0.5)
1110                .unwrap_or(false)
1111        }
1112        ExitTransition::ScaleOut { target } => {
1113            let done_scale = read_anim_value(&format!("{key}:v{version}:exit:scale"), 1.0)
1114                .map(|v| (v - target).abs() < 0.005)
1115                .unwrap_or(false);
1116            let done_fade = read_anim_value(&format!("{key}:v{version}:exit:fade"), 1.0)
1117                .map(|v| v < 0.005)
1118                .unwrap_or(false);
1119            done_scale && done_fade
1120        }
1121        ExitTransition::ShrinkVertically { .. } => read_anim_value(
1122            &format!("{key}:v{version}:exit:expand_v"),
1123            1.0,
1124        )
1125        .map(|v| v < 0.005)
1126        .unwrap_or(false),
1127        ExitTransition::ShrinkHorizontally { .. } => read_anim_value(
1128            &format!("{key}:v{version}:exit:expand_h"),
1129            1.0,
1130        )
1131        .map(|v| v < 0.005)
1132        .unwrap_or(false),
1133        ExitTransition::ShrinkOut { .. } => read_anim_value(
1134            &format!("{key}:v{version}:exit:expand_in"),
1135            1.0,
1136        )
1137        .map(|v| v < 0.005)
1138        .unwrap_or(false),
1139        ExitTransition::Composite(ts) => ts.iter().all(|t| exit_animation_done(key, version, t)),
1140    }
1141}
1142
1143#[derive(Clone)]
1144pub struct AnimatedVisibilityConfig {
1145    pub key: String,
1146    pub spec: AnimationSpec,
1147    pub enter: EnterTransition,
1148    pub exit: ExitTransition,
1149}
1150
1151impl Default for AnimatedVisibilityConfig {
1152    fn default() -> Self {
1153        Self {
1154            key: "anim_vis".into(),
1155            spec: AnimationSpec::default(),
1156            enter: EnterTransition::default(),
1157            exit: ExitTransition::default(),
1158        }
1159    }
1160}
1161
1162impl AnimatedVisibilityConfig {
1163    pub fn with_key(key: impl Into<String>) -> Self {
1164        Self {
1165            key: key.into(),
1166            ..Default::default()
1167        }
1168    }
1169}
1170
1171/// Shows or hides content with animated enter/exit transitions.
1172///
1173/// When `visible` becomes `true`, the content enters using the specified `enter`
1174/// transition. When it becomes `false`, the content exits using the specified `exit`
1175/// transition.
1176pub fn AnimatedVisibility(visible: bool, content: View, config: AnimatedVisibilityConfig) -> View {
1177    let key = config.key;
1178    let spec = config.spec;
1179    let enter = config.enter;
1180    let exit = config.exit;
1181
1182    let old_content = remember_with_key(format!("av_old:{key}"), || RefCell::new(None::<View>));
1183    let version = remember_with_key(format!("av_ver:{key}"), || RefCell::new(0u64));
1184    let prev = remember_with_key(format!("av_prev:{key}"), || RefCell::new(visible));
1185
1186    // Detect transition
1187    if *prev.borrow() != visible {
1188        if !visible {
1189            // Going hidden: capture current content for exit animation
1190            let mut captured = content.clone();
1191            captured.scope_key = Some(format!("av_{key}_old"));
1192            captured.modifier.repaint_boundary = true;
1193            old_content.borrow_mut().replace(captured);
1194        } else {
1195            // Re-opening: drop any pending exit snapshot so enter starts clean.
1196            *old_content.borrow_mut() = None;
1197        }
1198        *version.borrow_mut() += 1;
1199        prev.borrow_mut().clone_from(&visible);
1200    }
1201
1202    let v = *version.borrow();
1203
1204    // Handle exiting old content
1205    let exiting = {
1206        let mut oc = old_content.borrow_mut();
1207        if let Some(ref old) = *oc {
1208            if exit_animation_done(&key, v, &exit) {
1209                *oc = None;
1210                None
1211            } else {
1212                let mut exit_view = apply_exit_inflow(&key, v, &exit, &spec, old.clone());
1213                exit_view.modifier.key = Some(transition_child_key(&key, v, "av_exit"));
1214                Some(exit_view)
1215            }
1216        } else {
1217            None
1218        }
1219    };
1220
1221    if visible {
1222        let mut content = content;
1223        content.scope_key = Some(format!("av_{key}_content"));
1224        content.modifier.repaint_boundary = true;
1225        let mut entering = if v > 0 {
1226            apply_enter_inflow(&key, v, &enter, &spec, content)
1227        } else {
1228            content
1229        };
1230        entering.modifier.key = Some(transition_child_key(&key, v, "av_enter"));
1231        entering
1232    } else {
1233        exiting.unwrap_or_else(|| Box(Modifier::new().height(0.0)))
1234    }
1235}