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    /// Stops a running fling at once and drops its frame callback. `on_end` does not run.
199    pub fn cancel(&self) {
200        if let Some(state) = self.state.borrow_mut().take() {
201            state.is_running.set(false);
202            drop(state.registration);
203        }
204    }
205
206    /// Returns true if a fling animation is currently running.
207    pub fn is_running(&self) -> bool {
208        self.state
209            .borrow()
210            .as_ref()
211            .is_some_and(|s| s.is_running.get())
212    }
213}
214
215impl Clone for FlingAnimation {
216    fn clone(&self) -> Self {
217        Self {
218            state: self.state.clone(),
219            frame_clock: self.frame_clock.clone(),
220        }
221    }
222}
223
224/// Predicts where a fling starting at `initial_value` with `velocity` would
225/// naturally come to rest, using the same decay physics as
226/// [`FlingAnimation::start_fling`]. Settle policies remap this proposed rest
227/// position before the deceleration starts (the `UIScrollView
228/// targetContentOffset` analog).
229pub fn fling_rest_position(initial_value: f32, velocity: f32) -> f32 {
230    if velocity.abs() < MIN_FLING_VELOCITY {
231        return initial_value;
232    }
233    let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
234    spec.get_target_value(initial_value, velocity)
235}
236
237/// Damped-spring parameters for a [`SettleAnimation`]. `advance_spring_displacement`
238/// already generalizes over damping ratio, so the two settle use cases in
239/// this crate share one scheduler and differ only in these two numbers.
240#[derive(Debug, Clone, Copy)]
241pub struct SpringParams {
242    /// Spring stiffness with a mass of 1. A higher value pulls toward the target faster.
243    pub stiffness: f32,
244    /// Damping ratio. Below 1 the spring overshoots the target, at 1 it settles without overshoot,
245    /// above 1 it settles more slowly.
246    pub damping_ratio: f32,
247}
248
249impl SpringParams {
250    /// Settle-policy remapping (e.g. the liquid nav bar's title-collapse
251    /// snap): critically damped, settling in ≈0.35s, the iOS large-title
252    /// snap feel.
253    pub const SETTLE_POLICY: Self = Self {
254        stiffness: 300.0,
255        damping_ratio: 1.0,
256    };
257
258    /// Overscroll bounce-back: the spring a scroll container's rubber-banded
259    /// offset relaxes through once the finger releases (or a fling's own
260    /// velocity decays) while still past the edge. Fit to a `UIScrollView`
261    /// bounce-back trace recorded on the iOS 26.5 Simulator (drag past the
262    /// top edge, hold to zero velocity, release: -177pt to rest in 667ms) —
263    /// see `ios_fling_measurement.rs`. Overdamped rather than critical: a
264    /// critically-damped ζ=1 spring at the same stiffness overshoots the
265    /// measured curve's shape by 15-20x; this (stiffness, damping_ratio) pair
266    /// was fit by least squares against the recorded trace, residual ≤2.9pt
267    /// (mean 0.79pt) against a 177pt swing.
268    pub const OVERSCROLL_BOUNCE: Self = Self {
269        stiffness: 1909.69,
270        damping_ratio: 2.71,
271    };
272}
273
274const SETTLE_REST_DISTANCE: f32 = 0.1;
275const SETTLE_REST_VELOCITY: f32 = 4.0;
276
277struct SettleAnimationState {
278    /// The distance left to the target, held apart from the scroll offset so
279    /// a settle far down a list keeps the fractions the offset cannot.
280    displacement: Cell<f32>,
281    velocity: Cell<f32>,
282    params: SpringParams,
283    last_frame_time_nanos: Cell<Option<u64>>,
284    registration: Option<FrameCallbackRegistration>,
285    is_running: Cell<bool>,
286}
287
288pub(crate) struct SettleEnd {
289    pub(crate) velocity: f32,
290    pub(crate) hit_boundary: bool,
291}
292
293/// Drives a damped spring toward a settle target on a scroll container,
294/// taking over the gesture's release velocity so a policy-adjusted rest
295/// position (or an overscroll bounce-back) still reads as one continuous
296/// deceleration.
297pub struct SettleAnimation {
298    state: Rc<RefCell<Option<SettleAnimationState>>>,
299    frame_clock: FrameClock,
300    params: SpringParams,
301}
302
303impl SettleAnimation {
304    /// Creates a settle animation that runs on the frame clock of `runtime` with the spring
305    /// `params`.
306    pub fn new(runtime: RuntimeHandle, params: SpringParams) -> Self {
307        Self {
308            state: Rc::new(RefCell::new(None)),
309            frame_clock: runtime.frame_clock(),
310            params,
311        }
312    }
313
314    pub(crate) fn start_settle<F, G>(
315        &self,
316        initial_value: f32,
317        initial_velocity: f32,
318        target: f32,
319        on_scroll: F,
320        on_end: G,
321    ) where
322        F: Fn(f32) -> f32 + 'static,
323        G: FnOnce(SettleEnd) + 'static,
324    {
325        self.cancel();
326        *self.state.borrow_mut() = Some(SettleAnimationState {
327            displacement: Cell::new(initial_value - target),
328            velocity: Cell::new(initial_velocity),
329            params: self.params,
330            last_frame_time_nanos: Cell::new(None),
331            registration: None,
332            is_running: Cell::new(true),
333        });
334        schedule_next_settle_frame(
335            self.state.clone(),
336            self.frame_clock.clone(),
337            on_scroll,
338            on_end,
339        );
340    }
341
342    /// Stops a running settle at once and drops its frame callback. The end callback does not run.
343    pub fn cancel(&self) {
344        if let Some(state) = self.state.borrow_mut().take() {
345            state.is_running.set(false);
346            drop(state.registration);
347        }
348    }
349
350    /// Returns true while a settle animation is running.
351    pub fn is_running(&self) -> bool {
352        self.state
353            .borrow()
354            .as_ref()
355            .is_some_and(|s| s.is_running.get())
356    }
357}
358
359impl Clone for SettleAnimation {
360    fn clone(&self) -> Self {
361        Self {
362            state: self.state.clone(),
363            frame_clock: self.frame_clock.clone(),
364            params: self.params,
365        }
366    }
367}
368
369fn schedule_next_settle_frame<F, G>(
370    state: Rc<RefCell<Option<SettleAnimationState>>>,
371    frame_clock: FrameClock,
372    on_scroll: F,
373    on_end: G,
374) where
375    F: Fn(f32) -> f32 + 'static,
376    G: FnOnce(SettleEnd) + 'static,
377{
378    let state_for_closure = state.clone();
379    let frame_clock_for_closure = frame_clock.clone();
380    let on_end = RefCell::new(Some(on_end));
381    let hit_boundary = Cell::new(false);
382
383    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
384        let should_continue = {
385            let state_guard = state_for_closure.borrow();
386            let Some(anim_state) = state_guard.as_ref() else {
387                return;
388            };
389            if !anim_state.is_running.get() {
390                return;
391            }
392
393            let dt = match anim_state.last_frame_time_nanos.get() {
394                Some(last) => (frame_time_nanos.saturating_sub(last) as f32) / 1_000_000_000.0,
395                None => 0.0,
396            };
397            anim_state.last_frame_time_nanos.set(Some(frame_time_nanos));
398
399            let displacement = anim_state.displacement.get();
400            let (mut next_displacement, next_velocity) =
401                cranpose_animation::advance_spring_displacement(
402                    displacement,
403                    anim_state.velocity.get(),
404                    anim_state.params.damping_ratio,
405                    anim_state.params.stiffness,
406                    dt.max(0.0),
407                );
408
409            let is_finished = next_displacement.abs() < SETTLE_REST_DISTANCE
410                && next_velocity.abs() < SETTLE_REST_VELOCITY;
411            if is_finished {
412                next_displacement = 0.0;
413                anim_state.is_running.set(false);
414            }
415
416            let delta = next_displacement - displacement;
417            anim_state.displacement.set(next_displacement);
418            anim_state.velocity.set(next_velocity);
419
420            let consumed = if delta.abs() > 0.0001 {
421                on_scroll(delta)
422            } else {
423                delta
424            };
425            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
426            if boundary_hit {
427                anim_state.is_running.set(false);
428                hit_boundary.set(true);
429            }
430
431            !is_finished && !boundary_hit
432        };
433
434        if should_continue {
435            if let Some(on_end_fn) = on_end.borrow_mut().take() {
436                schedule_next_settle_frame(
437                    state_for_closure.clone(),
438                    frame_clock_for_closure.clone(),
439                    on_scroll,
440                    on_end_fn,
441                );
442            }
443        } else if let Some(end_fn) = on_end.borrow_mut().take() {
444            let state_guard = state_for_closure.borrow();
445            let velocity = state_guard
446                .as_ref()
447                .map_or(0.0, |anim_state| anim_state.velocity.get());
448            end_fn(SettleEnd {
449                velocity,
450                hit_boundary: hit_boundary.get(),
451            });
452        }
453    });
454
455    if let Some(anim_state) = state.borrow_mut().as_mut() {
456        anim_state.registration = Some(registration);
457    }
458}
459
460#[cfg(test)]
461#[path = "tests/fling_animation_tests.rs"]
462mod tests;