Skip to main content

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}