Skip to main content

animato_dioxus/
presence.rs

1//! Presence-style mount and hide animation helpers.
2
3use crate::AnimatedStyle;
4use animato_core::Easing;
5use animato_spring::SpringConfig;
6use dioxus::prelude::*;
7
8/// Style transition used by [`AnimatePresence`] and page/list helpers.
9#[derive(Clone, Debug)]
10pub struct PresenceAnimation {
11    /// Duration in seconds for tween-based presence transitions.
12    pub duration: f32,
13    /// Easing curve for tween-based presence transitions.
14    pub easing: Easing,
15    /// Starting style.
16    pub from: AnimatedStyle,
17    /// Ending style.
18    pub to: AnimatedStyle,
19    /// Optional spring config for spring-driven presence transitions.
20    pub spring: Option<SpringConfig>,
21}
22
23impl PartialEq for PresenceAnimation {
24    fn eq(&self, other: &Self) -> bool {
25        self.duration == other.duration
26            && self.easing == other.easing
27            && self.from == other.from
28            && self.to == other.to
29            && spring_eq(self.spring.as_ref(), other.spring.as_ref())
30    }
31}
32
33impl PresenceAnimation {
34    /// Fade from transparent to opaque.
35    pub fn fade() -> Self {
36        Self::new(
37            AnimatedStyle::new().opacity(0.0),
38            AnimatedStyle::new().opacity(1.0),
39        )
40    }
41
42    /// Slide up while fading in.
43    pub fn slide_up() -> Self {
44        Self::new(
45            AnimatedStyle::new().opacity(0.0).translate(0.0, 20.0),
46            AnimatedStyle::new().opacity(1.0).translate(0.0, 0.0),
47        )
48    }
49
50    /// Slide down while fading in.
51    pub fn slide_down() -> Self {
52        Self::new(
53            AnimatedStyle::new().opacity(0.0).translate(0.0, -20.0),
54            AnimatedStyle::new().opacity(1.0).translate(0.0, 0.0),
55        )
56    }
57
58    /// Slide from the left while fading in.
59    pub fn slide_left() -> Self {
60        Self::new(
61            AnimatedStyle::new().opacity(0.0).translate(-20.0, 0.0),
62            AnimatedStyle::new().opacity(1.0).translate(0.0, 0.0),
63        )
64    }
65
66    /// Slide from the right while fading in.
67    pub fn slide_right() -> Self {
68        Self::new(
69            AnimatedStyle::new().opacity(0.0).translate(20.0, 0.0),
70            AnimatedStyle::new().opacity(1.0).translate(0.0, 0.0),
71        )
72    }
73
74    /// Zoom in while fading in.
75    pub fn zoom_in() -> Self {
76        Self::new(
77            AnimatedStyle::new().opacity(0.0).scale(0.8),
78            AnimatedStyle::new().opacity(1.0).scale(1.0),
79        )
80    }
81
82    /// Zoom out while fading in.
83    pub fn zoom_out() -> Self {
84        Self::new(
85            AnimatedStyle::new().opacity(0.0).scale(1.2),
86            AnimatedStyle::new().opacity(1.0).scale(1.0),
87        )
88    }
89
90    /// Rotate on the x axis while fading in.
91    pub fn flip_x() -> Self {
92        Self::new(
93            AnimatedStyle::new()
94                .opacity(0.0)
95                .transform("rotateX(90deg)"),
96            AnimatedStyle::new().opacity(1.0).transform("rotateX(0deg)"),
97        )
98    }
99
100    /// Rotate on the y axis while fading in.
101    pub fn flip_y() -> Self {
102        Self::new(
103            AnimatedStyle::new()
104                .opacity(0.0)
105                .transform("rotateY(90deg)"),
106            AnimatedStyle::new().opacity(1.0).transform("rotateY(0deg)"),
107        )
108    }
109
110    /// Blur in while fading in.
111    pub fn blur_in() -> Self {
112        Self::new(
113            AnimatedStyle::new().opacity(0.0).blur(10.0),
114            AnimatedStyle::new().opacity(1.0).blur(0.0),
115        )
116    }
117
118    /// Spring presence transition.
119    pub fn spring(config: SpringConfig) -> Self {
120        let mut animation = Self::zoom_in();
121        animation.spring = Some(config);
122        animation
123    }
124
125    /// Build a presence animation from two styles.
126    pub fn new(from: AnimatedStyle, to: AnimatedStyle) -> Self {
127        Self {
128            duration: 0.25,
129            easing: Easing::EaseOutCubic,
130            from,
131            to,
132            spring: None,
133        }
134    }
135
136    /// Return a reversed version of the animation.
137    pub fn reversed(&self) -> Self {
138        Self {
139            duration: self.duration,
140            easing: self.easing.clone(),
141            from: self.to.clone(),
142            to: self.from.clone(),
143            spring: self.spring.clone(),
144        }
145    }
146}
147
148/// Mount/hide transition wrapper.
149#[component]
150pub fn AnimatePresence(
151    /// Show or hide the children.
152    show: Signal<bool>,
153    /// Enter animation.
154    enter: Option<PresenceAnimation>,
155    /// Exit animation.
156    exit: Option<PresenceAnimation>,
157    /// Keep the node mounted during exit.
158    wait_exit: Option<bool>,
159    /// Child view.
160    children: Element,
161) -> Element {
162    let enter = enter.unwrap_or_else(PresenceAnimation::fade);
163    let exit = exit.unwrap_or_else(|| enter.reversed());
164    let wait_exit = wait_exit.unwrap_or(true);
165    let transition = transition_css(enter.duration.max(exit.duration));
166    let enter_style = format!("{}{}", enter.to.to_css(), transition);
167    let exit_style = format!("{}{}", exit.to.to_css(), transition);
168    let exit_display = if wait_exit { "" } else { "display:none;" };
169    let style = if crate::read_signal(show) {
170        enter_style
171    } else {
172        format!("{exit_style}{exit_display}")
173    };
174
175    rsx! {
176        div {
177            style: "{style}",
178            {children}
179        }
180    }
181}
182
183pub(crate) fn transition_css(duration: f32) -> String {
184    let duration = duration.max(0.0);
185    format!(
186        "transition:opacity {duration:.3}s ease, transform {duration:.3}s ease, filter {duration:.3}s ease; will-change:opacity,transform,filter;"
187    )
188}
189
190fn spring_eq(a: Option<&SpringConfig>, b: Option<&SpringConfig>) -> bool {
191    match (a, b) {
192        (Some(a), Some(b)) => {
193            a.stiffness == b.stiffness
194                && a.damping == b.damping
195                && a.mass == b.mass
196                && a.epsilon == b.epsilon
197        }
198        (None, None) => true,
199        _ => false,
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn presets_have_expected_styles() {
209        let fade = PresenceAnimation::fade();
210        assert_eq!(fade.from.opacity, Some(0.0));
211        assert_eq!(fade.to.opacity, Some(1.0));
212
213        let slide = PresenceAnimation::slide_up();
214        assert_eq!(slide.from.translate_y, Some(20.0));
215        assert_eq!(slide.to.translate_y, Some(0.0));
216    }
217
218    #[test]
219    fn spring_presence_keeps_config_and_reverses() {
220        let config = SpringConfig::wobbly();
221        let spring = PresenceAnimation::spring(config.clone());
222        let stored = spring.spring.as_ref().expect("spring config");
223        assert_eq!(stored.stiffness, config.stiffness);
224        assert_eq!(spring.from.scale, Some(0.8));
225
226        let reversed = spring.reversed();
227        assert_eq!(reversed.from.scale, Some(1.0));
228        assert_eq!(reversed.to.scale, Some(0.8));
229    }
230
231    #[test]
232    fn all_presence_presets_are_well_formed() {
233        let presets = [
234            PresenceAnimation::fade(),
235            PresenceAnimation::slide_down(),
236            PresenceAnimation::slide_left(),
237            PresenceAnimation::slide_right(),
238            PresenceAnimation::zoom_in(),
239            PresenceAnimation::zoom_out(),
240            PresenceAnimation::flip_x(),
241            PresenceAnimation::flip_y(),
242            PresenceAnimation::blur_in(),
243        ];
244
245        for preset in presets {
246            assert!(preset.duration > 0.0);
247            assert_eq!(preset.easing, Easing::EaseOutCubic);
248            assert_eq!(preset.from.opacity, Some(0.0));
249            assert_eq!(preset.to.opacity, Some(1.0));
250            assert!(preset.from.to_css().contains("opacity:0;"));
251            assert!(preset.to.to_css().contains("opacity:1;"));
252        }
253    }
254
255    #[test]
256    fn equality_and_transition_css_cover_edge_cases() {
257        assert_eq!(PresenceAnimation::fade(), PresenceAnimation::fade());
258        assert_ne!(
259            PresenceAnimation::spring(SpringConfig::snappy()),
260            PresenceAnimation::spring(SpringConfig::wobbly())
261        );
262        assert_ne!(PresenceAnimation::fade(), PresenceAnimation::slide_up());
263
264        let css = transition_css(-1.0);
265        assert!(css.contains("0.000s"));
266        assert!(css.contains("will-change:opacity,transform,filter;"));
267    }
268}