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