Skip to main content

animato_dioxus/
scroll.rs

1//! Scroll-driven animation helpers.
2
3use dioxus::prelude::{Signal, use_signal};
4use std::fmt;
5use std::sync::{Arc, Mutex};
6
7/// Scroll axis used by scroll progress helpers.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum ScrollAxis {
10    /// Vertical scroll.
11    #[default]
12    Vertical,
13    /// Horizontal scroll.
14    Horizontal,
15    /// Track both axes by using the larger normalized progress.
16    Both,
17}
18
19/// Scroll progress configuration.
20#[derive(Clone, Debug, PartialEq)]
21pub struct ScrollConfig {
22    /// Axis to track.
23    pub axis: ScrollAxis,
24    /// Viewport offset where progress starts.
25    pub offset_start: f32,
26    /// Viewport offset where progress ends.
27    pub offset_end: f32,
28    /// Smooth progress by lerping toward the latest value.
29    pub smooth: bool,
30    /// Smoothing factor in `[0.0, 1.0]`.
31    pub smooth_factor: f32,
32}
33
34impl Default for ScrollConfig {
35    fn default() -> Self {
36        Self {
37            axis: ScrollAxis::Vertical,
38            offset_start: 0.0,
39            offset_end: 1.0,
40            smooth: true,
41            smooth_factor: 0.1,
42        }
43    }
44}
45
46/// Scroll trigger configuration.
47#[derive(Clone, Debug, PartialEq)]
48pub struct ScrollTriggerConfig {
49    /// Intersection threshold in `[0.0, 1.0]`.
50    pub threshold: f32,
51    /// Fire only once.
52    pub once: bool,
53    /// GSAP-style start expression, such as `"top bottom"`.
54    pub start: String,
55    /// GSAP-style end expression, such as `"bottom top"`.
56    pub end: String,
57    /// Link animation progress to scroll progress.
58    pub scrub: bool,
59    /// Pin the target for the active range.
60    pub pin: bool,
61}
62
63impl Default for ScrollTriggerConfig {
64    fn default() -> Self {
65        Self {
66            threshold: 0.0,
67            once: false,
68            start: "top bottom".to_owned(),
69            end: "bottom top".to_owned(),
70            scrub: false,
71            pin: false,
72        }
73    }
74}
75
76/// Pure scroll progress calculator used by hooks and tests.
77#[derive(Clone, Debug)]
78pub struct ScrollProgressCalculator {
79    config: ScrollConfig,
80    current: f32,
81}
82
83impl ScrollProgressCalculator {
84    /// Create a calculator with configuration.
85    pub fn new(config: ScrollConfig) -> Self {
86        Self {
87            config,
88            current: 0.0,
89        }
90    }
91
92    /// Calculate progress from element and viewport geometry.
93    pub fn calculate(
94        &mut self,
95        element_start: f32,
96        element_size: f32,
97        viewport_size: f32,
98        scroll_position: f32,
99    ) -> f32 {
100        let target = scroll_progress_target(
101            &self.config,
102            element_start,
103            element_size,
104            viewport_size,
105            scroll_position,
106        );
107        self.apply_smoothing(target)
108    }
109
110    /// Return whether an intersection ratio activates a trigger.
111    pub fn triggered(ratio: f32, config: &ScrollTriggerConfig) -> bool {
112        ratio >= config.threshold.clamp(0.0, 1.0)
113    }
114
115    fn apply_smoothing(&mut self, target: f32) -> f32 {
116        let target = target.clamp(0.0, 1.0);
117        self.current =
118            if !self.config.smooth || target <= f32::EPSILON || target >= 1.0 - f32::EPSILON {
119                target
120            } else {
121                let factor = self.config.smooth_factor.clamp(0.0, 1.0);
122                let next = self.current + (target - self.current) * factor;
123                if (target - next).abs() <= 0.001 {
124                    target
125                } else {
126                    next
127                }
128            };
129
130        self.current
131    }
132}
133
134/// Scroll trigger handle.
135#[derive(Clone)]
136pub struct ScrollTriggerHandle {
137    active: Signal<bool>,
138    progress: Signal<f32>,
139    once: bool,
140    fired: Arc<Mutex<bool>>,
141}
142
143impl fmt::Debug for ScrollTriggerHandle {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.debug_struct("ScrollTriggerHandle")
146            .field("once", &self.once)
147            .finish_non_exhaustive()
148    }
149}
150
151impl ScrollTriggerHandle {
152    /// Active-state signal.
153    pub fn active(&self) -> Signal<bool> {
154        self.active
155    }
156
157    /// Progress signal.
158    pub fn progress(&self) -> Signal<f32> {
159        self.progress
160    }
161
162    /// Update active state from an intersection ratio.
163    pub fn update_ratio(&self, ratio: f32, config: &ScrollTriggerConfig) {
164        let mut fired = self
165            .fired
166            .lock()
167            .unwrap_or_else(|poisoned| poisoned.into_inner());
168        if self.once && *fired {
169            return;
170        }
171
172        let active = ScrollProgressCalculator::triggered(ratio, config);
173        if active {
174            *fired = true;
175        }
176        crate::set_signal(self.active, active);
177        crate::set_signal(self.progress, ratio.clamp(0.0, 1.0));
178    }
179}
180
181/// Return scroll progress for a target.
182///
183/// The `target` argument is intentionally generic so Dioxus callers can pass
184/// renderer-specific mounted-element handles without coupling this crate to a
185/// single renderer. Non-web targets return a stable no-op signal.
186pub fn use_scroll_progress<T: 'static>(target: T, config: ScrollConfig) -> Signal<f32> {
187    let _ = (target, config);
188    use_signal(|| 0.0)
189}
190
191/// Return a scroll trigger handle for a target.
192pub fn use_scroll_trigger<T: 'static>(
193    target: T,
194    config: ScrollTriggerConfig,
195) -> ScrollTriggerHandle {
196    let _ = target;
197    let active = use_signal(|| false);
198    let progress = use_signal(|| 0.0);
199    ScrollTriggerHandle {
200        active,
201        progress,
202        once: config.once,
203        fired: Arc::new(Mutex::new(false)),
204    }
205}
206
207/// Return the current scroll velocity in pixels per second.
208pub fn use_scroll_velocity() -> Signal<f32> {
209    use_signal(|| 0.0)
210}
211
212fn scroll_progress_target(
213    config: &ScrollConfig,
214    element_start: f32,
215    element_size: f32,
216    viewport_size: f32,
217    scroll_position: f32,
218) -> f32 {
219    let start_offset = viewport_size * config.offset_start;
220    let end_offset = viewport_size * config.offset_end;
221    let start = element_start - end_offset;
222    let end = element_start + element_size - start_offset;
223    let span = (end - start).abs().max(f32::EPSILON);
224    ((scroll_position - start) / span).clamp(0.0, 1.0)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use dioxus::prelude::*;
231    use std::cell::RefCell;
232
233    thread_local! {
234        static SCROLL_PROGRESS_CAPTURE: RefCell<Option<Signal<f32>>> = const { RefCell::new(None) };
235        static SCROLL_TRIGGER_CAPTURE: RefCell<Option<ScrollTriggerHandle>> = const { RefCell::new(None) };
236        static SCROLL_VELOCITY_CAPTURE: RefCell<Option<Signal<f32>>> = const { RefCell::new(None) };
237    }
238
239    #[allow(non_snake_case)]
240    fn ScrollHookApp() -> Element {
241        let progress = use_scroll_progress(
242            "node",
243            ScrollConfig {
244                axis: ScrollAxis::Both,
245                offset_start: 0.2,
246                offset_end: 0.8,
247                smooth: false,
248                smooth_factor: 1.0,
249            },
250        );
251        let trigger = use_scroll_trigger(
252            "node",
253            ScrollTriggerConfig {
254                threshold: 0.5,
255                once: true,
256                start: "top center".to_owned(),
257                end: "bottom center".to_owned(),
258                scrub: true,
259                pin: true,
260            },
261        );
262        let velocity = use_scroll_velocity();
263
264        SCROLL_PROGRESS_CAPTURE.with(|slot| *slot.borrow_mut() = Some(progress));
265        SCROLL_TRIGGER_CAPTURE.with(|slot| *slot.borrow_mut() = Some(trigger));
266        SCROLL_VELOCITY_CAPTURE.with(|slot| *slot.borrow_mut() = Some(velocity));
267
268        rsx! { div {} }
269    }
270
271    #[test]
272    fn progress_calculator_clamps() {
273        let mut calc = ScrollProgressCalculator::new(ScrollConfig {
274            smooth: false,
275            ..ScrollConfig::default()
276        });
277
278        assert_eq!(calc.calculate(100.0, 100.0, 100.0, -100.0), 0.0);
279        assert_eq!(calc.calculate(100.0, 100.0, 100.0, 300.0), 1.0);
280    }
281
282    #[test]
283    fn smoothed_progress_snaps_to_edges() {
284        let mut calc = ScrollProgressCalculator::new(ScrollConfig {
285            smooth: true,
286            smooth_factor: 0.1,
287            ..ScrollConfig::default()
288        });
289
290        assert_eq!(calc.calculate(100.0, 100.0, 100.0, 50.0), 0.025);
291        assert_eq!(calc.calculate(100.0, 100.0, 100.0, 300.0), 1.0);
292        assert_eq!(calc.calculate(100.0, 100.0, 100.0, -100.0), 0.0);
293    }
294
295    #[test]
296    fn trigger_threshold_activates() {
297        let config = ScrollTriggerConfig {
298            threshold: 0.5,
299            ..ScrollTriggerConfig::default()
300        };
301        assert!(!ScrollProgressCalculator::triggered(0.49, &config));
302        assert!(ScrollProgressCalculator::triggered(0.5, &config));
303    }
304
305    #[test]
306    fn calculator_handles_offsets_smoothing_and_threshold_clamps() {
307        let mut instant = ScrollProgressCalculator::new(ScrollConfig {
308            offset_start: 0.25,
309            offset_end: 0.75,
310            smooth: false,
311            ..ScrollConfig::default()
312        });
313        assert_eq!(instant.calculate(200.0, 100.0, 100.0, 125.0), 0.0);
314        assert_eq!(instant.calculate(200.0, 100.0, 100.0, 275.0), 1.0);
315
316        let mut fast_smooth = ScrollProgressCalculator::new(ScrollConfig {
317            smooth: true,
318            smooth_factor: 2.0,
319            ..ScrollConfig::default()
320        });
321        assert_eq!(fast_smooth.calculate(100.0, 100.0, 100.0, 150.0), 0.75);
322
323        assert!(ScrollProgressCalculator::triggered(
324            0.0,
325            &ScrollTriggerConfig {
326                threshold: -1.0,
327                ..ScrollTriggerConfig::default()
328            }
329        ));
330        assert!(!ScrollProgressCalculator::triggered(
331            0.99,
332            &ScrollTriggerConfig {
333                threshold: 2.0,
334                ..ScrollTriggerConfig::default()
335            }
336        ));
337    }
338
339    #[test]
340    fn scroll_hooks_return_noop_signals_and_once_trigger_handle() {
341        SCROLL_PROGRESS_CAPTURE.with(|slot| *slot.borrow_mut() = None);
342        SCROLL_TRIGGER_CAPTURE.with(|slot| *slot.borrow_mut() = None);
343        SCROLL_VELOCITY_CAPTURE.with(|slot| *slot.borrow_mut() = None);
344        let mut dom = VirtualDom::new(ScrollHookApp);
345        dom.rebuild_in_place();
346
347        let progress = SCROLL_PROGRESS_CAPTURE.with(|slot| {
348            slot.borrow()
349                .as_ref()
350                .copied()
351                .expect("scroll progress captured")
352        });
353        let trigger = SCROLL_TRIGGER_CAPTURE.with(|slot| {
354            slot.borrow()
355                .as_ref()
356                .cloned()
357                .expect("scroll trigger captured")
358        });
359        let velocity = SCROLL_VELOCITY_CAPTURE.with(|slot| {
360            slot.borrow()
361                .as_ref()
362                .copied()
363                .expect("scroll velocity captured")
364        });
365
366        assert_eq!(crate::read_signal(progress), 0.0);
367        assert_eq!(crate::read_signal(velocity), 0.0);
368        assert!(!crate::read_signal(trigger.active()));
369        assert_eq!(crate::read_signal(trigger.progress()), 0.0);
370
371        let config = ScrollTriggerConfig {
372            threshold: 0.5,
373            once: true,
374            ..ScrollTriggerConfig::default()
375        };
376        trigger.update_ratio(0.4, &config);
377        assert!(!crate::read_signal(trigger.active()));
378        assert_eq!(crate::read_signal(trigger.progress()), 0.4);
379        trigger.update_ratio(0.75, &config);
380        assert!(crate::read_signal(trigger.active()));
381        assert_eq!(crate::read_signal(trigger.progress()), 0.75);
382        trigger.update_ratio(0.1, &config);
383        assert!(crate::read_signal(trigger.active()));
384        assert_eq!(crate::read_signal(trigger.progress()), 0.75);
385    }
386}