Skip to main content

repose_core/
gesture.rs

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