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 boundary drags) and active `pointermove` events (in-app
6//! pointer drags via [`crate::core::Draggable`]) 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//! ```text
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::hooks::use_rect_refresh_provider;
24use crate::core::{Point, Rect};
25
26/// Which axes to auto-scroll.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ScrollAxis {
29    /// Vertical only (the common case for lists).
30    #[default]
31    Y,
32    /// Horizontal only.
33    X,
34    /// Both.
35    Both,
36}
37
38/// Per-axis scroll delta for a pointer at `pos` inside `rect`.
39/// Returns `(dx, dy)`, each in `-speed..=speed`, scaled by how deep into the
40/// edge band the pointer is. Pure, for testability.
41pub fn edge_delta(
42    pos: Point,
43    rect: Rect,
44    threshold: f64,
45    speed: f64,
46    axis: ScrollAxis,
47) -> (f64, f64) {
48    // Only scroll while the pointer is within the container. Under pointer
49    // capture the container keeps receiving (bubbled) pointermove events even
50    // when the cursor is far outside it; without this gate the delta pins to
51    // full `speed` and the container scrolls forever. A pointer right at the
52    // edge still scrolls - `contains` is edge-inclusive.
53    if !rect.contains(pos) {
54        return (0.0, 0.0);
55    }
56    let ramp = |dist_into_band: f64| (dist_into_band / threshold.max(1.0)).clamp(0.0, 1.0) * speed;
57    // Scroll toward whichever edge is nearer on this axis. Choosing the nearer
58    // edge (rather than a plain `if left else if right`) means a container
59    // narrower than `2 * threshold` - where the pointer is within the band of
60    // both edges at once - still scrolls both ways instead of the near edge
61    // always winning.
62    let edge = |lo: f64, hi: f64| -> f64 {
63        if lo <= hi {
64            if lo < threshold {
65                -ramp(threshold - lo)
66            } else {
67                0.0
68            }
69        } else if hi < threshold {
70            ramp(threshold - hi)
71        } else {
72            0.0
73        }
74    };
75    let mut dx = 0.0;
76    let mut dy = 0.0;
77    if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
78        dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
79    }
80    if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
81        dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
82    }
83    (dx, dy)
84}
85
86/// Whether a pointer move should drive auto-scroll.
87///
88/// Mouse pointer drags report contact through held buttons. Touch and pen
89/// paths commonly report pressure during contact, and some platforms also
90/// expose held buttons for them.
91fn pointer_move_should_scroll(
92    pointer_type: &str,
93    pressure: f32,
94    has_held_button: bool,
95    active: Option<bool>,
96) -> bool {
97    match active {
98        Some(active) => active,
99        None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
100    }
101}
102
103/// A scrollable container that scrolls itself while a drag hovers near its
104/// edges. Give it the `overflow` CSS yourself (via `style`/`class`).
105#[component]
106pub fn AutoScroll(
107    /// Edge band size in px.
108    #[props(default = 48.0)]
109    threshold: f64,
110    /// Max scroll px per event.
111    #[props(default = 24.0)]
112    speed: f64,
113    /// Axes to scroll.
114    #[props(default)]
115    axis: ScrollAxis,
116    /// Optional external drag-state gate. `Some(true)` scrolls on pointer
117    /// movement, `Some(false)` suppresses it, and `None` uses the built-in
118    /// pointer contact heuristic.
119    #[props(default)]
120    active: Option<bool>,
121    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
122    children: Element,
123) -> Element {
124    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
125    // In-flight guard so a burst of dragover events doesn't queue a pile of
126    // overlapping async scrolls.
127    let busy = use_signal(|| false);
128    // Scrolling this container moves everything inside it, so cached
129    // hit-test rects go stale the moment we scroll. Create-or-inherit the
130    // tree's rect-refresh channel: with a DndProvider above we join its
131    // channel; without one (self-contained sortables, native pages) we
132    // anchor a channel ourselves so the components inside can register.
133    let refresh = use_rect_refresh_provider();
134
135    let scroll_for = move |point: Point| {
136        let Some(m) = mounted.peek().clone() else {
137            return;
138        };
139        if *busy.peek() {
140            return;
141        }
142        let mut busy = busy;
143        busy.set(true);
144        spawn(async move {
145            if let Ok(r) = m.get_client_rect().await {
146                let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
147                let (dx, dy) = edge_delta(point, rect, threshold, speed, axis);
148                if dx != 0.0 || dy != 0.0 {
149                    if let Ok(offset) = m.get_scroll_offset().await {
150                        let _ = m
151                            .scroll(
152                                PixelsVector2D::new(offset.x + dx, offset.y + dy),
153                                ScrollBehavior::Instant,
154                            )
155                            .await;
156                        // Everything just moved under the drag: re-measure
157                        // so hover and the eventual drop hit what the user
158                        // sees, not where things sat at pickup.
159                        refresh.refresh_all();
160                    }
161                }
162            }
163            busy.set(false);
164        });
165    };
166
167    rsx! {
168        div {
169            onmounted: move |evt: Event<MountedData>| {
170                mounted.set(Some(evt.data()));
171            },
172            // Any scroll of this container - ours above, or the user's own
173            // wheel/trackpad mid-drag - moves the zones inside it. Ping the
174            // provider tree to re-measure; self-gated thunks make this free
175            // while no drag is in flight.
176            onscroll: move |_| {
177                refresh.refresh_all();
178            },
179            // Native boundary drags: dragover fires continuously while
180            // hovering. Note: no prevent_default here - drop permission stays
181            // the business of the zones inside.
182            ondragover: move |evt: DragEvent| {
183                let c = evt.client_coordinates();
184                scroll_for(Point::new(c.x, c.y));
185            },
186            // Pointer-driven drags: mouse uses held buttons, while touch and
187            // pen commonly report pressure during contact.
188            onpointermove: move |evt: PointerEvent| {
189                if pointer_move_should_scroll(
190                    &evt.pointer_type(),
191                    evt.pressure(),
192                    !evt.held_buttons().is_empty(),
193                    active,
194                ) {
195                    let c = evt.client_coordinates();
196                    scroll_for(Point::new(c.x, c.y));
197                }
198            },
199            ..attributes,
200            {children}
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn deltas_ramp_toward_edges() {
211        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
212        // dead center: no scroll
213        assert_eq!(
214            edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
215            (0.0, 0.0)
216        );
217        // near top: negative dy, magnitude below max
218        let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
219        assert!((-24.0..0.0).contains(&dy));
220        // at the very bottom edge: full speed down
221        let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
222        assert_eq!(dy, 24.0);
223        // axis filtering: Y-only ignores horizontal proximity
224        let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
225        assert_eq!(dx, 0.0);
226    }
227
228    #[test]
229    fn no_scroll_when_pointer_leaves_the_container() {
230        // Under pointer capture a bubbled move can report a cursor far outside
231        // the container; that must not scroll (previously it pinned to full
232        // speed forever).
233        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
234        assert_eq!(
235            edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
236            (0.0, 0.0)
237        );
238        assert_eq!(
239            edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
240            (0.0, 0.0)
241        );
242    }
243
244    #[test]
245    fn narrow_container_scrolls_toward_the_nearer_edge() {
246        // 40px wide, band 48: the pointer is within both edges' bands, so the
247        // nearer edge must win rather than the left always winning.
248        let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
249        let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
250        assert!(
251            dx > 0.0,
252            "near the right edge should scroll right, got {dx}"
253        );
254        let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
255        assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
256    }
257
258    #[test]
259    fn pointer_scroll_predicate_matches_active_pointer_drags() {
260        assert!(
261            pointer_move_should_scroll("mouse", 0.0, true, None),
262            "default mouse pointer drags keep a held button during movement"
263        );
264        assert!(
265            !pointer_move_should_scroll("mouse", 0.0, false, None),
266            "passive mouse hover must not scroll"
267        );
268        assert!(
269            pointer_move_should_scroll("touch", 0.5, false, None),
270            "touch contact can report pressure instead of held buttons"
271        );
272        assert!(
273            pointer_move_should_scroll("pen", 0.0, true, None),
274            "pen contact can also surface as held buttons"
275        );
276        assert!(
277            !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
278            "callers that track drag state can explicitly gate scrolling off"
279        );
280        assert!(
281            pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
282            "callers that track drag state can explicitly gate scrolling on"
283        );
284    }
285}