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}