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