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}