Skip to main content

cranpose_ui/
fling_animation.rs

1//! Fling animation driver for scroll containers.
2//!
3//! Drives decay animation using the runtime's frame callback system.
4
5use std::{
6    cell::{Cell, RefCell},
7    rc::Rc,
8};
9
10use cranpose_animation::{
11    ExponentialDecaySpec, FloatDecayAnimationSpec, IOS_DECELERATION_RATE_NORMAL,
12};
13use cranpose_core::{
14    RuntimeHandle,
15    internal::{FrameCallbackRegistration, FrameClock},
16};
17
18/// Minimum release velocity (in points/sec) for `UIScrollView` to start
19/// decelerating at all; below this a release just stops. Measured on the iOS
20/// 26.5 Simulator: releases consistently below ~260pt/s never decelerate,
21/// releases consistently above ~350pt/s always do, with a noisy transition
22/// between (real touch-velocity noise near a threshold, not a measurement
23/// artifact — see `ios_fling_measurement.rs`). 300 sits in that band.
24pub const MIN_FLING_VELOCITY: f32 = 300.0;
25
26const BOUNDARY_EPSILON: f32 = 0.5;
27
28fn schedule_next_frame<F, G>(
29    state: Rc<RefCell<Option<FlingAnimationState>>>,
30    frame_clock: FrameClock,
31    on_scroll: F,
32    on_end: G,
33) where
34    F: Fn(f32) -> f32 + 'static,
35    G: FnOnce() + 'static,
36{
37    let state_for_closure = state.clone();
38    let frame_clock_for_closure = frame_clock.clone();
39    let on_end = RefCell::new(Some(on_end));
40
41    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
42        let should_continue = {
43            let state_guard = state_for_closure.borrow();
44            let Some(anim_state) = state_guard.as_ref() else {
45                return;
46            };
47
48            if !anim_state.is_running.get() {
49                return;
50            }
51
52            let start_time = match anim_state.start_frame_time_nanos.get() {
53                Some(value) => value,
54                None => {
55                    anim_state
56                        .start_frame_time_nanos
57                        .set(Some(frame_time_nanos));
58                    frame_time_nanos
59                }
60            };
61
62            let play_time_nanos = frame_time_nanos.saturating_sub(start_time) as i64;
63
64            let new_value = anim_state.decay_spec.get_value_from_nanos(
65                play_time_nanos,
66                anim_state.initial_value,
67                anim_state.initial_velocity,
68            );
69
70            let last = anim_state.last_value.get();
71            let delta = new_value - last;
72            anim_state.last_value.set(new_value);
73            anim_state
74                .total_delta
75                .set(anim_state.total_delta.get() + delta);
76
77            let duration_nanos = anim_state
78                .decay_spec
79                .get_duration_nanos(anim_state.initial_value, anim_state.initial_velocity);
80
81            let current_velocity = anim_state.decay_spec.get_velocity_from_nanos(
82                play_time_nanos,
83                anim_state.initial_value,
84                anim_state.initial_velocity,
85            );
86
87            let is_finished = play_time_nanos >= duration_nanos
88                || current_velocity.abs() < anim_state.decay_spec.abs_velocity_threshold();
89
90            if is_finished {
91                anim_state.is_running.set(false);
92            }
93
94            let consumed = if delta.abs() > 0.001 {
95                on_scroll(delta)
96            } else {
97                0.0
98            };
99
100            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
101            if boundary_hit {
102                anim_state.is_running.set(false);
103            }
104
105            !is_finished && !boundary_hit
106        };
107
108        if should_continue {
109            if let Some(on_end_fn) = on_end.borrow_mut().take() {
110                schedule_next_frame(
111                    state_for_closure.clone(),
112                    frame_clock_for_closure.clone(),
113                    on_scroll,
114                    on_end_fn,
115                );
116            }
117        } else if let Some(end_fn) = on_end.borrow_mut().take() {
118            end_fn();
119        }
120    });
121
122    if let Some(anim_state) = state.borrow_mut().as_mut() {
123        anim_state.registration = Some(registration);
124    }
125}
126
127struct FlingAnimationState {
128    initial_value: f32,
129    last_value: Cell<f32>,
130    initial_velocity: f32,
131    start_frame_time_nanos: Cell<Option<u64>>,
132    decay_spec: ExponentialDecaySpec,
133    registration: Option<FrameCallbackRegistration>,
134    is_running: Cell<bool>,
135    total_delta: Cell<f32>,
136}
137
138/// Drives a fling (decay) animation on a scroll target.
139///
140/// Each frame, it calculates the scroll DELTA based on the decay curve
141/// and applies it to the scroll target via the provided callback.
142pub struct FlingAnimation {
143    state: Rc<RefCell<Option<FlingAnimationState>>>,
144    frame_clock: FrameClock,
145}
146
147impl FlingAnimation {
148    /// Creates a new fling animation driver.
149    pub fn new(runtime: RuntimeHandle) -> Self {
150        Self {
151            state: Rc::new(RefCell::new(None)),
152            frame_clock: runtime.frame_clock(),
153        }
154    }
155
156    /// Starts a fling animation with the given velocity.
157    ///
158    /// # Arguments
159    /// * `initial_value` - Current scroll position (used as reference)
160    /// * `velocity` - Initial velocity in px/sec (from VelocityTracker)
161    /// * `on_scroll` - Callback invoked each frame with scroll DELTA (not absolute position)
162    /// * `on_end` - Callback invoked when animation completes
163    pub fn start_fling<F, G>(&self, initial_value: f32, velocity: f32, on_scroll: F, on_end: G)
164    where
165        F: Fn(f32) -> f32 + 'static,
166        G: FnOnce() + 'static,
167    {
168        self.cancel();
169
170        if velocity.abs() < MIN_FLING_VELOCITY {
171            on_end();
172            return;
173        }
174
175        let decay_spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
176
177        let anim_state = FlingAnimationState {
178            initial_value,
179            last_value: Cell::new(initial_value),
180            initial_velocity: velocity,
181            start_frame_time_nanos: Cell::new(None),
182            decay_spec,
183            registration: None,
184            is_running: Cell::new(true),
185            total_delta: Cell::new(0.0),
186        };
187
188        *self.state.borrow_mut() = Some(anim_state);
189
190        schedule_next_frame(
191            self.state.clone(),
192            self.frame_clock.clone(),
193            on_scroll,
194            on_end,
195        );
196    }
197
198    pub fn cancel(&self) {
199        if let Some(state) = self.state.borrow_mut().take() {
200            state.is_running.set(false);
201            drop(state.registration);
202        }
203    }
204
205    /// Returns true if a fling animation is currently running.
206    pub fn is_running(&self) -> bool {
207        self.state
208            .borrow()
209            .as_ref()
210            .is_some_and(|s| s.is_running.get())
211    }
212}
213
214impl Clone for FlingAnimation {
215    fn clone(&self) -> Self {
216        Self {
217            state: self.state.clone(),
218            frame_clock: self.frame_clock.clone(),
219        }
220    }
221}
222
223/// Predicts where a fling starting at `initial_value` with `velocity` would
224/// naturally come to rest, using the same decay physics as
225/// [`FlingAnimation::start_fling`]. Settle policies remap this proposed rest
226/// position before the deceleration starts (the `UIScrollView
227/// targetContentOffset` analog).
228pub fn fling_rest_position(initial_value: f32, velocity: f32) -> f32 {
229    if velocity.abs() < MIN_FLING_VELOCITY {
230        return initial_value;
231    }
232    let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
233    spec.get_target_value(initial_value, velocity)
234}
235
236/// Damped-spring parameters for a [`SettleAnimation`]. `advance_spring`
237/// already generalizes over damping ratio, so the two settle use cases in
238/// this crate share one scheduler and differ only in these two numbers.
239#[derive(Debug, Clone, Copy)]
240pub struct SpringParams {
241    pub stiffness: f32,
242    pub damping_ratio: f32,
243}
244
245impl SpringParams {
246    /// Settle-policy remapping (e.g. the liquid nav bar's title-collapse
247    /// snap): critically damped, settling in ≈0.35s, the iOS large-title
248    /// snap feel.
249    pub const SETTLE_POLICY: Self = Self {
250        stiffness: 300.0,
251        damping_ratio: 1.0,
252    };
253
254    /// Overscroll bounce-back: the spring a scroll container's rubber-banded
255    /// offset relaxes through once the finger releases (or a fling's own
256    /// velocity decays) while still past the edge. Fit to a `UIScrollView`
257    /// bounce-back trace recorded on the iOS 26.5 Simulator (drag past the
258    /// top edge, hold to zero velocity, release: -177pt to rest in 667ms) —
259    /// see `ios_fling_measurement.rs`. Overdamped rather than critical: a
260    /// critically-damped ζ=1 spring at the same stiffness overshoots the
261    /// measured curve's shape by 15-20x; this (stiffness, damping_ratio) pair
262    /// was fit by least squares against the recorded trace, residual ≤2.9pt
263    /// (mean 0.79pt) against a 177pt swing.
264    pub const OVERSCROLL_BOUNCE: Self = Self {
265        stiffness: 1909.69,
266        damping_ratio: 2.71,
267    };
268}
269
270const SETTLE_REST_DISTANCE: f32 = 0.1;
271const SETTLE_REST_VELOCITY: f32 = 4.0;
272
273struct SettleAnimationState {
274    value: Cell<f32>,
275    velocity: Cell<f32>,
276    target: f32,
277    params: SpringParams,
278    last_frame_time_nanos: Cell<Option<u64>>,
279    registration: Option<FrameCallbackRegistration>,
280    is_running: Cell<bool>,
281}
282
283pub(crate) struct SettleEnd {
284    pub(crate) velocity: f32,
285    pub(crate) hit_boundary: bool,
286}
287
288/// Drives a damped spring toward a settle target on a scroll container,
289/// taking over the gesture's release velocity so a policy-adjusted rest
290/// position (or an overscroll bounce-back) still reads as one continuous
291/// deceleration.
292pub struct SettleAnimation {
293    state: Rc<RefCell<Option<SettleAnimationState>>>,
294    frame_clock: FrameClock,
295    params: SpringParams,
296}
297
298impl SettleAnimation {
299    pub fn new(runtime: RuntimeHandle, params: SpringParams) -> Self {
300        Self {
301            state: Rc::new(RefCell::new(None)),
302            frame_clock: runtime.frame_clock(),
303            params,
304        }
305    }
306
307    pub(crate) fn start_settle<F, G>(
308        &self,
309        initial_value: f32,
310        initial_velocity: f32,
311        target: f32,
312        on_scroll: F,
313        on_end: G,
314    ) where
315        F: Fn(f32) -> f32 + 'static,
316        G: FnOnce(SettleEnd) + 'static,
317    {
318        self.cancel();
319        *self.state.borrow_mut() = Some(SettleAnimationState {
320            value: Cell::new(initial_value),
321            velocity: Cell::new(initial_velocity),
322            target,
323            params: self.params,
324            last_frame_time_nanos: Cell::new(None),
325            registration: None,
326            is_running: Cell::new(true),
327        });
328        schedule_next_settle_frame(
329            self.state.clone(),
330            self.frame_clock.clone(),
331            on_scroll,
332            on_end,
333        );
334    }
335
336    pub fn cancel(&self) {
337        if let Some(state) = self.state.borrow_mut().take() {
338            state.is_running.set(false);
339            drop(state.registration);
340        }
341    }
342
343    pub fn is_running(&self) -> bool {
344        self.state
345            .borrow()
346            .as_ref()
347            .is_some_and(|s| s.is_running.get())
348    }
349}
350
351impl Clone for SettleAnimation {
352    fn clone(&self) -> Self {
353        Self {
354            state: self.state.clone(),
355            frame_clock: self.frame_clock.clone(),
356            params: self.params,
357        }
358    }
359}
360
361fn schedule_next_settle_frame<F, G>(
362    state: Rc<RefCell<Option<SettleAnimationState>>>,
363    frame_clock: FrameClock,
364    on_scroll: F,
365    on_end: G,
366) where
367    F: Fn(f32) -> f32 + 'static,
368    G: FnOnce(SettleEnd) + 'static,
369{
370    let state_for_closure = state.clone();
371    let frame_clock_for_closure = frame_clock.clone();
372    let on_end = RefCell::new(Some(on_end));
373    let hit_boundary = Cell::new(false);
374
375    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
376        let should_continue = {
377            let state_guard = state_for_closure.borrow();
378            let Some(anim_state) = state_guard.as_ref() else {
379                return;
380            };
381            if !anim_state.is_running.get() {
382                return;
383            }
384
385            let dt = match anim_state.last_frame_time_nanos.get() {
386                Some(last) => (frame_time_nanos.saturating_sub(last) as f32) / 1_000_000_000.0,
387                None => 0.0,
388            };
389            anim_state.last_frame_time_nanos.set(Some(frame_time_nanos));
390
391            let (mut next_value, next_velocity) = cranpose_animation::advance_spring(
392                anim_state.value.get(),
393                anim_state.velocity.get(),
394                anim_state.target,
395                anim_state.params.damping_ratio,
396                anim_state.params.stiffness,
397                dt.max(0.0),
398            );
399
400            let is_finished = (next_value - anim_state.target).abs() < SETTLE_REST_DISTANCE
401                && next_velocity.abs() < SETTLE_REST_VELOCITY;
402            if is_finished {
403                next_value = anim_state.target;
404                anim_state.is_running.set(false);
405            }
406
407            let delta = next_value - anim_state.value.get();
408            anim_state.value.set(next_value);
409            anim_state.velocity.set(next_velocity);
410
411            let consumed = if delta.abs() > 0.0001 {
412                on_scroll(delta)
413            } else {
414                delta
415            };
416            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
417            if boundary_hit {
418                anim_state.is_running.set(false);
419                hit_boundary.set(true);
420            }
421
422            !is_finished && !boundary_hit
423        };
424
425        if should_continue {
426            if let Some(on_end_fn) = on_end.borrow_mut().take() {
427                schedule_next_settle_frame(
428                    state_for_closure.clone(),
429                    frame_clock_for_closure.clone(),
430                    on_scroll,
431                    on_end_fn,
432                );
433            }
434        } else if let Some(end_fn) = on_end.borrow_mut().take() {
435            let state_guard = state_for_closure.borrow();
436            let velocity = state_guard
437                .as_ref()
438                .map_or(0.0, |anim_state| anim_state.velocity.get());
439            end_fn(SettleEnd {
440                velocity,
441                hit_boundary: hit_boundary.get(),
442            });
443        }
444    });
445
446    if let Some(anim_state) = state.borrow_mut().as_mut() {
447        anim_state.registration = Some(registration);
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use std::{cell::Cell, rc::Rc, sync::Arc};
454
455    use cranpose_core::{DefaultScheduler, Runtime};
456
457    use super::*;
458
459    #[test]
460    fn test_min_velocity_threshold() {
461        assert_eq!(MIN_FLING_VELOCITY, 300.0);
462    }
463
464    #[test]
465    fn settle_animation_springs_to_target_and_ends() {
466        let runtime = Runtime::new(Arc::new(DefaultScheduler));
467        let handle = runtime.handle();
468        let settle = SettleAnimation::new(handle.clone(), SpringParams::SETTLE_POLICY);
469        let position = Rc::new(Cell::new(30.0f32));
470        let ended = Rc::new(Cell::new(false));
471        let position_for_scroll = Rc::clone(&position);
472        let ended_for_end = Rc::clone(&ended);
473        settle.start_settle(
474            30.0,
475            0.0,
476            52.0,
477            move |delta| {
478                position_for_scroll.set(position_for_scroll.get() + delta);
479                delta
480            },
481            move |_| ended_for_end.set(true),
482        );
483        for frame in 0..240u64 {
484            handle.drain_frame_callbacks(frame * 16_000_000);
485            if ended.get() {
486                break;
487            }
488        }
489        assert!(ended.get(), "settle animation must finish");
490        assert!(
491            (position.get() - 52.0).abs() < 0.2,
492            "settle must land on the target, got {}",
493            position.get()
494        );
495    }
496
497    #[test]
498    fn settle_reports_reduced_velocity_when_crossing_boundary() {
499        let runtime = Runtime::new(Arc::new(DefaultScheduler));
500        let handle = runtime.handle();
501        let settle = SettleAnimation::new(handle.clone(), SpringParams::SETTLE_POLICY);
502        let position = Rc::new(Cell::new(30.0f32));
503        let ended = Rc::new(Cell::new(None::<(f32, bool)>));
504        let position_for_scroll = Rc::clone(&position);
505        let ended_for_end = Rc::clone(&ended);
506        settle.start_settle(
507            30.0,
508            -1_200.0,
509            0.0,
510            move |delta| {
511                let previous = position_for_scroll.get();
512                let next = (previous + delta).max(0.0);
513                position_for_scroll.set(next);
514                next - previous
515            },
516            move |end| ended_for_end.set(Some((end.velocity, end.hit_boundary))),
517        );
518        for frame in 0..240u64 {
519            handle.drain_frame_callbacks(frame * 16_000_000);
520            if ended.get().is_some() {
521                break;
522            }
523        }
524
525        let (velocity, hit_boundary) = ended.get().expect("settle must finish");
526        assert!(hit_boundary);
527        assert!(velocity < 0.0 && velocity.abs() < 1_200.0);
528        assert_eq!(position.get(), 0.0);
529    }
530
531    #[test]
532    fn fling_rest_position_is_beyond_start_in_fling_direction() {
533        let rest = fling_rest_position(100.0, 900.0);
534        assert!(rest > 100.0, "rest {rest} must be past the start");
535        assert_eq!(fling_rest_position(100.0, 0.0), 100.0);
536    }
537
538    #[test]
539    fn test_on_end_called_when_boundary_hit() {
540        let runtime = Runtime::new(Arc::new(DefaultScheduler));
541        let handle = runtime.handle();
542        let fling = FlingAnimation::new(handle.clone());
543        let finished = Rc::new(Cell::new(false));
544        let finished_flag = Rc::clone(&finished);
545
546        fling.start_fling(0.0, 10_000.0, |_| 0.0, move || finished_flag.set(true));
547
548        handle.drain_frame_callbacks(0);
549        handle.drain_frame_callbacks(16_000_000);
550
551        assert!(finished.get());
552    }
553}