Skip to main content

dioxus_dnd/
autoscroll.rs

1#![doc = include_str!("../docs/api/autoscroll.md")]
2
3use std::rc::Rc;
4
5use dioxus::html::geometry::PixelsVector2D;
6use dioxus::html::{MountedData, ScrollBehavior};
7use dioxus::prelude::*;
8
9use crate::core::hooks::use_rect_refresh_provider;
10use crate::core::{Point, Rect};
11
12/// Which axes to auto-scroll.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ScrollAxis {
15    /// Vertical only (the common case for lists).
16    #[default]
17    Y,
18    /// Horizontal only.
19    X,
20    /// Both.
21    Both,
22}
23
24/// Per-axis scroll delta for a pointer at `pos` inside `rect`.
25/// Returns `(dx, dy)`, each in `-speed..=speed`, scaled by how deep into the
26/// edge band the pointer is. Pure, for testability.
27pub fn edge_delta(
28    pos: Point,
29    rect: Rect,
30    threshold: f64,
31    speed: f64,
32    axis: ScrollAxis,
33) -> (f64, f64) {
34    // Only scroll while the pointer is within the container. Under pointer
35    // capture the container keeps receiving (bubbled) pointermove events even
36    // when the cursor is far outside it; without this gate the delta pins to
37    // full `speed` and the container scrolls forever. A pointer right at the
38    // edge still scrolls - `contains` is edge-inclusive.
39    if !rect.contains(pos) {
40        return (0.0, 0.0);
41    }
42    let ramp = |dist_into_band: f64| (dist_into_band / threshold.max(1.0)).clamp(0.0, 1.0) * speed;
43    // Scroll toward whichever edge is nearer on this axis. Choosing the nearer
44    // edge (rather than a plain `if left else if right`) means a container
45    // narrower than `2 * threshold` - where the pointer is within the band of
46    // both edges at once - still scrolls both ways instead of the near edge
47    // always winning.
48    let edge = |lo: f64, hi: f64| -> f64 {
49        if lo <= hi {
50            if lo < threshold {
51                -ramp(threshold - lo)
52            } else {
53                0.0
54            }
55        } else if hi < threshold {
56            ramp(threshold - hi)
57        } else {
58            0.0
59        }
60    };
61    let mut dx = 0.0;
62    let mut dy = 0.0;
63    if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
64        dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
65    }
66    if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
67        dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
68    }
69    (dx, dy)
70}
71
72/// Whether a pointer move should drive auto-scroll.
73///
74/// Mouse pointer drags report contact through held buttons. Touch and pen
75/// paths commonly report pressure during contact, and some platforms also
76/// expose held buttons for them.
77fn pointer_move_should_scroll(
78    pointer_type: &str,
79    pressure: f32,
80    has_held_button: bool,
81    active: Option<bool>,
82) -> bool {
83    match active {
84        Some(active) => active,
85        None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
86    }
87}
88
89/// Select a host-driven pointer sample only when the caller explicitly
90/// confirms that its drag is active. An externally retained coordinate must
91/// never keep scrolling idle or settling content.
92fn external_pointer_sample(active: Option<bool>, drag_pointer: Option<Point>) -> Option<Point> {
93    (active == Some(true)).then_some(drag_pointer).flatten()
94}
95
96/// A scrollable container that scrolls itself while a drag hovers near its
97/// edges. Give it the `overflow` CSS yourself (via `style`/`class`) - and
98/// consider `overscroll-behavior: contain` alongside it, so a wheel or
99/// touch scroll that hits the container's end mid-drag doesn't chain into
100/// scrolling the page. (The edge-scrolling itself is programmatic, clamps
101/// at the container's bounds, and never chains.)
102#[component]
103pub fn AutoScroll(
104    /// Edge band size in px.
105    #[props(default = 48.0)]
106    threshold: f64,
107    /// Max scroll px per event.
108    #[props(default = 24.0)]
109    speed: f64,
110    /// Axes to scroll.
111    #[props(default)]
112    axis: ScrollAxis,
113    /// Optional external drag-state gate. `Some(true)` scrolls on pointer
114    /// movement, `Some(false)` suppresses it, and `None` uses the built-in
115    /// pointer contact heuristic.
116    #[props(default)]
117    active: Option<bool>,
118    /// Optional pointer supplied by a host that tracks movement outside this
119    /// element's DOM event stream, expressed in this window's client
120    /// coordinates. The sample is used only with `active: Some(true)`; pass
121    /// the matching drag's live active state so a retained coordinate cannot
122    /// scroll idle or settling content.
123    #[props(default)]
124    drag_pointer: Option<Point>,
125    /// Fired with the container's scroll offset when a sample sees it
126    /// changed - after the auto-scroll's own scrolling, a wheel/trackpad
127    /// scroll, or pointer movement over the container - following the
128    /// rect-refresh ping. Drive a windowed (virtualized) list from
129    /// `offset.y`. See the module docs for how observation works and its
130    /// one blind spot.
131    #[props(default)]
132    on_scroll: Option<EventHandler<Point>>,
133    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
134    children: Element,
135) -> Element {
136    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
137    // In-flight guard so a burst of dragover events doesn't queue a pile of
138    // overlapping async scrolls.
139    let busy = use_signal(|| false);
140    // Scrolling this container moves everything inside it, so cached
141    // hit-test rects go stale the moment we scroll. Create-or-inherit the
142    // tree's rect-refresh channel: with a DndProvider above we join its
143    // channel; without one (self-contained sortables, native pages) we
144    // anchor a channel ourselves so the components inside can register.
145    let refresh = use_rect_refresh_provider();
146    // Last offset `sample` saw, deduplicating pings and on_scroll reports.
147    let last_offset = use_signal(Point::default);
148
149    // The observer: read the offset, and when it moved, ping the
150    // rect-refresh channel and report to on_scroll. Called from every
151    // event that can cause or accompany scrolling; the dedup makes the
152    // common nothing-changed case one cheap async read.
153    let sample = move || {
154        let Some(m) = mounted.peek().clone() else {
155            return;
156        };
157        let mut last_offset = last_offset;
158        spawn(async move {
159            if let Ok(o) = m.get_scroll_offset().await {
160                let now = Point::new(o.x, o.y);
161                if *last_offset.peek() != now {
162                    last_offset.set(now);
163                    // The zones inside just moved: re-measure (free while
164                    // no drag is in flight), then let the app re-slice its
165                    // window.
166                    refresh.refresh_all();
167                    if let Some(h) = &on_scroll {
168                        h.call(now);
169                    }
170                }
171            }
172        });
173    };
174
175    let scroll_for = move |point: Point| {
176        let Some(m) = mounted.peek().clone() else {
177            return;
178        };
179        if *busy.peek() {
180            return;
181        }
182        let mut busy = busy;
183        busy.set(true);
184        spawn(async move {
185            if let Ok(r) = m.get_client_rect().await {
186                let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
187                let (dx, dy) = edge_delta(point, rect, threshold, speed, axis);
188                if dx != 0.0 || dy != 0.0 {
189                    if let Ok(offset) = m.get_scroll_offset().await {
190                        let _ = m
191                            .scroll(
192                                PixelsVector2D::new(offset.x + dx, offset.y + dy),
193                                ScrollBehavior::Instant,
194                            )
195                            .await;
196                        // Everything just moved under the drag: re-measure
197                        // so hover and the eventual drop hit what the user
198                        // sees, not where things sat at pickup - and report
199                        // the new offset so a windowed list re-slices.
200                        refresh.refresh_all();
201                        sample();
202                    }
203                }
204            }
205            busy.set(false);
206        });
207    };
208
209    // A host-driven receiver may be event-blind while another surface owns
210    // the pointer. React to its client-space feed through the same scroll
211    // path as DOM pointer movement, with the explicit active gate above.
212    use_effect(move || {
213        if let Some(point) = external_pointer_sample(active, drag_pointer) {
214            scroll_for(point);
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    fn external_pointer_app() -> Element {
266        rsx! {
267            AutoScroll {
268                active: true,
269                drag_pointer: Point::new(5.0, 5.0),
270                "receiver"
271            }
272        }
273    }
274
275    #[test]
276    fn external_pointer_feed_is_available_without_dom_pointer_events() {
277        let mut dom = VirtualDom::new(external_pointer_app);
278        dom.rebuild_in_place();
279        assert!(dioxus_ssr::render(&dom).contains("receiver"));
280    }
281
282    #[test]
283    fn external_pointer_requires_an_explicit_active_gate() {
284        let point = Point::new(5.0, 5.0);
285        assert_eq!(
286            external_pointer_sample(Some(true), Some(point)),
287            Some(point)
288        );
289        assert_eq!(external_pointer_sample(Some(false), Some(point)), None);
290        assert_eq!(external_pointer_sample(None, Some(point)), None);
291        assert_eq!(external_pointer_sample(Some(true), None), None);
292    }
293
294    #[test]
295    fn deltas_ramp_toward_edges() {
296        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
297        // dead center: no scroll
298        assert_eq!(
299            edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
300            (0.0, 0.0)
301        );
302        // near top: negative dy, magnitude below max
303        let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
304        assert!((-24.0..0.0).contains(&dy));
305        // at the very bottom edge: full speed down
306        let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
307        assert_eq!(dy, 24.0);
308        // axis filtering: Y-only ignores horizontal proximity
309        let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
310        assert_eq!(dx, 0.0);
311    }
312
313    #[test]
314    fn no_scroll_when_pointer_leaves_the_container() {
315        // Under pointer capture a bubbled move can report a cursor far outside
316        // the container; that must not scroll (previously it pinned to full
317        // speed forever).
318        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
319        assert_eq!(
320            edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
321            (0.0, 0.0)
322        );
323        assert_eq!(
324            edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
325            (0.0, 0.0)
326        );
327    }
328
329    #[test]
330    fn narrow_container_scrolls_toward_the_nearer_edge() {
331        // 40px wide, band 48: the pointer is within both edges' bands, so the
332        // nearer edge must win rather than the left always winning.
333        let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
334        let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
335        assert!(
336            dx > 0.0,
337            "near the right edge should scroll right, got {dx}"
338        );
339        let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
340        assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
341    }
342
343    #[test]
344    fn pointer_scroll_predicate_matches_active_pointer_drags() {
345        assert!(
346            pointer_move_should_scroll("mouse", 0.0, true, None),
347            "default mouse pointer drags keep a held button during movement"
348        );
349        assert!(
350            !pointer_move_should_scroll("mouse", 0.0, false, None),
351            "passive mouse hover must not scroll"
352        );
353        assert!(
354            pointer_move_should_scroll("touch", 0.5, false, None),
355            "touch contact can report pressure instead of held buttons"
356        );
357        assert!(
358            pointer_move_should_scroll("pen", 0.0, true, None),
359            "pen contact can also surface as held buttons"
360        );
361        assert!(
362            !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
363            "callers that track drag state can explicitly gate scrolling off"
364        );
365        assert!(
366            pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
367            "callers that track drag state can explicitly gate scrolling on"
368        );
369    }
370}