Skip to main content

dioxus_dnd/
autoscroll.rs

1//! Auto-scroll: when a drag hovers near the edge of a scrollable container,
2//! scroll it — the missing piece for long lists and tall boards.
3//!
4//! Implemented entirely through Dioxus's `MountedData` (no JS eval):
5//! `dragover` (native drags) and `pointermove` with contact (touch drags via
6//! [`crate::pointer::PointerDraggable`]) feed pointer positions; when the
7//! pointer sits within `threshold` px of an edge, the container is scrolled
8//! by up to `speed` px per event, scaled by proximity.
9//!
10//! ```rust,ignore
11//! AutoScroll {
12//!     style: "height: 300px; overflow-y: auto;",
13//!     for item in long_list { Row { item } }
14//! }
15//! ```
16
17use std::rc::Rc;
18
19use dioxus::html::geometry::PixelsVector2D;
20use dioxus::html::{MountedData, ScrollBehavior};
21use dioxus::prelude::*;
22
23use crate::core::{Point, Rect};
24
25/// Which axes to auto-scroll.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ScrollAxis {
28    /// Vertical only (the common case for lists).
29    #[default]
30    Y,
31    /// Horizontal only.
32    X,
33    /// Both.
34    Both,
35}
36
37/// Per-axis scroll delta for a pointer at `pos` inside `rect`.
38/// Returns `(dx, dy)`, each in `-speed..=speed`, scaled by how deep into the
39/// edge band the pointer is. Pure, for testability.
40pub fn edge_delta(
41    pos: Point,
42    rect: Rect,
43    threshold: f64,
44    speed: f64,
45    axis: ScrollAxis,
46) -> (f64, f64) {
47    let ramp = |dist_into_band: f64| (dist_into_band / threshold.max(1.0)).clamp(0.0, 1.0) * speed;
48    let mut dx = 0.0;
49    let mut dy = 0.0;
50    if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
51        let from_left = pos.x - rect.x;
52        let from_right = rect.x + rect.width - pos.x;
53        if from_left < threshold {
54            dx = -ramp(threshold - from_left);
55        } else if from_right < threshold {
56            dx = ramp(threshold - from_right);
57        }
58    }
59    if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
60        let from_top = pos.y - rect.y;
61        let from_bottom = rect.y + rect.height - pos.y;
62        if from_top < threshold {
63            dy = -ramp(threshold - from_top);
64        } else if from_bottom < threshold {
65            dy = ramp(threshold - from_bottom);
66        }
67    }
68    (dx, dy)
69}
70
71/// A scrollable container that scrolls itself while a drag hovers near its
72/// edges. Give it the `overflow` CSS yourself (via `style`/`class`).
73#[component]
74pub fn AutoScroll(
75    /// Edge band size in px.
76    #[props(default = 48.0)]
77    threshold: f64,
78    /// Max scroll px per event.
79    #[props(default = 24.0)]
80    speed: f64,
81    /// Axes to scroll.
82    #[props(default)]
83    axis: ScrollAxis,
84    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
85    children: Element,
86) -> Element {
87    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
88    // In-flight guard so a burst of dragover events doesn't queue a pile of
89    // overlapping async scrolls.
90    let busy = use_signal(|| false);
91
92    let scroll_for = move |point: Point| {
93        let Some(m) = mounted.peek().clone() else {
94            return;
95        };
96        if *busy.peek() {
97            return;
98        }
99        let mut busy = busy;
100        busy.set(true);
101        spawn(async move {
102            if let Ok(r) = m.get_client_rect().await {
103                let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
104                let (dx, dy) = edge_delta(point, rect, threshold, speed, axis);
105                if dx != 0.0 || dy != 0.0 {
106                    if let Ok(offset) = m.get_scroll_offset().await {
107                        let _ = m
108                            .scroll(
109                                PixelsVector2D::new(offset.x + dx, offset.y + dy),
110                                ScrollBehavior::Instant,
111                            )
112                            .await;
113                    }
114                }
115            }
116            busy.set(false);
117        });
118    };
119
120    rsx! {
121        div {
122            onmounted: move |evt: Event<MountedData>| {
123                mounted.set(Some(evt.data()));
124            },
125            // Native HTML5 drags (mouse): dragover fires continuously while
126            // hovering. Note: no prevent_default here — drop permission stays
127            // the business of the zones inside.
128            ondragover: move |evt: DragEvent| {
129                let c = evt.client_coordinates();
130                scroll_for(Point::new(c.x, c.y));
131            },
132            // Pointer-driven drags (touch/pen via PointerDraggable): moves
133            // with contact pressure count as dragging.
134            onpointermove: move |evt: PointerEvent| {
135                if evt.pointer_type() != "mouse" && evt.pressure() > 0.0 {
136                    let c = evt.client_coordinates();
137                    scroll_for(Point::new(c.x, c.y));
138                }
139            },
140            ..attributes,
141            {children}
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn deltas_ramp_toward_edges() {
152        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
153        // dead center: no scroll
154        assert_eq!(
155            edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
156            (0.0, 0.0)
157        );
158        // near top: negative dy, magnitude below max
159        let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
160        assert!(dy < 0.0 && dy >= -24.0);
161        // at the very bottom edge: full speed down
162        let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
163        assert_eq!(dy, 24.0);
164        // axis filtering: Y-only ignores horizontal proximity
165        let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
166        assert_eq!(dx, 0.0);
167    }
168}