Skip to main content

cranpose_ui/
draggable.rs

1//! General drag state, for controls that are dragged but do not scroll.
2//!
3//! A scrollbar thumb, a bottom sheet, a resizable split, a swipe-away card and
4//! a knob all want the same thing from the framework: the drag discipline the
5//! scroll containers already have — touch slop before a drag starts, axis
6//! locking so a mostly-vertical drag does not steal a horizontal gesture,
7//! yielding to whoever already consumed the event, and an observable "is this
8//! being dragged right now" for the visuals to react to.
9//!
10//! [`DraggableState`] carries that, and `Modifier::draggable` runs the same
11//! gesture pipeline the scroll modifiers run, so a dragged control and a
12//! scrolled list respond to a finger identically.
13//!
14//! ```text
15//! let offset = rememberMutableStateOf(|| 0.0_f32);
16//! let drag = rememberDraggableState(move |delta| offset.set(offset.get() + delta));
17//! Box(Modifier::empty().size_points(64.0, 64.0).draggable(Axis::Horizontal, drag.clone()), …);
18//! ```
19//!
20//! Deltas arrive in the same logical pixels layout uses, positive along the
21//! axis (right for `Axis::Horizontal`, down for `Axis::Vertical`).
22
23#![allow(non_snake_case)]
24
25use std::{
26    cell::{Cell, RefCell},
27    rc::Rc,
28};
29
30use cranpose_core::{MutableState, State, remember};
31
32/// What a [`DraggableState`] hands each drag delta to.
33pub type DragDeltaHandler = Rc<dyn Fn(f32)>;
34
35struct DraggableStateInner {
36    on_delta: RefCell<DragDeltaHandler>,
37    dragging: MutableState<bool>,
38    /// Everything this state has been dragged by, in logical pixels. The
39    /// gesture pipeline reads it to reason about direction; a caller that keeps
40    /// its own position never has to.
41    offset: Cell<f32>,
42}
43
44/// Drag position and progress for one control.
45///
46/// Cloning shares the state, so a scope can hold a handle and the modifier can
47/// hold another without either owning the truth.
48#[derive(Clone)]
49pub struct DraggableState {
50    inner: Rc<DraggableStateInner>,
51}
52
53impl PartialEq for DraggableState {
54    /// Two handles are equal when they drive the same control — identity, not
55    /// value, so a composable that takes one skips on recomposition.
56    fn eq(&self, other: &Self) -> bool {
57        Rc::ptr_eq(&self.inner, &other.inner)
58    }
59}
60
61impl DraggableState {
62    /// Creates a drag state delivering deltas to `on_delta`.
63    ///
64    /// Prefer [`rememberDraggableState`] inside a composition; this is for
65    /// callers that own the state themselves.
66    pub fn new(on_delta: impl Fn(f32) + 'static) -> Self {
67        let runtime = cranpose_core::current_runtime_handle()
68            .expect("DraggableState::new requires an active runtime");
69        Self {
70            inner: Rc::new(DraggableStateInner {
71                on_delta: RefCell::new(Rc::new(on_delta)),
72                dragging: MutableState::with_runtime(false, runtime),
73                offset: Cell::new(0.0),
74            }),
75        }
76    }
77
78    /// Replaces the delta handler.
79    ///
80    /// A composition calls this every recomposition so the handler closes over
81    /// the current values rather than the ones the first composition captured.
82    pub fn update_handler(&self, on_delta: impl Fn(f32) + 'static) {
83        *self.inner.on_delta.borrow_mut() = Rc::new(on_delta);
84    }
85
86    /// Whether a drag is in flight. Reactive: a composable that reads it
87    /// recomposes when the drag starts and when it ends, and not per frame in
88    /// between.
89    pub fn is_dragging(&self) -> bool {
90        self.inner.dragging.value()
91    }
92
93    /// [`is_dragging`](Self::is_dragging) as a state a scope can hand around.
94    pub fn dragging(&self) -> State<bool> {
95        self.inner.dragging.as_state()
96    }
97
98    /// Everything this state has been dragged by since it was created.
99    pub fn offset(&self) -> f32 {
100        self.inner.offset.get()
101    }
102
103    /// Drags by `delta` as though a finger had moved that far.
104    ///
105    /// This is how a control is driven from outside a gesture — a keyboard
106    /// arrow, a test, an animation — through exactly the path a finger takes.
107    pub fn drag_by(&self, delta: f32) {
108        if !delta.is_finite() || delta == 0.0 {
109            return;
110        }
111        self.inner.offset.set(self.inner.offset.get() + delta);
112        let handler = Rc::clone(&self.inner.on_delta.borrow());
113        handler(delta);
114    }
115
116    /// A stable identity for this state, used to key the gesture that drives
117    /// it so a recomposition reuses the running gesture instead of restarting
118    /// it mid-drag.
119    pub(crate) fn identity(&self) -> usize {
120        Rc::as_ptr(&self.inner) as usize
121    }
122
123    pub(crate) fn set_dragging(&self, dragging: bool) {
124        if self.inner.dragging.get_non_reactive() != dragging {
125            self.inner.dragging.set(dragging);
126        }
127    }
128}
129
130/// Remembers a [`DraggableState`] for this composition, keeping its delta
131/// handler current across recompositions.
132#[track_caller]
133pub fn rememberDraggableState(on_delta: impl Fn(f32) + 'static) -> DraggableState {
134    let state = remember(|| DraggableState::new(|_| {})).with(|state| state.clone());
135    state.update_handler(on_delta);
136    state
137}
138
139#[cfg(test)]
140#[path = "tests/draggable_tests.rs"]
141mod draggable_tests;
142
143#[cfg(test)]
144mod tests {
145    use std::sync::Arc;
146
147    use cranpose_core::{DefaultScheduler, Runtime};
148
149    use super::*;
150
151    fn with_runtime<T>(body: impl FnOnce() -> T) -> T {
152        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
153        body()
154    }
155
156    /// A remembered handle has to survive recomposition -- a control given a
157    /// fresh state each pass would forget it was being dragged -- while the
158    /// closure it delivers to has to be the latest pass's, or a delta lands on
159    /// a value the first pass captured.
160    #[test]
161    fn a_remembered_drag_state_survives_recomposition_and_takes_the_new_handler() {
162        use cranpose_core::{Composition, MemoryApplier, location_key};
163
164        let mut composition = Composition::new(MemoryApplier::new());
165        let seen = Rc::new(RefCell::new(Vec::new()));
166        let handles: Rc<RefCell<Vec<DraggableState>>> = Rc::new(RefCell::new(Vec::new()));
167        let pass = Rc::new(Cell::new(0usize));
168
169        let key = location_key(file!(), line!(), column!());
170        for _ in 0..2 {
171            let seen = Rc::clone(&seen);
172            let handles = Rc::clone(&handles);
173            let pass = Rc::clone(&pass);
174            let mut render = move || {
175                let tag = pass.get();
176                pass.set(tag + 1);
177                let recorder = Rc::clone(&seen);
178                let state = rememberDraggableState(move |delta| {
179                    recorder.borrow_mut().push((tag, delta));
180                });
181                handles.borrow_mut().push(state);
182            };
183            composition.render(key, &mut render).expect("render");
184        }
185
186        let handles = handles.borrow();
187        assert_eq!(handles.len(), 2);
188        assert!(
189            handles[0] == handles[1],
190            "a remembered state must survive the slot"
191        );
192
193        handles[1].drag_by(3.0);
194        assert_eq!(
195            *seen.borrow(),
196            vec![(1, 3.0)],
197            "the delta must reach the handler the latest pass supplied"
198        );
199    }
200
201    #[test]
202    fn a_drag_delta_reaches_the_current_handler() {
203        with_runtime(|| {
204            let seen = Rc::new(RefCell::new(Vec::new()));
205            let recorder = Rc::clone(&seen);
206            let state = DraggableState::new(move |delta| recorder.borrow_mut().push(delta));
207            state.drag_by(4.0);
208            state.drag_by(-1.5);
209            assert_eq!(seen.borrow().as_slice(), [4.0, -1.5]);
210            assert_eq!(state.offset(), 2.5);
211        });
212    }
213
214    #[test]
215    fn replacing_the_handler_redirects_later_deltas() {
216        with_runtime(|| {
217            let first = Rc::new(Cell::new(0.0));
218            let second = Rc::new(Cell::new(0.0));
219            let recorder = Rc::clone(&first);
220            let state = DraggableState::new(move |delta| recorder.set(recorder.get() + delta));
221            state.drag_by(2.0);
222            let recorder = Rc::clone(&second);
223            state.update_handler(move |delta| recorder.set(recorder.get() + delta));
224            state.drag_by(3.0);
225            assert_eq!(first.get(), 2.0);
226            assert_eq!(second.get(), 3.0);
227        });
228    }
229
230    #[test]
231    fn a_delta_that_is_not_a_movement_is_not_delivered() {
232        with_runtime(|| {
233            let count = Rc::new(Cell::new(0u32));
234            let recorder = Rc::clone(&count);
235            let state = DraggableState::new(move |_| recorder.set(recorder.get() + 1));
236            state.drag_by(0.0);
237            state.drag_by(f32::NAN);
238            assert_eq!(count.get(), 0);
239            assert_eq!(state.offset(), 0.0);
240        });
241    }
242
243    #[test]
244    fn dragging_is_observable_and_clones_share_it() {
245        with_runtime(|| {
246            let state = DraggableState::new(|_| {});
247            let handle = state.clone();
248            assert!(!handle.is_dragging());
249            state.set_dragging(true);
250            assert!(handle.is_dragging());
251            state.set_dragging(false);
252            assert!(!handle.is_dragging());
253            assert!(state == handle);
254        });
255    }
256}