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//! Scrolling and measuring go through Dioxus's `MountedData`: `dragover`
5//! (native boundary drags) and active `pointermove` events (in-app pointer
6//! 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//! Scroll *observation* (the rect-refresh ping and the `on_scroll` prop)
11//! rides the events that cause or accompany scrolling - wheel, pointer
12//! contact moves, and the auto-scrolls this component performs - each of
13//! which samples the offset through `MountedData` and reports when it
14//! changed. It has to work this way: dioxus-web 0.7 never delivers
15//! element-level `scroll` events to `onscroll` handlers, and its eval
16//! channel drops messages that resolve after the receiver parked, so
17//! neither a Rust `onscroll` nor a JS listener bridge can carry the
18//! signal. The known blind spot is a scroll no event accompanies (a
19//! programmatic `scroll-to-index` with the pointer at rest) - the code
20//! that initiates one should update its own state, and the next pointer
21//! or wheel activity trues everything up.
22//!
23//! ```text
24//! AutoScroll {
25//!     style: "height: 300px; overflow-y: auto;",
26//!     for item in long_list { Row { item } }
27//! }
28//! ```
29
30use std::rc::Rc;
31
32use dioxus::html::geometry::PixelsVector2D;
33use dioxus::html::{MountedData, ScrollBehavior};
34use dioxus::prelude::*;
35
36use crate::core::hooks::use_rect_refresh_provider;
37use crate::core::{Point, Rect};
38
39/// Which axes to auto-scroll.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ScrollAxis {
42    /// Vertical only (the common case for lists).
43    #[default]
44    Y,
45    /// Horizontal only.
46    X,
47    /// Both.
48    Both,
49}
50
51/// Per-axis scroll delta for a pointer at `pos` inside `rect`.
52/// Returns `(dx, dy)`, each in `-speed..=speed`, scaled by how deep into the
53/// edge band the pointer is. Pure, for testability.
54pub fn edge_delta(
55    pos: Point,
56    rect: Rect,
57    threshold: f64,
58    speed: f64,
59    axis: ScrollAxis,
60) -> (f64, f64) {
61    // Only scroll while the pointer is within the container. Under pointer
62    // capture the container keeps receiving (bubbled) pointermove events even
63    // when the cursor is far outside it; without this gate the delta pins to
64    // full `speed` and the container scrolls forever. A pointer right at the
65    // edge still scrolls - `contains` is edge-inclusive.
66    if !rect.contains(pos) {
67        return (0.0, 0.0);
68    }
69    let ramp = |dist_into_band: f64| (dist_into_band / threshold.max(1.0)).clamp(0.0, 1.0) * speed;
70    // Scroll toward whichever edge is nearer on this axis. Choosing the nearer
71    // edge (rather than a plain `if left else if right`) means a container
72    // narrower than `2 * threshold` - where the pointer is within the band of
73    // both edges at once - still scrolls both ways instead of the near edge
74    // always winning.
75    let edge = |lo: f64, hi: f64| -> f64 {
76        if lo <= hi {
77            if lo < threshold {
78                -ramp(threshold - lo)
79            } else {
80                0.0
81            }
82        } else if hi < threshold {
83            ramp(threshold - hi)
84        } else {
85            0.0
86        }
87    };
88    let mut dx = 0.0;
89    let mut dy = 0.0;
90    if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
91        dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
92    }
93    if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
94        dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
95    }
96    (dx, dy)
97}
98
99/// Whether a pointer move should drive auto-scroll.
100///
101/// Mouse pointer drags report contact through held buttons. Touch and pen
102/// paths commonly report pressure during contact, and some platforms also
103/// expose held buttons for them.
104fn pointer_move_should_scroll(
105    pointer_type: &str,
106    pressure: f32,
107    has_held_button: bool,
108    active: Option<bool>,
109) -> bool {
110    match active {
111        Some(active) => active,
112        None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
113    }
114}
115
116/// A scrollable container that scrolls itself while a drag hovers near its
117/// edges. Give it the `overflow` CSS yourself (via `style`/`class`).
118#[component]
119pub fn AutoScroll(
120    /// Edge band size in px.
121    #[props(default = 48.0)]
122    threshold: f64,
123    /// Max scroll px per event.
124    #[props(default = 24.0)]
125    speed: f64,
126    /// Axes to scroll.
127    #[props(default)]
128    axis: ScrollAxis,
129    /// Optional external drag-state gate. `Some(true)` scrolls on pointer
130    /// movement, `Some(false)` suppresses it, and `None` uses the built-in
131    /// pointer contact heuristic.
132    #[props(default)]
133    active: Option<bool>,
134    /// Fired with the container's scroll offset when a sample sees it
135    /// changed - after the auto-scroll's own scrolling, a wheel/trackpad
136    /// scroll, or pointer movement over the container - following the
137    /// rect-refresh ping. Drive a windowed (virtualized) list from
138    /// `offset.y`. See the module docs for how observation works and its
139    /// one blind spot.
140    #[props(default)]
141    on_scroll: Option<EventHandler<Point>>,
142    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
143    children: Element,
144) -> Element {
145    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
146    // In-flight guard so a burst of dragover events doesn't queue a pile of
147    // overlapping async scrolls.
148    let busy = use_signal(|| false);
149    // Scrolling this container moves everything inside it, so cached
150    // hit-test rects go stale the moment we scroll. Create-or-inherit the
151    // tree's rect-refresh channel: with a DndProvider above we join its
152    // channel; without one (self-contained sortables, native pages) we
153    // anchor a channel ourselves so the components inside can register.
154    let refresh = use_rect_refresh_provider();
155    // Last offset `sample` saw, deduplicating pings and on_scroll reports.
156    let last_offset = use_signal(Point::default);
157
158    // The observer: read the offset, and when it moved, ping the
159    // rect-refresh channel and report to on_scroll. Called from every
160    // event that can cause or accompany scrolling; the dedup makes the
161    // common nothing-changed case one cheap async read.
162    let sample = move || {
163        let Some(m) = mounted.peek().clone() else {
164            return;
165        };
166        let mut last_offset = last_offset;
167        spawn(async move {
168            if let Ok(o) = m.get_scroll_offset().await {
169                let now = Point::new(o.x, o.y);
170                if *last_offset.peek() != now {
171                    last_offset.set(now);
172                    // The zones inside just moved: re-measure (free while
173                    // no drag is in flight), then let the app re-slice its
174                    // window.
175                    refresh.refresh_all();
176                    if let Some(h) = &on_scroll {
177                        h.call(now);
178                    }
179                }
180            }
181        });
182    };
183
184    let scroll_for = move |point: Point| {
185        let Some(m) = mounted.peek().clone() else {
186            return;
187        };
188        if *busy.peek() {
189            return;
190        }
191        let mut busy = busy;
192        busy.set(true);
193        spawn(async move {
194            if let Ok(r) = m.get_client_rect().await {
195                let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
196                let (dx, dy) = edge_delta(point, rect, threshold, speed, axis);
197                if dx != 0.0 || dy != 0.0 {
198                    if let Ok(offset) = m.get_scroll_offset().await {
199                        let _ = m
200                            .scroll(
201                                PixelsVector2D::new(offset.x + dx, offset.y + dy),
202                                ScrollBehavior::Instant,
203                            )
204                            .await;
205                        // Everything just moved under the drag: re-measure
206                        // so hover and the eventual drop hit what the user
207                        // sees, not where things sat at pickup - and report
208                        // the new offset so a windowed list re-slices.
209                        refresh.refresh_all();
210                        sample();
211                    }
212                }
213            }
214            busy.set(false);
215        });
216    };
217
218    rsx! {
219        div {
220            onmounted: move |evt: Event<MountedData>| {
221                mounted.set(Some(evt.data()));
222                // Report the initial offset (restored scroll positions
223                // exist) so windowing starts aligned.
224                sample();
225            },
226            // Wheel and trackpad scrolling, idle or mid-drag. Wheel events
227            // go to the element under the cursor regardless of pointer
228            // capture, and the sample's async offset read resolves after
229            // the browser applied the scroll this event causes.
230            onwheel: move |_| sample(),
231            // Native boundary drags: dragover fires continuously while
232            // hovering. Note: no prevent_default here - drop permission stays
233            // the business of the zones inside.
234            ondragover: move |evt: DragEvent| {
235                let c = evt.client_coordinates();
236                scroll_for(Point::new(c.x, c.y));
237            },
238            // Pointer-driven drags: mouse uses held buttons, while touch and
239            // pen commonly report pressure during contact.
240            onpointermove: move |evt: PointerEvent| {
241                if pointer_move_should_scroll(
242                    &evt.pointer_type(),
243                    evt.pressure(),
244                    !evt.held_buttons().is_empty(),
245                    active,
246                ) {
247                    let c = evt.client_coordinates();
248                    scroll_for(Point::new(c.x, c.y));
249                }
250                // Sample on every move, contact or hover: it trues up the
251                // window after scrollbar drags and programmatic scrolls
252                // the moment the pointer stirs.
253                sample();
254            },
255            ..attributes,
256            {children}
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn deltas_ramp_toward_edges() {
267        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
268        // dead center: no scroll
269        assert_eq!(
270            edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
271            (0.0, 0.0)
272        );
273        // near top: negative dy, magnitude below max
274        let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
275        assert!((-24.0..0.0).contains(&dy));
276        // at the very bottom edge: full speed down
277        let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
278        assert_eq!(dy, 24.0);
279        // axis filtering: Y-only ignores horizontal proximity
280        let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
281        assert_eq!(dx, 0.0);
282    }
283
284    #[test]
285    fn no_scroll_when_pointer_leaves_the_container() {
286        // Under pointer capture a bubbled move can report a cursor far outside
287        // the container; that must not scroll (previously it pinned to full
288        // speed forever).
289        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
290        assert_eq!(
291            edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
292            (0.0, 0.0)
293        );
294        assert_eq!(
295            edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
296            (0.0, 0.0)
297        );
298    }
299
300    #[test]
301    fn narrow_container_scrolls_toward_the_nearer_edge() {
302        // 40px wide, band 48: the pointer is within both edges' bands, so the
303        // nearer edge must win rather than the left always winning.
304        let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
305        let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
306        assert!(
307            dx > 0.0,
308            "near the right edge should scroll right, got {dx}"
309        );
310        let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
311        assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
312    }
313
314    #[test]
315    fn pointer_scroll_predicate_matches_active_pointer_drags() {
316        assert!(
317            pointer_move_should_scroll("mouse", 0.0, true, None),
318            "default mouse pointer drags keep a held button during movement"
319        );
320        assert!(
321            !pointer_move_should_scroll("mouse", 0.0, false, None),
322            "passive mouse hover must not scroll"
323        );
324        assert!(
325            pointer_move_should_scroll("touch", 0.5, false, None),
326            "touch contact can report pressure instead of held buttons"
327        );
328        assert!(
329            pointer_move_should_scroll("pen", 0.0, true, None),
330            "pen contact can also surface as held buttons"
331        );
332        assert!(
333            !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
334            "callers that track drag state can explicitly gate scrolling off"
335        );
336        assert!(
337            pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
338            "callers that track drag state can explicitly gate scrolling on"
339        );
340    }
341}