Skip to main content

dioxus_dnd/
grid.rs

1//! 2D grids — dashboards, tile galleries, icon views. A grid is a flat
2//! `Vec` displayed in `cols` columns; dragging a tile onto another either
3//! **inserts** (everything reflows, like a photo gallery) or **swaps**
4//! (tiles trade places, like a dashboard) depending on [`ReorderMode`].
5//!
6//! Reuses the sortable vocabulary: drops emit [`SortEvent`]s you apply with
7//! [`crate::sortable::apply_sort`] or [`crate::sortable::apply_swap`].
8//!
9//! ```rust,ignore
10//! let mut tiles = use_signal(|| (0..12).collect::<Vec<u32>>());
11//! rsx! {
12//!     SortableGrid {
13//!         len: tiles.read().len(),
14//!         cols: 4,
15//!         mode: ReorderMode::Swap,
16//!         render: move |ix: usize| rsx! { Tile { n: tiles.read()[ix] } },
17//!         on_sort: move |ev: SortEvent| apply_swap(&mut tiles.write(), ev),
18//!     }
19//! }
20//! ```
21//!
22//! Grid coordinate helpers ([`cell_of`], [`index_of`]) are provided for
23//! custom layouts and keyboard grid navigation.
24//!
25//! Touch and pen drags work instantly in every browser via the same
26//! pointer-event gesture machine as [`crate::pointer::PointerDraggable`];
27//! tiles carry `touch-action: none` (grids rarely need to scroll by
28//! dragging across their own tiles). The hovered tile is simply the one
29//! under the finger — no hysteresis needed, since tiles don't shift while
30//! you hover in swap/insert grids.
31
32use std::collections::HashMap;
33use std::rc::Rc;
34
35use dioxus::html::MountedData;
36use dioxus::prelude::*;
37
38use crate::core::{transition, GestureEffect, GestureEvent, GesturePhase, Rect};
39use crate::pointer::pointer_client;
40use crate::sortable::{ReorderMode, SortEvent};
41
42/// `(row, col)` of a flat index in a grid with `cols` columns.
43pub fn cell_of(index: usize, cols: usize) -> (usize, usize) {
44    let cols = cols.max(1);
45    (index / cols, index % cols)
46}
47
48/// Flat index of `(row, col)` in a grid with `cols` columns, or `None` if
49/// outside `len`.
50pub fn index_of(row: usize, col: usize, cols: usize, len: usize) -> Option<usize> {
51    let cols = cols.max(1);
52    if col >= cols {
53        return None;
54    }
55    let ix = row * cols + col;
56    (ix < len).then_some(ix)
57}
58
59/// A grid of tiles reordered (or swapped) by dragging.
60///
61/// Renders a `display: grid` wrapper with `cols` equal columns — pass your
62/// own `class`/`style` for gaps and sizing (your `grid-template-columns`
63/// wins if you set one, since spread attributes land after the default).
64/// The hovered tile gets `data-drop-target="true"`, the dragged one
65/// `data-dragging="true"`.
66#[component]
67pub fn SortableGrid(
68    /// Number of tiles.
69    len: usize,
70    /// Number of columns.
71    cols: usize,
72    /// Renders the tile at the given index.
73    render: Callback<usize, Element>,
74    /// Fired when the user drops a tile on another.
75    on_sort: EventHandler<SortEvent>,
76    /// Insert-and-reflow (gallery) or swap (dashboard). Default: insert.
77    #[props(default)]
78    mode: ReorderMode,
79    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
80) -> Element {
81    // `mode` only affects what the caller does with the SortEvent, but we
82    // surface it as a data attribute so styling can differ (e.g. swap
83    // targets often highlight the whole tile, insert targets show an edge).
84    let mode_str = match mode {
85        ReorderMode::Insert => "insert",
86        ReorderMode::Swap => "swap",
87    };
88    let mut drag_from = use_signal(|| None::<usize>);
89    let mut over = use_signal(|| None::<usize>);
90
91    // Touch/pen path: per-tile rects measured at drag start, hovered tile =
92    // the one containing the pointer.
93    let rects = use_signal(HashMap::<usize, Rect>::new);
94    let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
95    let mut gesture = use_signal(|| GesturePhase::Idle);
96    let mut step = move |event: GestureEvent| -> GestureEffect {
97        let (next, fx) = transition(*gesture.peek(), event, 8.0);
98        gesture.set(next);
99        fx
100    };
101    let mut feed = move |ix: usize, event: GestureEvent| match step(event) {
102        GestureEffect::Begin { .. } => {
103            drag_from.set(Some(ix));
104            over.set(None);
105            for (i, m) in mounteds.peek().clone() {
106                let mut rects = rects;
107                spawn(async move {
108                    if let Ok(r) = m.get_client_rect().await {
109                        rects.write().insert(
110                            i,
111                            Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
112                        );
113                    }
114                });
115            }
116        }
117        GestureEffect::Track { at } => {
118            let next = rects
119                .peek()
120                .iter()
121                .find(|(_, r)| r.contains(at))
122                .map(|(&i, _)| i)
123                .filter(|&i| Some(i) != *drag_from.peek())
124                .or(*over.peek());
125            if next != *over.peek() {
126                over.set(next);
127            }
128        }
129        GestureEffect::Drop { .. } => {
130            if let (Some(from), Some(to)) = (*drag_from.peek(), *over.peek()) {
131                if from != to {
132                    on_sort.call(SortEvent { from, to });
133                }
134            }
135            drag_from.set(None);
136            over.set(None);
137        }
138        GestureEffect::Abort => {
139            drag_from.set(None);
140            over.set(None);
141        }
142        GestureEffect::Tap | GestureEffect::None => {}
143    };
144
145    rsx! {
146        div {
147            style: "display: grid; grid-template-columns: repeat({cols}, 1fr);",
148            "data-mode": mode_str,
149            ..attributes,
150            for ix in 0..len {
151                div {
152                    key: "{ix}",
153                    draggable: true,
154                    style: "touch-action: none;",
155                    "data-dragging": drag_from() == Some(ix),
156                    "data-drop-target": over() == Some(ix) && drag_from() != Some(ix),
157                    onmounted: move |evt: Event<MountedData>| {
158                        let m: Rc<MountedData> = evt.data();
159                        let mut mounteds = mounteds;
160                        let mut rects = rects;
161                        mounteds.write().insert(ix, m.clone());
162                        spawn(async move {
163                            if let Ok(r) = m.get_client_rect().await {
164                                rects.write().insert(
165                                    ix,
166                                    Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
167                                );
168                            }
169                        });
170                    },
171                    onpointerdown: move |evt: PointerEvent| {
172                        if evt.pointer_type() == "mouse" || !evt.is_primary() { return; }
173                        feed(ix, GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
174                    },
175                    onpointermove: move |evt: PointerEvent| {
176                        feed(ix, GestureEvent::Move { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
177                    },
178                    onpointerup: move |evt: PointerEvent| {
179                        feed(ix, GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
180                    },
181                    onpointercancel: move |_| feed(ix, GestureEvent::Cancel),
182                    onlostpointercapture: move |_| feed(ix, GestureEvent::Cancel),
183                    ondragstart: move |evt: DragEvent| {
184                        evt.stop_propagation();
185                        let _ = evt.data_transfer().set_data("text/plain", "dioxus-dnd-grid");
186                        drag_from.set(Some(ix));
187                    },
188                    ondragover: move |evt: DragEvent| {
189                        if drag_from().is_some() {
190                            evt.prevent_default();
191                            if over() != Some(ix) {
192                                over.set(Some(ix));
193                            }
194                        }
195                    },
196                    ondrop: move |evt: DragEvent| {
197                        evt.prevent_default();
198                        evt.stop_propagation();
199                        if let Some(from) = drag_from() {
200                            if from != ix {
201                                on_sort.call(SortEvent { from, to: ix });
202                            }
203                        }
204                        drag_from.set(None);
205                        over.set(None);
206                    },
207                    ondragend: move |_| {
208                        drag_from.set(None);
209                        over.set(None);
210                    },
211                    {render.call(ix)}
212                }
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn grid_coordinates_round_trip() {
224        assert_eq!(cell_of(0, 4), (0, 0));
225        assert_eq!(cell_of(5, 4), (1, 1));
226        assert_eq!(index_of(1, 1, 4, 12), Some(5));
227        assert_eq!(index_of(0, 4, 4, 12), None); // col out of range
228        assert_eq!(index_of(3, 0, 4, 12), None); // beyond len
229        assert_eq!(cell_of(7, 0), (7, 0)); // degenerate cols clamps to 1
230    }
231}