Skip to main content

animato_dioxus/
motion.rs

1//! Unified Dioxus motion hook.
2
3use animato_core::{Easing, Update};
4use animato_spring::{Decompose, SpringConfig, SpringN};
5use animato_tween::{KeyframeTrack, Tween};
6use dioxus::prelude::{Signal, use_signal};
7use std::fmt;
8use std::sync::{Arc, Mutex};
9
10/// Motion transition configuration.
11#[derive(Clone, Debug)]
12pub enum MotionConfig {
13    /// Tween to a target using duration, easing, and delay.
14    Tween {
15        /// Duration in seconds.
16        duration: f32,
17        /// Easing curve.
18        easing: Easing,
19        /// Start delay in seconds.
20        delay: f32,
21    },
22    /// Spring to a target using a spring configuration.
23    Spring(SpringConfig),
24}
25
26enum ActiveMotion<T: Decompose + Send + Sync + Clone + 'static> {
27    Idle,
28    Tween(Tween<T>),
29    Spring(SpringN<T>),
30    Keyframes(KeyframeTrack<T>),
31}
32
33/// All-in-one motion handle.
34#[derive(Clone)]
35pub struct MotionHandle<T: Decompose + Send + Sync + Clone + 'static> {
36    value: Signal<T>,
37    active: Arc<Mutex<ActiveMotion<T>>>,
38}
39
40impl<T: Decompose + Send + Sync + Clone + 'static> fmt::Debug for MotionHandle<T> {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.debug_struct("MotionHandle").finish_non_exhaustive()
43    }
44}
45
46impl<T: Decompose + Send + Sync + Clone + 'static> MotionHandle<T> {
47    /// Current reactive value signal.
48    pub fn signal(&self) -> Signal<T> {
49        self.value
50    }
51
52    /// Current value snapshot.
53    pub fn value(&self) -> T {
54        crate::read_signal(self.value)
55    }
56
57    /// Animate to a target with tween or spring configuration.
58    pub fn animate_to(&self, target: T, config: MotionConfig) {
59        match config {
60            MotionConfig::Tween {
61                duration,
62                easing,
63                delay,
64            } => {
65                let tween = Tween::new(self.value(), target)
66                    .duration(duration.max(0.0))
67                    .delay(delay.max(0.0))
68                    .easing(easing)
69                    .build();
70                crate::with_lock(&self.active, |active| *active = ActiveMotion::Tween(tween));
71            }
72            MotionConfig::Spring(config) => self.spring_to(target, config),
73        }
74    }
75
76    /// Spring to a target.
77    pub fn spring_to(&self, target: T, config: SpringConfig) {
78        let mut spring = SpringN::new(config, self.value());
79        spring.set_target(target);
80        crate::with_lock(&self.active, |active| {
81            *active = ActiveMotion::Spring(spring)
82        });
83    }
84
85    /// Play a keyframe track.
86    pub fn keyframes(&self, track: KeyframeTrack<T>) {
87        crate::with_lock(&self.active, |active| {
88            *active = ActiveMotion::Keyframes(track)
89        });
90    }
91
92    /// Stop the active animation without changing the current value.
93    pub fn stop(&self) {
94        crate::with_lock(&self.active, |active| *active = ActiveMotion::Idle);
95    }
96
97    /// Snap instantly to a value and stop animation.
98    pub fn snap_to(&self, value: T) {
99        crate::set_signal(self.value, value);
100        self.stop();
101    }
102
103    /// Returns `true` while an animation is active.
104    pub fn is_animating(&self) -> bool {
105        crate::with_lock(&self.active, |active| !matches!(active, ActiveMotion::Idle))
106    }
107
108    /// Deterministically advance the active animation by `dt` seconds.
109    pub fn tick(&self, dt: f32) -> bool {
110        crate::with_lock(&self.active, |active| match active {
111            ActiveMotion::Idle => false,
112            ActiveMotion::Tween(tween) => {
113                let running = tween.update(dt.max(0.0));
114                crate::set_signal(self.value, tween.value());
115                if !running {
116                    *active = ActiveMotion::Idle;
117                }
118                running
119            }
120            ActiveMotion::Spring(spring) => {
121                let running = spring.update(dt.max(0.0));
122                crate::set_signal(self.value, spring.position());
123                if !running {
124                    *active = ActiveMotion::Idle;
125                }
126                running
127            }
128            ActiveMotion::Keyframes(track) => {
129                let running = track.update(dt.max(0.0));
130                if let Some(value) = track.value() {
131                    crate::set_signal(self.value, value);
132                }
133                if !running {
134                    *active = ActiveMotion::Idle;
135                }
136                running
137            }
138        })
139    }
140}
141
142/// Create an all-in-one motion hook.
143pub fn use_motion<T>(initial: T) -> MotionHandle<T>
144where
145    T: Decompose + Send + Sync + Clone + 'static,
146{
147    let value = use_signal(move || initial);
148    let handle = MotionHandle {
149        value,
150        active: Arc::new(Mutex::new(ActiveMotion::Idle)),
151    };
152
153    let loop_handle = handle.clone();
154    crate::spawn_animation_loop(move |dt| {
155        loop_handle.tick(dt);
156        true
157    });
158
159    handle
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use approx::assert_relative_eq;
166    use dioxus::prelude::*;
167    use std::cell::RefCell;
168
169    thread_local! {
170        static MOTION_CAPTURE: RefCell<Option<MotionHandle<f32>>> = const { RefCell::new(None) };
171    }
172
173    #[allow(non_snake_case)]
174    fn MotionHookApp() -> Element {
175        let handle = use_motion(0.0_f32);
176        MOTION_CAPTURE.with(|slot| *slot.borrow_mut() = Some(handle));
177
178        rsx! { div {} }
179    }
180
181    fn mount_motion() -> (VirtualDom, MotionHandle<f32>) {
182        MOTION_CAPTURE.with(|slot| *slot.borrow_mut() = None);
183        let mut dom = VirtualDom::new(MotionHookApp);
184        dom.rebuild_in_place();
185        let handle = MOTION_CAPTURE.with(|slot| {
186            slot.borrow()
187                .as_ref()
188                .cloned()
189                .expect("motion hook captured")
190        });
191        (dom, handle)
192    }
193
194    #[test]
195    fn motion_tween_stop_and_snap_are_deterministic() {
196        let (_dom, handle) = mount_motion();
197
198        assert_relative_eq!(handle.value(), 0.0);
199        assert!(!handle.is_animating());
200        assert!(!handle.tick(0.1));
201
202        handle.animate_to(
203            10.0,
204            MotionConfig::Tween {
205                duration: 1.0,
206                easing: Easing::Linear,
207                delay: 0.0,
208            },
209        );
210        assert!(handle.is_animating());
211        assert!(handle.tick(0.25));
212        assert_relative_eq!(handle.value(), 2.5, epsilon = 0.001);
213        assert_relative_eq!(crate::read_signal(handle.signal()), 2.5, epsilon = 0.001);
214
215        handle.stop();
216        assert!(!handle.is_animating());
217        assert!(!handle.tick(0.25));
218        assert_relative_eq!(handle.value(), 2.5, epsilon = 0.001);
219
220        handle.snap_to(7.0);
221        assert_relative_eq!(handle.value(), 7.0, epsilon = 0.001);
222        assert!(!handle.is_animating());
223    }
224
225    #[test]
226    fn motion_tween_delay_spring_and_keyframes_update_value() {
227        let (_dom, handle) = mount_motion();
228
229        handle.animate_to(
230            10.0,
231            MotionConfig::Tween {
232                duration: 1.0,
233                easing: Easing::Linear,
234                delay: 0.25,
235            },
236        );
237        assert!(handle.tick(0.1));
238        assert_relative_eq!(handle.value(), 0.0, epsilon = 0.001);
239        assert!(handle.tick(0.15));
240        assert_relative_eq!(handle.value(), 0.0, epsilon = 0.001);
241        assert!(handle.tick(0.25));
242        assert_relative_eq!(handle.value(), 2.5, epsilon = 0.001);
243
244        handle.animate_to(1.0, MotionConfig::Spring(SpringConfig::snappy()));
245        assert!(handle.is_animating());
246        assert!(handle.tick(1.0 / 60.0));
247        assert!(handle.value() < 2.5);
248
249        handle.keyframes(
250            KeyframeTrack::new()
251                .push(0.0, 4.0_f32)
252                .push(0.5, 8.0)
253                .push(1.0, 12.0),
254        );
255        assert!(handle.tick(0.5));
256        assert_relative_eq!(handle.value(), 8.0, epsilon = 0.001);
257        assert!(!handle.tick(0.5));
258        assert_relative_eq!(handle.value(), 12.0, epsilon = 0.001);
259        assert!(!handle.is_animating());
260    }
261}