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