1#![doc = include_str!("../docs/api/autoscroll.md")]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use dioxus::html::geometry::PixelsVector2D;
7use dioxus::html::{MountedData, ScrollBehavior};
8use dioxus::prelude::*;
9
10use crate::core::hooks::use_rect_refresh_provider;
11use crate::core::{Point, Rect};
12
13static NEXT_SCROLL_CONTAINER: AtomicU64 = AtomicU64::new(1);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub(crate) struct ScrollContainerId(pub u64);
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19struct ScrollEntry {
20 id: ScrollContainerId,
21 rect: Rect,
22 available: bool,
23 blocked: bool,
24}
25
26pub(crate) struct ScrollCoordinator {
30 entries: Signal<Vec<ScrollEntry>>,
31}
32
33impl Copy for ScrollCoordinator {}
34impl Clone for ScrollCoordinator {
35 fn clone(&self) -> Self {
36 *self
37 }
38}
39impl PartialEq for ScrollCoordinator {
40 fn eq(&self, other: &Self) -> bool {
41 self.entries == other.entries
42 }
43}
44
45impl ScrollCoordinator {
46 fn new() -> Self {
47 Self {
48 entries: Signal::new(Vec::new()),
49 }
50 }
51
52 fn update(&mut self, id: ScrollContainerId, rect: Rect, available: bool) {
53 let mut entries = self.entries.write();
54 if let Some(entry) = entries.iter_mut().find(|entry| entry.id == id) {
55 entry.rect = rect;
56 entry.available = available;
57 } else {
58 entries.push(ScrollEntry {
59 id,
60 rect,
61 available,
62 blocked: false,
63 });
64 }
65 }
66
67 fn set_blocked(&mut self, id: ScrollContainerId, blocked: bool) {
68 if let Some(entry) = self.entries.write().iter_mut().find(|entry| entry.id == id) {
69 entry.blocked = blocked;
70 }
71 }
72
73 fn owner(&self, point: Point) -> Option<ScrollContainerId> {
74 self.entries
75 .peek()
76 .iter()
77 .filter(|entry| entry.available && !entry.blocked && entry.rect.contains(point))
78 .min_by(|a, b| {
79 let aa = a.rect.width.max(0.0) * a.rect.height.max(0.0);
80 let ba = b.rect.width.max(0.0) * b.rect.height.max(0.0);
81 aa.total_cmp(&ba)
82 })
83 .map(|entry| entry.id)
84 }
85
86 fn unregister(&mut self, id: ScrollContainerId) {
87 if let Ok(mut entries) = self.entries.try_write() {
88 entries.retain(|entry| entry.id != id);
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum ScrollAxis {
96 #[default]
98 Y,
99 X,
101 Both,
103}
104
105pub fn edge_delta(
109 pos: Point,
110 rect: Rect,
111 threshold: f64,
112 speed: f64,
113 axis: ScrollAxis,
114) -> (f64, f64) {
115 if !rect.contains(pos) {
121 return (0.0, 0.0);
122 }
123 let threshold = threshold.max(1.0);
124 let speed = speed.max(0.0);
125 let ramp = |dist_into_band: f64| (dist_into_band / threshold).clamp(0.0, 1.0) * speed;
126 let edge = |lo: f64, hi: f64| -> f64 {
132 if lo <= hi {
133 if lo < threshold {
134 -ramp(threshold - lo)
135 } else {
136 0.0
137 }
138 } else if hi < threshold {
139 ramp(threshold - hi)
140 } else {
141 0.0
142 }
143 };
144 let mut dx = 0.0;
145 let mut dy = 0.0;
146 if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
147 dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
148 }
149 if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
150 dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
151 }
152 (dx, dy)
153}
154
155pub fn frame_delta(velocity: (f64, f64), elapsed_seconds: f64) -> (f64, f64) {
157 let elapsed = if elapsed_seconds.is_finite() {
158 elapsed_seconds.clamp(0.0, 0.1)
159 } else {
160 0.0
161 };
162 (velocity.0 * elapsed, velocity.1 * elapsed)
163}
164
165fn animation_elapsed_seconds(elapsed_seconds: f32) -> f64 {
166 let elapsed_seconds = f64::from(elapsed_seconds);
167 if elapsed_seconds.is_finite() && elapsed_seconds > 0.0 {
168 elapsed_seconds
169 } else {
170 0.016
173 }
174}
175
176fn pointer_move_should_scroll(
182 pointer_type: &str,
183 pressure: f32,
184 has_held_button: bool,
185 active: Option<bool>,
186) -> bool {
187 match active {
188 Some(active) => active,
189 None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
190 }
191}
192
193fn external_pointer_sample(active: Option<bool>, drag_pointer: Option<Point>) -> Option<Point> {
197 (active == Some(true)).then_some(drag_pointer).flatten()
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201enum ClockOwner {
202 Pointer,
203 NativeDrag,
204 External,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208struct ClockToken {
209 owner: ClockOwner,
210 epoch: u64,
211}
212
213fn resolved_velocity(speed: f64, speed_px_per_second: Option<f64>) -> f64 {
214 speed_px_per_second.unwrap_or(speed * 60.0)
215}
216
217#[component]
224pub fn AutoScroll(
225 #[props(default = 48.0)]
227 threshold: f64,
228 #[props(default = 24.0)]
232 speed: f64,
233 #[props(default)]
236 speed_px_per_second: Option<f64>,
237 #[props(default)]
239 axis: ScrollAxis,
240 #[props(default)]
244 active: Option<bool>,
245 #[props(default)]
251 drag_pointer: Option<Point>,
252 #[props(default)]
259 on_scroll: Option<EventHandler<Point>>,
260 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
261 children: Element,
262) -> Element {
263 let max_velocity = resolved_velocity(speed, speed_px_per_second);
264 let mut mounted = use_signal(|| None::<Rc<MountedData>>);
265 let busy = use_signal(|| false);
268 let mut latest_pointer = use_signal(|| None::<Point>);
269 let mut clock_running = use_signal(|| false);
270 let mut clock_generation = use_signal(|| 0u64);
271 let mut clock_owner = use_signal(|| None::<ClockOwner>);
272 let mut clock_epoch = use_signal(|| 0u64);
273 let mut native_drag_depth = use_signal(|| 0u32);
274 let scroll_id =
275 use_hook(|| ScrollContainerId(NEXT_SCROLL_CONTAINER.fetch_add(1, Ordering::Relaxed)));
276 let coordinator = use_hook(|| {
277 try_consume_context::<ScrollCoordinator>().unwrap_or_else(ScrollCoordinator::new)
278 });
279 use_context_provider(|| coordinator);
280 use_drop(move || {
281 let mut coordinator = coordinator;
282 coordinator.unregister(scroll_id);
283 });
284 let refresh = use_rect_refresh_provider();
290 let last_offset = use_signal(Point::default);
292
293 let sample = move || {
298 let Some(m) = mounted.peek().clone() else {
299 return;
300 };
301 let mut last_offset = last_offset;
302 spawn(async move {
303 if let Ok(o) = m.get_scroll_offset().await {
304 let now = Point::new(o.x, o.y);
305 if *last_offset.peek() != now {
306 last_offset.set(now);
307 refresh.refresh_all();
311 if let Some(h) = &on_scroll {
312 h.call(now);
313 }
314 }
315 }
316 });
317 };
318
319 let scroll_for = move |point: Point, elapsed_seconds: f64, token: ClockToken| {
320 let Some(m) = mounted.peek().clone() else {
321 return;
322 };
323 let still_owned = move || {
324 *clock_running.peek()
325 && *clock_owner.peek() == Some(token.owner)
326 && *clock_epoch.peek() == token.epoch
327 };
328 if !still_owned() {
329 return;
330 }
331 if *busy.peek() {
332 return;
333 }
334 let mut busy = busy;
335 let mut clock_running = clock_running;
336 let mut coordinator = coordinator;
337 busy.set(true);
338 spawn(async move {
339 if let Ok(r) = m.get_client_rect().await {
340 if !still_owned() {
341 busy.set(false);
342 return;
343 }
344 let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
345 let velocity = edge_delta(point, rect, threshold, max_velocity, axis);
346 let (dx, dy) = frame_delta(velocity, elapsed_seconds);
347 let available = dx != 0.0 || dy != 0.0;
348 coordinator.update(scroll_id, rect, available);
349 if !available {
350 if still_owned() {
351 clock_running.set(false);
352 clock_owner.set(None);
353 clock_epoch += 1;
354 }
355 busy.set(false);
356 return;
357 }
358 if coordinator.owner(point) != Some(scroll_id) {
359 busy.set(false);
360 return;
361 }
362 if dx != 0.0 || dy != 0.0 {
363 if let Ok(offset) = m.get_scroll_offset().await {
364 if !still_owned() {
365 busy.set(false);
366 return;
367 }
368 let _ = m
369 .scroll(
370 PixelsVector2D::new(offset.x + dx, offset.y + dy),
371 ScrollBehavior::Instant,
372 )
373 .await;
374 if !still_owned() {
375 busy.set(false);
376 return;
377 }
378 let moved = m
379 .get_scroll_offset()
380 .await
381 .map(|after| after.x != offset.x || after.y != offset.y)
382 .unwrap_or(true);
383 coordinator.set_blocked(scroll_id, !moved);
384 if !moved {
385 clock_running.set(false);
386 clock_owner.set(None);
387 clock_epoch += 1;
388 }
389 refresh.refresh_all();
394 sample();
395 }
396 }
397 }
398 busy.set(false);
399 });
400 };
401
402 let mut start_clock = move |point: Point, owner: ClockOwner| {
403 latest_pointer.set(Some(point));
404 let mut coordinator = coordinator;
409 coordinator.set_blocked(scroll_id, false);
410 if !*clock_running.peek() || *clock_owner.peek() != Some(owner) {
411 clock_owner.set(Some(owner));
412 clock_epoch += 1;
413 clock_running.set(true);
414 clock_generation += 1;
415 }
416 };
417 let mut stop_clock = move |owner: ClockOwner| {
418 if *clock_owner.peek() == Some(owner) {
419 clock_running.set(false);
420 clock_owner.set(None);
421 clock_epoch += 1;
422 }
423 };
424
425 use_effect(use_reactive!(|(active, drag_pointer)| {
429 if let Some(point) = external_pointer_sample(active, drag_pointer) {
430 start_clock(point, ClockOwner::External);
431 } else {
432 stop_clock(ClockOwner::External);
433 }
434 }));
435 let mut attributes = attributes;
436 crate::core::components::protect_attributes(
437 &mut attributes,
438 &[
439 "onmounted",
440 "onwheel",
441 "ondragenter",
442 "ondragover",
443 "ondragleave",
444 "ondrop",
445 "onpointermove",
446 "onpointerup",
447 "onpointercancel",
448 ],
449 );
450
451 rsx! {
452 style { style: "display: none;",
453 "@keyframes dnd-scroll-clock {{ from {{ opacity: 0.99; }} to {{ opacity: 1; }} }}"
454 }
455 div {
456 onmounted: move |evt: Event<MountedData>| {
457 mounted.set(Some(evt.data()));
458 sample();
461 },
462 onwheel: move |_| sample(),
467 ondragenter: move |_| native_drag_depth += 1,
473 ondragover: move |evt: DragEvent| {
474 if *native_drag_depth.peek() == 0 {
475 native_drag_depth.set(1);
476 }
477 let c = evt.client_coordinates();
478 start_clock(Point::new(c.x, c.y), ClockOwner::NativeDrag);
479 },
480 ondragleave: move |_| {
481 let next = native_drag_depth.peek().saturating_sub(1);
482 native_drag_depth.set(next);
483 if next == 0 {
484 stop_clock(ClockOwner::NativeDrag);
485 }
486 },
487 ondrop: move |_| {
488 native_drag_depth.set(0);
489 stop_clock(ClockOwner::NativeDrag);
490 },
491 onpointermove: move |evt: PointerEvent| {
494 if pointer_move_should_scroll(
495 &evt.pointer_type(),
496 evt.pressure(),
497 !evt.held_buttons().is_empty(),
498 active,
499 ) {
500 let c = evt.client_coordinates();
501 start_clock(Point::new(c.x, c.y), ClockOwner::Pointer);
502 }
503 sample();
507 },
508 onpointerup: move |_| stop_clock(ClockOwner::Pointer),
509 onpointercancel: move |_| stop_clock(ClockOwner::Pointer),
510 ..attributes,
511 if let Some(owner) = clock_running().then_some(clock_owner()).flatten() {
512 div {
513 key: "{clock_generation}",
514 style: "position: absolute; width: 0; height: 0; overflow: hidden; \
515 animation: dnd-scroll-clock 16ms linear forwards;",
516 aria_hidden: true,
517 onanimationend: move |event: AnimationEvent| {
518 let token = ClockToken {
519 owner,
520 epoch: *clock_epoch.peek(),
521 };
522 let elapsed = animation_elapsed_seconds(event.data().elapsed_time());
523 if let Some(point) = *latest_pointer.peek() {
524 scroll_for(point, elapsed, token);
525 }
526 if *clock_running.peek()
527 && *clock_owner.peek() == Some(token.owner)
528 && *clock_epoch.peek() == token.epoch
529 {
530 clock_generation += 1;
531 }
532 },
533 }
534 }
535 {children}
536 }
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use std::cell::Cell;
543
544 use super::*;
545
546 fn external_pointer_app() -> Element {
547 rsx! {
548 AutoScroll {
549 active: true,
550 drag_pointer: Point::new(5.0, 5.0),
551 "receiver"
552 }
553 }
554 }
555
556 #[test]
557 fn external_pointer_feed_is_available_without_dom_pointer_events() {
558 let mut dom = VirtualDom::new(external_pointer_app);
559 dom.rebuild_in_place();
560 assert!(dioxus_ssr::render(&dom).contains("receiver"));
561 }
562
563 #[derive(Clone, Props)]
564 struct DynamicPointerProps {
565 state: Rc<Cell<(Option<bool>, Option<Point>)>>,
566 }
567
568 impl PartialEq for DynamicPointerProps {
569 fn eq(&self, other: &Self) -> bool {
570 Rc::ptr_eq(&self.state, &other.state)
571 }
572 }
573
574 fn dynamic_pointer_app(props: DynamicPointerProps) -> Element {
575 let (active, drag_pointer) = props.state.get();
576 rsx! {
577 AutoScroll { active, drag_pointer, "receiver" }
578 }
579 }
580
581 fn flush_effects(dom: &mut VirtualDom) {
582 for _ in 0..3 {
583 dom.process_events();
584 dom.render_immediate(&mut dioxus::core::NoOpMutations);
585 }
586 }
587
588 #[test]
589 fn external_pointer_prop_changes_start_and_stop_the_clock() {
590 let state = Rc::new(Cell::new((Some(false), None)));
591 let mut dom = VirtualDom::new_with_props(
592 dynamic_pointer_app,
593 DynamicPointerProps {
594 state: state.clone(),
595 },
596 );
597 dom.rebuild_in_place();
598 flush_effects(&mut dom);
599 assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
600
601 state.set((Some(true), Some(Point::new(5.0, 5.0))));
602 dom.mark_dirty(ScopeId::APP);
603 flush_effects(&mut dom);
604 assert!(dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
605
606 state.set((None, Some(Point::new(5.0, 5.0))));
607 dom.mark_dirty(ScopeId::APP);
608 flush_effects(&mut dom);
609 assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
610
611 state.set((Some(true), Some(Point::new(5.0, 5.0))));
612 dom.mark_dirty(ScopeId::APP);
613 flush_effects(&mut dom);
614 assert!(dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
615
616 state.set((Some(true), None));
617 dom.mark_dirty(ScopeId::APP);
618 flush_effects(&mut dom);
619 assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
620 }
621
622 #[test]
623 fn external_pointer_requires_an_explicit_active_gate() {
624 let point = Point::new(5.0, 5.0);
625 assert_eq!(
626 external_pointer_sample(Some(true), Some(point)),
627 Some(point)
628 );
629 assert_eq!(external_pointer_sample(Some(false), Some(point)), None);
630 assert_eq!(external_pointer_sample(None, Some(point)), None);
631 assert_eq!(external_pointer_sample(Some(true), None), None);
632 }
633
634 #[test]
635 fn legacy_speed_keeps_its_nominal_sixty_hertz_behavior() {
636 assert_eq!(resolved_velocity(24.0, None), 1440.0);
637 assert_eq!(resolved_velocity(24.0, Some(720.0)), 720.0);
638 }
639
640 #[test]
641 fn deltas_ramp_toward_edges() {
642 let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
643 assert_eq!(
645 edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
646 (0.0, 0.0)
647 );
648 let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
650 assert!((-24.0..0.0).contains(&dy));
651 let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
653 assert_eq!(dy, 24.0);
654 let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
656 assert_eq!(dx, 0.0);
657 }
658
659 #[test]
660 fn velocity_is_scaled_by_elapsed_time() {
661 assert_eq!(frame_delta((600.0, -300.0), 0.02), (12.0, -6.0));
662 assert_eq!(frame_delta((100.0, 100.0), 5.0), (10.0, 10.0));
664 assert_eq!(frame_delta((100.0, 100.0), f64::NAN), (0.0, 0.0));
665 assert_eq!(animation_elapsed_seconds(0.0), 0.016);
666 assert_eq!(animation_elapsed_seconds(f32::NAN), 0.016);
667 }
668
669 fn coordinator_probe() -> Element {
670 let mut coordinator = ScrollCoordinator::new();
671 let outer = ScrollContainerId(1);
672 let inner = ScrollContainerId(2);
673 let point = Point::new(50.0, 50.0);
674 coordinator.update(outer, Rect::new(0.0, 0.0, 200.0, 200.0), true);
675 coordinator.update(inner, Rect::new(25.0, 25.0, 50.0, 50.0), true);
676 assert_eq!(coordinator.owner(point), Some(inner));
677 coordinator.set_blocked(inner, true);
678 assert_eq!(coordinator.owner(point), Some(outer));
679 rsx! {}
680 }
681
682 #[test]
683 fn nested_coordinator_hands_boundary_to_outer_container() {
684 let mut dom = VirtualDom::new(coordinator_probe);
685 dom.rebuild_in_place();
686 }
687
688 #[test]
689 fn no_scroll_when_pointer_leaves_the_container() {
690 let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
694 assert_eq!(
695 edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
696 (0.0, 0.0)
697 );
698 assert_eq!(
699 edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
700 (0.0, 0.0)
701 );
702 }
703
704 #[test]
705 fn narrow_container_scrolls_toward_the_nearer_edge() {
706 let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
709 let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
710 assert!(
711 dx > 0.0,
712 "near the right edge should scroll right, got {dx}"
713 );
714 let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
715 assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
716 }
717
718 #[test]
719 fn pointer_scroll_predicate_matches_active_pointer_drags() {
720 assert!(
721 pointer_move_should_scroll("mouse", 0.0, true, None),
722 "default mouse pointer drags keep a held button during movement"
723 );
724 assert!(
725 !pointer_move_should_scroll("mouse", 0.0, false, None),
726 "passive mouse hover must not scroll"
727 );
728 assert!(
729 pointer_move_should_scroll("touch", 0.5, false, None),
730 "touch contact can report pressure instead of held buttons"
731 );
732 assert!(
733 pointer_move_should_scroll("pen", 0.0, true, None),
734 "pen contact can also surface as held buttons"
735 );
736 assert!(
737 !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
738 "callers that track drag state can explicitly gate scrolling off"
739 );
740 assert!(
741 pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
742 "callers that track drag state can explicitly gate scrolling on"
743 );
744 }
745}