dioxus_dnd/pointer.rs
1//! Instant, consistent touch (and pen) support. Some mobile browsers can
2//! fire native HTML5 drag events from a touch long-press on `draggable`
3//! elements (Safari on iOS/iPadOS 15+; reports for Chrome on Android
4//! conflict), but the hold delay is browser-controlled and support is
5//! inconsistent across the mobile landscape. This module drives the same
6//! shared [`crate::core::DndContext`] from pointer events instead: the drag
7//! starts after a small movement threshold with no hold, behaves the same
8//! in every browser, and uses your `DragOverlay` as the ghost. Where native
9//! long-press exists, it keeps working as a fallback on plain `Draggable`s.
10//!
11//! [`PointerDraggable`] *composes* the core [`Draggable`]: mouse users get
12//! the native HTML5 drag path, while touch/pen input is handled with
13//! `pointerdown` → `pointermove` → `pointerup`, hit-testing the registered
14//! drop zones' cached client rects to decide where the drop lands. Touch
15//! pointers have implicit pointer capture, so the originating element keeps
16//! receiving moves for the whole gesture.
17//!
18//! Two things to know:
19//! - The wrapper sets `touch-action: none` so the browser doesn't hijack the
20//! gesture for scrolling. If your list must also scroll by touch, consider
21//! a drag handle: put `PointerDraggable` on the handle only.
22//! - A small movement threshold (default 8 px) distinguishes drags from taps.
23
24use dioxus::prelude::*;
25
26use crate::core::{
27 transition, use_dnd, use_zone_registry, DragMode, Draggable, DropEffect, DropOutcome,
28 GestureEffect, GestureEvent, GesturePhase, Point, ZoneId,
29};
30
31/// Pointer position from a pointer event, in client coordinates.
32pub(crate) fn pointer_client(evt: &PointerEvent) -> Point {
33 let c = evt.client_coordinates();
34 Point::new(c.x, c.y)
35}
36
37/// A draggable that works for mouse *and* touch/pen.
38///
39/// Mouse drags go through the native HTML5 path (inner core `Draggable`);
40/// touch and pen drags are synthesized from pointer events. Both feed the
41/// same context, so your `DropZone`s don't care which path delivered the
42/// payload — touch drops arrive through the zone registry with correct
43/// client/element coordinates.
44#[component]
45pub fn PointerDraggable<T: Clone + PartialEq + 'static>(
46 /// The value delivered on drop.
47 payload: T,
48 /// The zone this item lives in (reported in `DropOutcome::from`).
49 #[props(default)]
50 zone: Option<ZoneId>,
51 /// Drop effect. Defaults to `Move`.
52 #[props(default)]
53 effect: DropEffect,
54 /// Disable dragging without unmounting.
55 #[props(default)]
56 disabled: bool,
57 /// Label for screen-reader announcements (forwarded to the inner
58 /// `Draggable`).
59 #[props(default)]
60 label: Option<String>,
61 /// Movement (px) before a touch counts as a drag rather than a tap.
62 #[props(default = 8.0)]
63 threshold: f64,
64 /// Fired when a drag begins (either path).
65 #[props(default)]
66 on_drag_start: Option<EventHandler<()>>,
67 /// Fired when the drag ends; `true` if a zone consumed the payload.
68 #[props(default)]
69 on_drag_end: Option<EventHandler<bool>>,
70 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
71 children: Element,
72) -> Element {
73 let mut dnd = use_dnd::<T>();
74 let registry = use_zone_registry::<T>();
75 // The gesture lifecycle is a formal state machine (see `core::machine`):
76 // handlers feed it events and act on the effects it returns.
77 let mut phase = use_signal(|| GesturePhase::Idle);
78 let mut step = move |event: GestureEvent, threshold: f64| -> GestureEffect {
79 let (next, fx) = transition(*phase.peek(), event, threshold);
80 phase.set(next);
81 fx
82 };
83
84 let touch_payload = payload.clone();
85
86 // Deliver to a specific zone. Returns true if the drop landed.
87 let mut deliver_to = move |target: ZoneId, point: Point, effect: DropEffect| -> bool {
88 let Some(record) = registry.get(target) else {
89 return false;
90 };
91 let Some(p) = dnd.payload() else {
92 return false;
93 };
94 if !record.accepts_payload(&p) {
95 return false;
96 }
97 let origin = (*record.rect.peek())
98 .map(|r| r.origin())
99 .unwrap_or_default();
100 if let Some((p, from)) = dnd.take() {
101 record.on_drop.call(DropOutcome {
102 payload: p,
103 from,
104 to: target,
105 effect,
106 client: point,
107 element: point - origin,
108 });
109 return true;
110 }
111 false
112 };
113
114 rsx! {
115 div {
116 style: "touch-action: none;",
117 onpointerdown: move |evt: PointerEvent| {
118 if disabled || !evt.is_primary() || evt.pointer_type() == "mouse" {
119 // Mouse uses the native HTML5 path of the inner Draggable.
120 return;
121 }
122 let _ = step(
123 GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
124 threshold,
125 );
126 },
127 onpointermove: move |evt: PointerEvent| {
128 let event = GestureEvent::Move {
129 at: pointer_client(&evt),
130 pointer_id: evt.pointer_id(),
131 };
132 match step(event, threshold) {
133 GestureEffect::Begin { origin, at } => {
134 dnd.start(
135 touch_payload.clone(),
136 zone,
137 at,
138 at - origin, // grab offset: travel from the press point
139 effect,
140 DragMode::Pointer,
141 );
142 // Rects go stale on scroll/layout; refresh at drag start.
143 registry.refresh_rects();
144 if let Some(h) = &on_drag_start {
145 h.call(());
146 }
147 }
148 GestureEffect::Track { at } => {
149 dnd.update_pointer(at);
150 // Track hover for zone highlighting.
151 match registry.hit_test(at) {
152 Some(z) => dnd.enter(z),
153 None => {
154 if let Some(over) = dnd.over() {
155 dnd.leave(over);
156 }
157 }
158 }
159 }
160 _ => {}
161 }
162 },
163 onpointerup: move |evt: PointerEvent| {
164 let event = GestureEvent::Up {
165 at: pointer_client(&evt),
166 pointer_id: evt.pointer_id(),
167 };
168 let GestureEffect::Drop { at: point } = step(event, threshold) else {
169 return; // tap, or a foreign pointer's release
170 };
171 // Fast path: cached rects contain the point.
172 if let Some(target) = registry.hit_test(point) {
173 let dropped = deliver_to(target, point, effect);
174 if !dropped {
175 dnd.cancel();
176 }
177 if let Some(h) = &on_drag_end {
178 h.call(dropped);
179 }
180 return;
181 }
182 // Miss: rects may be stale (scroll/resize mid-drag). Re-measure,
183 // then retry with a closest-center fallback for gutter drops.
184 let on_drag_end = on_drag_end;
185 spawn(async move {
186 registry.measure_all().await;
187 let target = dnd
188 .payload()
189 .and_then(|p| registry.hit_test_closest(point, &p, 48.0));
190 let dropped = match target {
191 Some(t) => deliver_to(t, point, effect),
192 None => false,
193 };
194 if !dropped {
195 dnd.cancel();
196 }
197 if let Some(h) = &on_drag_end {
198 h.call(dropped);
199 }
200 });
201 },
202 onpointercancel: move |_| {
203 if step(GestureEvent::Cancel, threshold) == GestureEffect::Abort {
204 dnd.cancel();
205 if let Some(h) = &on_drag_end {
206 h.call(false);
207 }
208 }
209 },
210 ..attributes,
211 Draggable::<T> {
212 payload,
213 zone,
214 effect,
215 disabled,
216 label,
217 on_drag_start,
218 on_drag_end,
219 {children}
220 }
221 }
222 }
223}