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#![expect(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 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 fn eq(&self, other: &Self) -> bool {
52 Rc::ptr_eq(&self.inner, &other.inner)
53 }
54}
55
56impl DraggableState {
57 /// Creates a drag state delivering deltas to `on_delta`.
58 ///
59 /// Prefer [`rememberDraggableState`] inside a composition; this is for
60 /// callers that own the state themselves.
61 pub fn new(on_delta: impl Fn(f32) + 'static) -> Self {
62 let runtime = cranpose_core::current_runtime_handle()
63 .expect("DraggableState::new requires an active runtime");
64 Self {
65 inner: Rc::new(DraggableStateInner {
66 on_delta: RefCell::new(Rc::new(on_delta)),
67 dragging: MutableState::with_runtime(false, runtime),
68 offset: Cell::new(0.0),
69 }),
70 }
71 }
72
73 /// Replaces the delta handler.
74 ///
75 /// A composition calls this every recomposition so the handler closes over
76 /// the current values rather than the ones the first composition captured.
77 pub fn update_handler(&self, on_delta: impl Fn(f32) + 'static) {
78 *self.inner.on_delta.borrow_mut() = Rc::new(on_delta);
79 }
80
81 /// Whether a drag is in flight. Reactive: a composable that reads it
82 /// recomposes when the drag starts and when it ends, and not per frame in
83 /// between.
84 pub fn is_dragging(&self) -> bool {
85 self.inner.dragging.value()
86 }
87
88 /// [`is_dragging`](Self::is_dragging) as a state a scope can hand around.
89 pub fn dragging(&self) -> State<bool> {
90 self.inner.dragging.as_state()
91 }
92
93 /// Everything this state has been dragged by since it was created.
94 pub fn offset(&self) -> f32 {
95 self.inner.offset.get()
96 }
97
98 /// Drags by `delta` as though a finger had moved that far.
99 ///
100 /// This is how a control is driven from outside a gesture — a keyboard
101 /// arrow, a test, an animation — through exactly the path a finger takes.
102 pub fn drag_by(&self, delta: f32) {
103 if !delta.is_finite() || delta == 0.0 {
104 return;
105 }
106 self.inner.offset.set(self.inner.offset.get() + delta);
107 let handler = Rc::clone(&self.inner.on_delta.borrow());
108 handler(delta);
109 }
110
111 pub(crate) fn identity(&self) -> usize {
112 Rc::as_ptr(&self.inner) as usize
113 }
114
115 pub(crate) fn set_dragging(&self, dragging: bool) {
116 if self.inner.dragging.get_non_reactive() != dragging {
117 self.inner.dragging.set(dragging);
118 }
119 }
120}
121
122/// Remembers a [`DraggableState`] for this composition, keeping its delta
123/// handler current across recompositions.
124#[track_caller]
125pub fn rememberDraggableState(on_delta: impl Fn(f32) + 'static) -> DraggableState {
126 let state = remember(|| DraggableState::new(|_| {})).with(Clone::clone);
127 state.update_handler(on_delta);
128 state
129}
130
131#[cfg(test)]
132#[path = "tests/draggable_tests.rs"]
133mod draggable_tests;
134
135#[cfg(test)]
136#[path = "tests/draggable_drag_state_tests.rs"]
137mod tests;