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::{FloatDecayAnimationSpec, SplineBasedDecaySpec};
11use cranpose_core::{
12    RuntimeHandle,
13    internal::{FrameCallbackRegistration, FrameClock},
14};
15
16/// Minimum velocity (in px/sec) to trigger a fling animation.
17/// Below this, the scroll just stops immediately.
18pub const MIN_FLING_VELOCITY: f32 = 1.0;
19
20/// Default fling friction value (matches Android ViewConfiguration).
21const DEFAULT_FLING_FRICTION: f32 = 0.015;
22
23/// Minimum unconsumed delta (in pixels) to consider a boundary hit.
24const BOUNDARY_EPSILON: f32 = 0.5;
25
26/// Schedules the next fling animation frame without creating a FlingAnimation instance.
27/// This is called recursively to drive the animation forward.
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    // Store the registration to keep the callback alive
123    if let Some(anim_state) = state.borrow_mut().as_mut() {
124        anim_state.registration = Some(registration);
125    }
126}
127
128/// State for an active fling animation.
129struct FlingAnimationState {
130    /// Initial position when fling started (used as reference for decay calc).
131    initial_value: f32,
132    /// Last applied position (to calculate delta for next frame).
133    last_value: Cell<f32>,
134    /// Initial velocity in px/sec.
135    initial_velocity: f32,
136    /// Frame time when the animation started (used for deterministic timing).
137    start_frame_time_nanos: Cell<Option<u64>>,
138    /// Decay animation spec for computing position/velocity.
139    decay_spec: SplineBasedDecaySpec,
140    /// Current frame callback registration (kept alive to continue animation).
141    registration: Option<FrameCallbackRegistration>,
142    /// Whether the animation is still active.
143    is_running: Cell<bool>,
144    /// Total delta applied so far (for debugging)
145    total_delta: Cell<f32>,
146}
147
148/// Drives a fling (decay) animation on a scroll target.
149///
150/// Each frame, it calculates the scroll DELTA based on the decay curve
151/// and applies it to the scroll target via the provided callback.
152pub struct FlingAnimation {
153    state: Rc<RefCell<Option<FlingAnimationState>>>,
154    frame_clock: FrameClock,
155}
156
157impl FlingAnimation {
158    /// Creates a new fling animation driver.
159    pub fn new(runtime: RuntimeHandle) -> Self {
160        Self {
161            state: Rc::new(RefCell::new(None)),
162            frame_clock: runtime.frame_clock(),
163        }
164    }
165
166    /// Starts a fling animation with the given velocity.
167    ///
168    /// # Arguments
169    /// * `initial_value` - Current scroll position (used as reference)
170    /// * `velocity` - Initial velocity in px/sec (from VelocityTracker)
171    /// * `density` - Screen density for physics calculations
172    /// * `on_scroll` - Callback invoked each frame with scroll DELTA (not absolute position)
173    /// * `on_end` - Callback invoked when animation completes
174    pub fn start_fling<F, G>(
175        &self,
176        initial_value: f32,
177        velocity: f32,
178        density: f32,
179        on_scroll: F,
180        on_end: G,
181    ) where
182        F: Fn(f32) -> f32 + 'static, // Returns consumed amount
183        G: FnOnce() + 'static,
184    {
185        // Cancel any existing animation
186        self.cancel();
187
188        // Check if velocity is high enough to warrant animation
189        if velocity.abs() < MIN_FLING_VELOCITY {
190            on_end();
191            return;
192        }
193
194        // Match Jetpack Compose's default friction (ViewConfiguration.getScrollFriction).
195        let friction = DEFAULT_FLING_FRICTION;
196        let calc = cranpose_animation::FlingCalculator::new(friction, density);
197        let decay_spec = SplineBasedDecaySpec::with_calculator(calc);
198
199        let anim_state = FlingAnimationState {
200            initial_value,
201            last_value: Cell::new(initial_value),
202            initial_velocity: velocity,
203            start_frame_time_nanos: Cell::new(None),
204            decay_spec,
205            registration: None,
206            is_running: Cell::new(true),
207            total_delta: Cell::new(0.0),
208        };
209
210        *self.state.borrow_mut() = Some(anim_state);
211
212        // Start frame loop
213        schedule_next_frame(
214            self.state.clone(),
215            self.frame_clock.clone(),
216            on_scroll,
217            on_end,
218        );
219    }
220
221    pub fn cancel(&self) {
222        if let Some(state) = self.state.borrow_mut().take() {
223            // Mark as not running to prevent callback from doing anything
224            state.is_running.set(false);
225            // Registration is dropped, cancelling the callback
226            drop(state.registration);
227        }
228    }
229
230    /// Returns true if a fling animation is currently running.
231    pub fn is_running(&self) -> bool {
232        self.state
233            .borrow()
234            .as_ref()
235            .is_some_and(|s| s.is_running.get())
236    }
237}
238
239impl Clone for FlingAnimation {
240    fn clone(&self) -> Self {
241        Self {
242            state: self.state.clone(),
243            frame_clock: self.frame_clock.clone(),
244        }
245    }
246}
247
248/// Predicts where a fling starting at `initial_value` with `velocity` would
249/// naturally come to rest, using the same decay physics as
250/// [`FlingAnimation::start_fling`]. Settle policies remap this proposed rest
251/// position before the deceleration starts (the `UIScrollView
252/// targetContentOffset` analog).
253pub fn fling_rest_position(initial_value: f32, velocity: f32, density: f32) -> f32 {
254    if velocity.abs() < MIN_FLING_VELOCITY {
255        return initial_value;
256    }
257    let calc = cranpose_animation::FlingCalculator::new(DEFAULT_FLING_FRICTION, density);
258    let spec = SplineBasedDecaySpec::with_calculator(calc);
259    spec.get_target_value(initial_value, velocity)
260}
261
262/// Spring stiffness for settle animations (critically damped ≈ 0.35 s), the
263/// iOS large-title snap feel.
264const SETTLE_STIFFNESS: f32 = 300.0;
265
266/// Position/velocity epsilons below which a settle animation finishes.
267const SETTLE_REST_DISTANCE: f32 = 0.1;
268const SETTLE_REST_VELOCITY: f32 = 4.0;
269
270struct SettleAnimationState {
271    value: Cell<f32>,
272    velocity: Cell<f32>,
273    target: f32,
274    last_frame_time_nanos: Cell<Option<u64>>,
275    registration: Option<FrameCallbackRegistration>,
276    is_running: Cell<bool>,
277}
278
279pub(crate) struct SettleEnd {
280    pub(crate) velocity: f32,
281    pub(crate) hit_boundary: bool,
282}
283
284/// Drives a critically-damped spring toward a settle target on a scroll
285/// container, taking over the gesture's release velocity so a policy-adjusted
286/// rest position still reads as one continuous deceleration.
287pub struct SettleAnimation {
288    state: Rc<RefCell<Option<SettleAnimationState>>>,
289    frame_clock: FrameClock,
290}
291
292impl SettleAnimation {
293    pub fn new(runtime: RuntimeHandle) -> Self {
294        Self {
295            state: Rc::new(RefCell::new(None)),
296            frame_clock: runtime.frame_clock(),
297        }
298    }
299
300    /// Starts settling from `initial_value` (with `initial_velocity`, in
301    /// offset units/sec) toward `target`. `on_scroll` receives per-frame
302    /// deltas and returns the consumed amount; `on_end` fires once when the
303    /// spring rests or the target stops consuming (boundary hit).
304    pub(crate) fn start_settle<F, G>(
305        &self,
306        initial_value: f32,
307        initial_velocity: f32,
308        target: f32,
309        on_scroll: F,
310        on_end: G,
311    ) where
312        F: Fn(f32) -> f32 + 'static,
313        G: FnOnce(SettleEnd) + 'static,
314    {
315        self.cancel();
316        *self.state.borrow_mut() = Some(SettleAnimationState {
317            value: Cell::new(initial_value),
318            velocity: Cell::new(initial_velocity),
319            target,
320            last_frame_time_nanos: Cell::new(None),
321            registration: None,
322            is_running: Cell::new(true),
323        });
324        schedule_next_settle_frame(
325            self.state.clone(),
326            self.frame_clock.clone(),
327            on_scroll,
328            on_end,
329        );
330    }
331
332    pub fn cancel(&self) {
333        if let Some(state) = self.state.borrow_mut().take() {
334            state.is_running.set(false);
335            drop(state.registration);
336        }
337    }
338
339    pub fn is_running(&self) -> bool {
340        self.state
341            .borrow()
342            .as_ref()
343            .is_some_and(|s| s.is_running.get())
344    }
345}
346
347impl Clone for SettleAnimation {
348    fn clone(&self) -> Self {
349        Self {
350            state: self.state.clone(),
351            frame_clock: self.frame_clock.clone(),
352        }
353    }
354}
355
356fn schedule_next_settle_frame<F, G>(
357    state: Rc<RefCell<Option<SettleAnimationState>>>,
358    frame_clock: FrameClock,
359    on_scroll: F,
360    on_end: G,
361) where
362    F: Fn(f32) -> f32 + 'static,
363    G: FnOnce(SettleEnd) + 'static,
364{
365    let state_for_closure = state.clone();
366    let frame_clock_for_closure = frame_clock.clone();
367    let on_end = RefCell::new(Some(on_end));
368    let hit_boundary = Cell::new(false);
369
370    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
371        let should_continue = {
372            let state_guard = state_for_closure.borrow();
373            let Some(anim_state) = state_guard.as_ref() else {
374                return;
375            };
376            if !anim_state.is_running.get() {
377                return;
378            }
379
380            let dt = match anim_state.last_frame_time_nanos.get() {
381                Some(last) => (frame_time_nanos.saturating_sub(last) as f32) / 1_000_000_000.0,
382                None => 0.0,
383            };
384            anim_state.last_frame_time_nanos.set(Some(frame_time_nanos));
385
386            let (mut next_value, next_velocity) = cranpose_animation::advance_spring(
387                anim_state.value.get(),
388                anim_state.velocity.get(),
389                anim_state.target,
390                1.0,
391                SETTLE_STIFFNESS,
392                dt.max(0.0),
393            );
394
395            let is_finished = (next_value - anim_state.target).abs() < SETTLE_REST_DISTANCE
396                && next_velocity.abs() < SETTLE_REST_VELOCITY;
397            if is_finished {
398                next_value = anim_state.target;
399                anim_state.is_running.set(false);
400            }
401
402            let delta = next_value - anim_state.value.get();
403            anim_state.value.set(next_value);
404            anim_state.velocity.set(next_velocity);
405
406            let consumed = if delta.abs() > 0.0001 {
407                on_scroll(delta)
408            } else {
409                delta
410            };
411            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
412            if boundary_hit {
413                anim_state.is_running.set(false);
414                hit_boundary.set(true);
415            }
416
417            !is_finished && !boundary_hit
418        };
419
420        if should_continue {
421            if let Some(on_end_fn) = on_end.borrow_mut().take() {
422                schedule_next_settle_frame(
423                    state_for_closure.clone(),
424                    frame_clock_for_closure.clone(),
425                    on_scroll,
426                    on_end_fn,
427                );
428            }
429        } else if let Some(end_fn) = on_end.borrow_mut().take() {
430            let state_guard = state_for_closure.borrow();
431            let velocity = state_guard
432                .as_ref()
433                .map_or(0.0, |anim_state| anim_state.velocity.get());
434            end_fn(SettleEnd {
435                velocity,
436                hit_boundary: hit_boundary.get(),
437            });
438        }
439    });
440
441    if let Some(anim_state) = state.borrow_mut().as_mut() {
442        anim_state.registration = Some(registration);
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::{cell::Cell, rc::Rc, sync::Arc};
449
450    use cranpose_core::{DefaultScheduler, Runtime};
451
452    use super::*;
453
454    #[test]
455    fn test_min_velocity_threshold() {
456        assert_eq!(MIN_FLING_VELOCITY, 1.0);
457    }
458
459    #[test]
460    fn settle_animation_springs_to_target_and_ends() {
461        let runtime = Runtime::new(Arc::new(DefaultScheduler));
462        let handle = runtime.handle();
463        let settle = SettleAnimation::new(handle.clone());
464        let position = Rc::new(Cell::new(30.0f32));
465        let ended = Rc::new(Cell::new(false));
466        let position_for_scroll = Rc::clone(&position);
467        let ended_for_end = Rc::clone(&ended);
468        settle.start_settle(
469            30.0,
470            0.0,
471            52.0,
472            move |delta| {
473                position_for_scroll.set(position_for_scroll.get() + delta);
474                delta
475            },
476            move |_| ended_for_end.set(true),
477        );
478        for frame in 0..240u64 {
479            handle.drain_frame_callbacks(frame * 16_000_000);
480            if ended.get() {
481                break;
482            }
483        }
484        assert!(ended.get(), "settle animation must finish");
485        assert!(
486            (position.get() - 52.0).abs() < 0.2,
487            "settle must land on the target, got {}",
488            position.get()
489        );
490    }
491
492    #[test]
493    fn settle_reports_reduced_velocity_when_crossing_boundary() {
494        let runtime = Runtime::new(Arc::new(DefaultScheduler));
495        let handle = runtime.handle();
496        let settle = SettleAnimation::new(handle.clone());
497        let position = Rc::new(Cell::new(30.0f32));
498        let ended = Rc::new(Cell::new(None::<(f32, bool)>));
499        let position_for_scroll = Rc::clone(&position);
500        let ended_for_end = Rc::clone(&ended);
501        settle.start_settle(
502            30.0,
503            -1_200.0,
504            0.0,
505            move |delta| {
506                let previous = position_for_scroll.get();
507                let next = (previous + delta).max(0.0);
508                position_for_scroll.set(next);
509                next - previous
510            },
511            move |end| ended_for_end.set(Some((end.velocity, end.hit_boundary))),
512        );
513        for frame in 0..240u64 {
514            handle.drain_frame_callbacks(frame * 16_000_000);
515            if ended.get().is_some() {
516                break;
517            }
518        }
519
520        let (velocity, hit_boundary) = ended.get().expect("settle must finish");
521        assert!(hit_boundary);
522        assert!(velocity < 0.0 && velocity.abs() < 1_200.0);
523        assert_eq!(position.get(), 0.0);
524    }
525
526    #[test]
527    fn fling_rest_position_is_beyond_start_in_fling_direction() {
528        let rest = fling_rest_position(100.0, 900.0, 1.0);
529        assert!(rest > 100.0, "rest {rest} must be past the start");
530        assert_eq!(fling_rest_position(100.0, 0.0, 1.0), 100.0);
531    }
532
533    #[test]
534    fn test_on_end_called_when_boundary_hit() {
535        let runtime = Runtime::new(Arc::new(DefaultScheduler));
536        let handle = runtime.handle();
537        let fling = FlingAnimation::new(handle.clone());
538        let finished = Rc::new(Cell::new(false));
539        let finished_flag = Rc::clone(&finished);
540
541        fling.start_fling(0.0, 10_000.0, 1.0, |_| 0.0, move || finished_flag.set(true));
542
543        handle.drain_frame_callbacks(0);
544        handle.drain_frame_callbacks(16_000_000);
545
546        assert!(finished.get());
547    }
548}