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