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#![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<Rc<dyn Fn(f32) -> f32>>,
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        Self::new_consuming(move |delta| {
63            on_delta(delta);
64            delta
65        })
66    }
67
68    fn new_consuming(on_delta: impl Fn(f32) -> f32 + 'static) -> Self {
69        let runtime = cranpose_core::current_runtime_handle()
70            .expect("DraggableState::new requires an active runtime");
71        Self {
72            inner: Rc::new(DraggableStateInner {
73                on_delta: RefCell::new(Rc::new(on_delta)),
74                dragging: MutableState::with_runtime(false, runtime),
75                offset: Cell::new(0.0),
76            }),
77        }
78    }
79
80    /// Replaces the delta handler.
81    ///
82    /// A composition calls this every recomposition so the handler closes over
83    /// the current values rather than the ones the first composition captured.
84    pub fn update_handler(&self, on_delta: impl Fn(f32) + 'static) {
85        self.update_consuming_handler(move |delta| {
86            on_delta(delta);
87            delta
88        });
89    }
90
91    fn update_consuming_handler(&self, on_delta: impl Fn(f32) -> f32 + 'static) {
92        *self.inner.on_delta.borrow_mut() = Rc::new(on_delta);
93    }
94
95    /// Whether a drag is in flight. Reactive: a composable that reads it
96    /// recomposes when the drag starts and when it ends, and not per frame in
97    /// between.
98    pub fn is_dragging(&self) -> bool {
99        self.inner.dragging.value()
100    }
101
102    /// [`is_dragging`](Self::is_dragging) as a state a scope can hand around.
103    pub fn dragging(&self) -> State<bool> {
104        self.inner.dragging.as_state()
105    }
106
107    /// Everything this state has been dragged by since it was created.
108    pub fn offset(&self) -> f32 {
109        self.inner.offset.get()
110    }
111
112    /// Drags by `delta` as though a finger had moved that far.
113    ///
114    /// This is how a control is driven from outside a gesture — a keyboard
115    /// arrow, a test, an animation — through exactly the path a finger takes.
116    pub fn drag_by(&self, delta: f32) {
117        self.dispatch_delta(delta);
118    }
119
120    fn dispatch_delta(&self, delta: f32) -> f32 {
121        if !delta.is_finite() || delta == 0.0 {
122            return 0.0;
123        }
124        let handler = Rc::clone(&self.inner.on_delta.borrow());
125        let consumed = handler(delta);
126        self.inner.offset.set(self.inner.offset.get() + consumed);
127        consumed
128    }
129
130    pub(crate) fn identity(&self) -> usize {
131        Rc::as_ptr(&self.inner) as usize
132    }
133
134    pub(crate) fn set_dragging(&self, dragging: bool) {
135        if self.inner.dragging.get_non_reactive() != dragging {
136            self.inner.dragging.set(dragging);
137        }
138    }
139}
140
141/// Remembers a [`DraggableState`] for this composition, keeping its delta
142/// handler current across recompositions.
143#[track_caller]
144pub fn rememberDraggableState(on_delta: impl Fn(f32) + 'static) -> DraggableState {
145    let state = remember(|| DraggableState::new(|_| {})).with(Clone::clone);
146    state.update_handler(on_delta);
147    state
148}
149
150/// Scroll input for content that supplies its own placement, such as a wheel.
151///
152/// The handler receives logical pixel deltas, positive down or right, and
153/// returns the amount consumed. A partial consumption stops a fling at a
154/// boundary. `Modifier::scrollable` supplies drag, wheel and fling gestures
155/// without applying a linear layout translation.
156#[derive(Clone, PartialEq)]
157pub struct ScrollableState {
158    drag: DraggableState,
159}
160
161impl ScrollableState {
162    /// Creates a scroll state with a delta consumption handler.
163    pub fn new(on_delta: impl Fn(f32) -> f32 + 'static) -> Self {
164        Self {
165            drag: DraggableState::new_consuming(on_delta),
166        }
167    }
168
169    /// Applies a scroll delta and returns the amount consumed by the content.
170    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
171        self.drag.dispatch_delta(delta)
172    }
173
174    /// Whether a pointer is currently dragging this content.
175    pub fn is_dragging(&self) -> bool {
176        self.drag.is_dragging()
177    }
178
179    pub(crate) fn identity(&self) -> usize {
180        self.drag.identity()
181    }
182
183    pub(crate) fn offset(&self) -> f32 {
184        self.drag.offset()
185    }
186
187    pub(crate) fn set_dragging(&self, dragging: bool) {
188        self.drag.set_dragging(dragging);
189    }
190}
191
192/// Remembers scroll input state and updates its consumption handler.
193#[track_caller]
194pub fn rememberScrollableState(on_delta: impl Fn(f32) -> f32 + 'static) -> ScrollableState {
195    let state = remember(|| ScrollableState::new(|_| 0.0)).with(Clone::clone);
196    state.drag.update_consuming_handler(on_delta);
197    state
198}
199
200#[cfg(test)]
201#[path = "tests/draggable_tests.rs"]
202mod draggable_tests;
203
204#[cfg(test)]
205#[path = "tests/scrollable_tests.rs"]
206mod scrollable_tests;
207
208#[cfg(test)]
209#[path = "tests/draggable_drag_state_tests.rs"]
210mod tests;