Skip to main content

repose_core/
gesture.rs

1use crate::animation::{AnimatedValue, AnimationSpec};
2use crate::frame_clock::request_frame;
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5use web_time::Instant;
6
7/// Axis for drag/swipe gestures.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum DragOrientation {
10    Horizontal,
11    Vertical,
12}
13
14/// Configuration for a [`SwipeableState`].
15#[derive(Clone)]
16pub struct SwipeableConfig {
17    /// How the offset animates to the target anchor on release (default: spring_gentle).
18    pub animation_spec: AnimationSpec,
19    /// Fractional threshold between anchors for positional snapping (0.0–1.0).
20    /// 0.3 means the swipe must cross 30% of the gap to snap to the next anchor.
21    pub positional_threshold: f32,
22    /// Velocity threshold in px/s. If the release velocity exceeds this,
23    /// the offset snaps to the anchor in the direction of movement regardless
24    /// of positional threshold. Set to `f32::MAX` to disable velocity-based snapping.
25    pub velocity_threshold: f32,
26    /// Maximum offset past the first/last anchor (resistance). 0 = no limit.
27    pub max_overshoot: f32,
28}
29
30impl Default for SwipeableConfig {
31    fn default() -> Self {
32        Self {
33            animation_spec: AnimationSpec::spring_gentle(),
34            positional_threshold: 0.3,
35            velocity_threshold: f32::MAX,
36            max_overshoot: 0.0,
37        }
38    }
39}
40
41/// A generic swipeable state that tracks an animated offset and snaps to
42/// the nearest anchor point on release.
43///
44/// # Type Parameters
45///
46/// * `T` - The anchor value type (e.g. `DismissValue`, `usize` for pager pages).
47///
48/// # Usage
49///
50/// ```ignore
51/// use repose_core::*;
52///
53/// let state = SwipeableState::new(
54///     vec![(0.0, "start"), (-200.0, "end")],
55///     SwipeableConfig::default(),
56/// );
57///
58/// // Wire pointer events using on_pointer_down/move/up modifier callbacks:
59/// // state.on_pointer_down(e.position.x)
60/// // state.on_pointer_move(e.position.x)
61/// // state.on_pointer_up()
62///
63/// // Read offset each frame:
64/// let offset = state.offset();
65/// ```
66pub struct SwipeableState<T: Clone + PartialEq + 'static> {
67    anim: Rc<RefCell<AnimatedValue<f32>>>,
68    anchors: Rc<Vec<(f32, T)>>,
69    config: Rc<SwipeableConfig>,
70    drag_start: Rc<Cell<Option<f32>>>,
71    drag_base: Rc<Cell<f32>>,
72    last_move_time: Rc<Cell<Option<Instant>>>,
73    last_move_pos: Rc<Cell<Option<f32>>>,
74    release_velocity: Rc<Cell<f32>>,
75}
76
77impl<T: Clone + PartialEq + 'static> SwipeableState<T> {
78    /// Create a new swipeable state.
79    ///
80    /// `anchors` - pairs of `(offset_px, value)` sorted by offset. The first anchor
81    /// is the initial position. At least one anchor is required.
82    pub fn new(anchors: Vec<(f32, T)>, config: SwipeableConfig) -> Self {
83        assert!(
84            !anchors.is_empty(),
85            "SwipeableState requires at least one anchor"
86        );
87        let initial_offset = anchors[0].0;
88        Self {
89            anim: Rc::new(RefCell::new(AnimatedValue::new(
90                initial_offset,
91                config.animation_spec,
92            ))),
93            anchors: Rc::new(anchors),
94            config: Rc::new(config),
95            drag_start: Rc::new(Cell::new(None)),
96            drag_base: Rc::new(Cell::new(initial_offset)),
97            last_move_time: Rc::new(Cell::new(None)),
98            last_move_pos: Rc::new(Cell::new(None)),
99            release_velocity: Rc::new(Cell::new(0.0)),
100        }
101    }
102
103    /// Current animated offset in pixels. Call every frame that needs the value.
104    /// Requests a re-draw while the spring is still running.
105    pub fn offset(&self) -> f32 {
106        let mut anim = self.anim.borrow_mut();
107        if anim.update() {
108            request_frame();
109        }
110        *anim.get()
111    }
112
113    /// Resolve the nearest anchor value from the current offset.
114    pub fn current_value(&self) -> T {
115        let off = *self.anim.borrow().get();
116        self.nearest_anchor(off).map(|(_, v)| v).unwrap_or_else(|| {
117            self.anchors
118                .first()
119                .map(|(_, v)| v.clone())
120                .unwrap_or_else(|| panic!("SwipeableState has no anchors"))
121        })
122    }
123
124    /// Snap to an absolute offset instantly (used during active drag).
125    pub fn snap_to(&self, off: f32) {
126        let clamped = self.clamp_offset(off);
127        self.anim.borrow_mut().snap_to(clamped);
128        request_frame();
129    }
130
131    /// Animate to the offset corresponding to a target value.
132    pub fn animate_to(&self, value: &T) {
133        if let Some((offset, _)) = self.anchors.iter().find(|(_, v)| v == value) {
134            self.anim.borrow_mut().set_target(*offset);
135            request_frame();
136        }
137    }
138
139    /// Returns true if the spring is still running.
140    pub fn is_animating(&self) -> bool {
141        self.anim.borrow().is_animating()
142    }
143
144    /// Call from `on_pointer_down` with the position along the drag axis.
145    pub fn on_pointer_down(&self, axis_pos: f32) {
146        self.drag_start.set(Some(axis_pos));
147        self.drag_base.set(*self.anim.borrow().get());
148        self.last_move_time.set(Some(Instant::now()));
149        self.last_move_pos.set(Some(axis_pos));
150        self.release_velocity.set(0.0);
151    }
152
153    /// Call from `on_pointer_move` with the position along the drag axis.
154    pub fn on_pointer_move(&self, axis_pos: f32) {
155        if let Some(start) = self.drag_start.get() {
156            let now = Instant::now();
157            let delta = axis_pos - start;
158            let new_offset = self.drag_base.get() + delta;
159
160            // Compute velocity from the last frame
161            if let (Some(last_pos), Some(last_time)) =
162                (self.last_move_pos.get(), self.last_move_time.get())
163            {
164                let dt = (now - last_time).as_secs_f32().max(1.0 / 240.0);
165                let vel = (axis_pos - last_pos) / dt;
166                self.release_velocity.set(vel);
167            }
168
169            self.snap_to(new_offset);
170            self.last_move_pos.set(Some(axis_pos));
171            self.last_move_time.set(Some(now));
172        }
173    }
174
175    /// Call from `on_pointer_up`. Snaps to the appropriate anchor based on
176    /// position and velocity.
177    pub fn on_pointer_up(&self) {
178        self.drag_start.set(None);
179        let off = *self.anim.borrow().get();
180        let vel = self.release_velocity.get();
181        let config = &*self.config;
182
183        // If velocity exceeds threshold, snap in the direction of movement.
184        if vel.abs() > config.velocity_threshold {
185            let target_off = if vel < 0.0 {
186                self.next_anchor_down(off)
187            } else {
188                self.next_anchor_up(off)
189            };
190            self.anim.borrow_mut().set_target(target_off);
191            request_frame();
192            return;
193        }
194
195        // Otherwise, find the anchor pair surrounding the current offset and
196        // check the fractional threshold.
197        let target_off = self.snap_target(off, config.positional_threshold);
198        self.anim.borrow_mut().set_target(target_off);
199        request_frame();
200    }
201
202    /// Clamp offset to the anchor range (with optional overshoot).
203    fn clamp_offset(&self, off: f32) -> f32 {
204        let min = self.anchors.first().map(|(o, _)| *o).unwrap_or(0.0);
205        let max = self.anchors.last().map(|(o, _)| *o).unwrap_or(0.0);
206        let lo = if min <= max {
207            min - self.config.max_overshoot
208        } else {
209            max - self.config.max_overshoot
210        };
211        let hi = if min <= max {
212            max + self.config.max_overshoot
213        } else {
214            min + self.config.max_overshoot
215        };
216        off.clamp(lo.min(hi), lo.max(hi))
217    }
218
219    /// Find the anchor value nearest to a given offset.
220    fn nearest_anchor(&self, off: f32) -> Option<(f32, T)> {
221        self.anchors
222            .iter()
223            .min_by(|(a, _), (b, _)| (a - off).abs().partial_cmp(&(b - off).abs()).unwrap())
224            .map(|(o, v)| (*o, v.clone()))
225    }
226
227    /// Next anchor to the left (more negative).
228    fn next_anchor_down(&self, off: f32) -> f32 {
229        self.anchors
230            .iter()
231            .rev()
232            .find(|(a, _)| *a < off)
233            .map(|(o, _)| *o)
234            .unwrap_or_else(|| self.anchors.last().map(|(o, _)| *o).unwrap_or(off))
235    }
236
237    /// Next anchor to the right (more positive).
238    fn next_anchor_up(&self, off: f32) -> f32 {
239        self.anchors
240            .iter()
241            .find(|(a, _)| *a > off)
242            .map(|(o, _)| *o)
243            .unwrap_or_else(|| self.anchors.first().map(|(o, _)| *o).unwrap_or(off))
244    }
245
246    /// Determine the snap target based on positional fraction between anchors.
247    fn snap_target(&self, off: f32, threshold: f32) -> f32 {
248        // Find the surrounding anchor pair
249        let upper = self.anchors.iter().find(|(a, _)| *a >= off);
250        let lower = self.anchors.iter().rev().find(|(a, _)| *a <= off);
251
252        match (lower, upper) {
253            (Some((lo, _)), Some((hi, _))) if lo != hi => {
254                // Fraction between lo and hi
255                let range = hi - lo;
256                if range == 0.0 {
257                    *lo
258                } else {
259                    let fraction = (off - lo) / range;
260                    if fraction >= threshold { *hi } else { *lo }
261                }
262            }
263            (Some((lo, _)), _) => {
264                // Past the minimum anchor - snap to it
265                *lo
266            }
267            (_, Some((hi, _))) => {
268                // Past the maximum anchor - snap to it
269                *hi
270            }
271            _ => off,
272        }
273    }
274}
275
276impl<T: Clone + PartialEq + 'static> Clone for SwipeableState<T> {
277    fn clone(&self) -> Self {
278        Self {
279            anim: self.anim.clone(),
280            anchors: self.anchors.clone(),
281            config: self.config.clone(),
282            drag_start: self.drag_start.clone(),
283            drag_base: self.drag_base.clone(),
284            last_move_time: self.last_move_time.clone(),
285            last_move_pos: self.last_move_pos.clone(),
286            release_velocity: self.release_velocity.clone(),
287        }
288    }
289}