1use dioxus::prelude::{Signal, use_signal};
4use std::fmt;
5use std::sync::{Arc, Mutex};
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum ScrollAxis {
10 #[default]
12 Vertical,
13 Horizontal,
15 Both,
17}
18
19#[derive(Clone, Debug, PartialEq)]
21pub struct ScrollConfig {
22 pub axis: ScrollAxis,
24 pub offset_start: f32,
26 pub offset_end: f32,
28 pub smooth: bool,
30 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#[derive(Clone, Debug, PartialEq)]
48pub struct ScrollTriggerConfig {
49 pub threshold: f32,
51 pub once: bool,
53 pub start: String,
55 pub end: String,
57 pub scrub: bool,
59 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#[derive(Clone, Debug)]
78pub struct ScrollProgressCalculator {
79 config: ScrollConfig,
80 current: f32,
81}
82
83impl ScrollProgressCalculator {
84 pub fn new(config: ScrollConfig) -> Self {
86 Self {
87 config,
88 current: 0.0,
89 }
90 }
91
92 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 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#[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 pub fn active(&self) -> Signal<bool> {
154 self.active
155 }
156
157 pub fn progress(&self) -> Signal<f32> {
159 self.progress
160 }
161
162 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
181pub fn use_scroll_progress<T: 'static>(target: T, config: ScrollConfig) -> Signal<f32> {
187 let _ = (target, config);
188 use_signal(|| 0.0)
189}
190
191pub 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
207pub 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}