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_displacement`
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    /// The distance left to the target, held apart from the scroll offset so
275    /// a settle far down a list keeps the fractions the offset cannot.
276    displacement: Cell<f32>,
277    velocity: Cell<f32>,
278    params: SpringParams,
279    last_frame_time_nanos: Cell<Option<u64>>,
280    registration: Option<FrameCallbackRegistration>,
281    is_running: Cell<bool>,
282}
283
284pub(crate) struct SettleEnd {
285    pub(crate) velocity: f32,
286    pub(crate) hit_boundary: bool,
287}
288
289/// Drives a damped spring toward a settle target on a scroll container,
290/// taking over the gesture's release velocity so a policy-adjusted rest
291/// position (or an overscroll bounce-back) still reads as one continuous
292/// deceleration.
293pub struct SettleAnimation {
294    state: Rc<RefCell<Option<SettleAnimationState>>>,
295    frame_clock: FrameClock,
296    params: SpringParams,
297}
298
299impl SettleAnimation {
300    pub fn new(runtime: RuntimeHandle, params: SpringParams) -> Self {
301        Self {
302            state: Rc::new(RefCell::new(None)),
303            frame_clock: runtime.frame_clock(),
304            params,
305        }
306    }
307
308    pub(crate) fn start_settle<F, G>(
309        &self,
310        initial_value: f32,
311        initial_velocity: f32,
312        target: f32,
313        on_scroll: F,
314        on_end: G,
315    ) where
316        F: Fn(f32) -> f32 + 'static,
317        G: FnOnce(SettleEnd) + 'static,
318    {
319        self.cancel();
320        *self.state.borrow_mut() = Some(SettleAnimationState {
321            displacement: Cell::new(initial_value - target),
322            velocity: Cell::new(initial_velocity),
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 displacement = anim_state.displacement.get();
392            let (mut next_displacement, next_velocity) =
393                cranpose_animation::advance_spring_displacement(
394                    displacement,
395                    anim_state.velocity.get(),
396                    anim_state.params.damping_ratio,
397                    anim_state.params.stiffness,
398                    dt.max(0.0),
399                );
400
401            let is_finished = next_displacement.abs() < SETTLE_REST_DISTANCE
402                && next_velocity.abs() < SETTLE_REST_VELOCITY;
403            if is_finished {
404                next_displacement = 0.0;
405                anim_state.is_running.set(false);
406            }
407
408            let delta = next_displacement - displacement;
409            anim_state.displacement.set(next_displacement);
410            anim_state.velocity.set(next_velocity);
411
412            let consumed = if delta.abs() > 0.0001 {
413                on_scroll(delta)
414            } else {
415                delta
416            };
417            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
418            if boundary_hit {
419                anim_state.is_running.set(false);
420                hit_boundary.set(true);
421            }
422
423            !is_finished && !boundary_hit
424        };
425
426        if should_continue {
427            if let Some(on_end_fn) = on_end.borrow_mut().take() {
428                schedule_next_settle_frame(
429                    state_for_closure.clone(),
430                    frame_clock_for_closure.clone(),
431                    on_scroll,
432                    on_end_fn,
433                );
434            }
435        } else if let Some(end_fn) = on_end.borrow_mut().take() {
436            let state_guard = state_for_closure.borrow();
437            let velocity = state_guard
438                .as_ref()
439                .map_or(0.0, |anim_state| anim_state.velocity.get());
440            end_fn(SettleEnd {
441                velocity,
442                hit_boundary: hit_boundary.get(),
443            });
444        }
445    });
446
447    if let Some(anim_state) = state.borrow_mut().as_mut() {
448        anim_state.registration = Some(registration);
449    }
450}
451
452#[cfg(test)]
453#[path = "tests/fling_animation_tests.rs"]
454mod tests;