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 cranpose_animation::{FloatDecayAnimationSpec, SplineBasedDecaySpec};
6use cranpose_core::{FrameCallbackRegistration, FrameClock, RuntimeHandle};
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9
10/// Minimum velocity (in px/sec) to trigger a fling animation.
11/// Below this, the scroll just stops immediately.
12pub const MIN_FLING_VELOCITY: f32 = 1.0;
13
14/// Default fling friction value (matches Android ViewConfiguration).
15const DEFAULT_FLING_FRICTION: f32 = 0.015;
16
17/// Minimum unconsumed delta (in pixels) to consider a boundary hit.
18const BOUNDARY_EPSILON: f32 = 0.5;
19
20/// Schedules the next fling animation frame without creating a FlingAnimation instance.
21/// This is called recursively to drive the animation forward.
22fn schedule_next_frame<F, G>(
23    state: Rc<RefCell<Option<FlingAnimationState>>>,
24    frame_clock: FrameClock,
25    on_scroll: F,
26    on_end: G,
27) where
28    F: Fn(f32) -> f32 + 'static,
29    G: FnOnce() + 'static,
30{
31    let state_for_closure = state.clone();
32    let frame_clock_for_closure = frame_clock.clone();
33    let on_end = RefCell::new(Some(on_end));
34
35    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
36        let should_continue = {
37            let state_guard = state_for_closure.borrow();
38            let Some(anim_state) = state_guard.as_ref() else {
39                return;
40            };
41
42            if !anim_state.is_running.get() {
43                return;
44            }
45
46            let start_time = match anim_state.start_frame_time_nanos.get() {
47                Some(value) => value,
48                None => {
49                    anim_state
50                        .start_frame_time_nanos
51                        .set(Some(frame_time_nanos));
52                    frame_time_nanos
53                }
54            };
55
56            let play_time_nanos = frame_time_nanos.saturating_sub(start_time) as i64;
57
58            let new_value = anim_state.decay_spec.get_value_from_nanos(
59                play_time_nanos,
60                anim_state.initial_value,
61                anim_state.initial_velocity,
62            );
63
64            let last = anim_state.last_value.get();
65            let delta = new_value - last;
66            anim_state.last_value.set(new_value);
67            anim_state
68                .total_delta
69                .set(anim_state.total_delta.get() + delta);
70
71            let duration_nanos = anim_state
72                .decay_spec
73                .get_duration_nanos(anim_state.initial_value, anim_state.initial_velocity);
74
75            let current_velocity = anim_state.decay_spec.get_velocity_from_nanos(
76                play_time_nanos,
77                anim_state.initial_value,
78                anim_state.initial_velocity,
79            );
80
81            let is_finished = play_time_nanos >= duration_nanos
82                || current_velocity.abs() < anim_state.decay_spec.abs_velocity_threshold();
83
84            if is_finished {
85                anim_state.is_running.set(false);
86            }
87
88            let consumed = if delta.abs() > 0.001 {
89                on_scroll(delta)
90            } else {
91                0.0
92            };
93
94            let hit_boundary = (delta - consumed).abs() > BOUNDARY_EPSILON;
95            if hit_boundary {
96                anim_state.is_running.set(false);
97            }
98
99            !is_finished && !hit_boundary
100        };
101
102        if should_continue {
103            if let Some(on_end_fn) = on_end.borrow_mut().take() {
104                schedule_next_frame(
105                    state_for_closure.clone(),
106                    frame_clock_for_closure.clone(),
107                    on_scroll,
108                    on_end_fn,
109                );
110            }
111        } else if let Some(end_fn) = on_end.borrow_mut().take() {
112            end_fn();
113        }
114    });
115
116    // Store the registration to keep the callback alive
117    if let Some(anim_state) = state.borrow_mut().as_mut() {
118        anim_state.registration = Some(registration);
119    }
120}
121
122/// State for an active fling animation.
123struct FlingAnimationState {
124    /// Initial position when fling started (used as reference for decay calc).
125    initial_value: f32,
126    /// Last applied position (to calculate delta for next frame).
127    last_value: Cell<f32>,
128    /// Initial velocity in px/sec.
129    initial_velocity: f32,
130    /// Frame time when the animation started (used for deterministic timing).
131    start_frame_time_nanos: Cell<Option<u64>>,
132    /// Decay animation spec for computing position/velocity.
133    decay_spec: SplineBasedDecaySpec,
134    /// Current frame callback registration (kept alive to continue animation).
135    registration: Option<FrameCallbackRegistration>,
136    /// Whether the animation is still active.
137    is_running: Cell<bool>,
138    /// Total delta applied so far (for debugging)
139    total_delta: Cell<f32>,
140}
141
142/// Drives a fling (decay) animation on a scroll target.
143///
144/// Each frame, it calculates the scroll DELTA based on the decay curve
145/// and applies it to the scroll target via the provided callback.
146pub struct FlingAnimation {
147    state: Rc<RefCell<Option<FlingAnimationState>>>,
148    frame_clock: FrameClock,
149}
150
151impl FlingAnimation {
152    /// Creates a new fling animation driver.
153    pub fn new(runtime: RuntimeHandle) -> Self {
154        Self {
155            state: Rc::new(RefCell::new(None)),
156            frame_clock: runtime.frame_clock(),
157        }
158    }
159
160    /// Starts a fling animation with the given velocity.
161    ///
162    /// # Arguments
163    /// * `initial_value` - Current scroll position (used as reference)
164    /// * `velocity` - Initial velocity in px/sec (from VelocityTracker)
165    /// * `density` - Screen density for physics calculations
166    /// * `on_scroll` - Callback invoked each frame with scroll DELTA (not absolute position)
167    /// * `on_end` - Callback invoked when animation completes
168    pub fn start_fling<F, G>(
169        &self,
170        initial_value: f32,
171        velocity: f32,
172        density: f32,
173        on_scroll: F,
174        on_end: G,
175    ) where
176        F: Fn(f32) -> f32 + 'static, // Returns consumed amount
177        G: FnOnce() + 'static,
178    {
179        // Cancel any existing animation
180        self.cancel();
181
182        // Check if velocity is high enough to warrant animation
183        if velocity.abs() < MIN_FLING_VELOCITY {
184            on_end();
185            return;
186        }
187
188        // Match Jetpack Compose's default friction (ViewConfiguration.getScrollFriction).
189        let friction = DEFAULT_FLING_FRICTION;
190        let calc = cranpose_animation::FlingCalculator::new(friction, density);
191        let decay_spec = SplineBasedDecaySpec::with_calculator(calc);
192
193        let anim_state = FlingAnimationState {
194            initial_value,
195            last_value: Cell::new(initial_value),
196            initial_velocity: velocity,
197            start_frame_time_nanos: Cell::new(None),
198            decay_spec,
199            registration: None,
200            is_running: Cell::new(true),
201            total_delta: Cell::new(0.0),
202        };
203
204        *self.state.borrow_mut() = Some(anim_state);
205
206        // Start frame loop
207        schedule_next_frame(
208            self.state.clone(),
209            self.frame_clock.clone(),
210            on_scroll,
211            on_end,
212        );
213    }
214
215    pub fn cancel(&self) {
216        if let Some(state) = self.state.borrow_mut().take() {
217            // Mark as not running to prevent callback from doing anything
218            state.is_running.set(false);
219            // Registration is dropped, cancelling the callback
220            drop(state.registration);
221        }
222    }
223
224    /// Returns true if a fling animation is currently running.
225    pub fn is_running(&self) -> bool {
226        self.state
227            .borrow()
228            .as_ref()
229            .is_some_and(|s| s.is_running.get())
230    }
231}
232
233impl Clone for FlingAnimation {
234    fn clone(&self) -> Self {
235        Self {
236            state: self.state.clone(),
237            frame_clock: self.frame_clock.clone(),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use cranpose_core::DefaultScheduler;
246    use cranpose_core::Runtime;
247    use std::cell::Cell;
248    use std::rc::Rc;
249    use std::sync::Arc;
250
251    #[test]
252    fn test_min_velocity_threshold() {
253        assert_eq!(MIN_FLING_VELOCITY, 1.0);
254    }
255
256    #[test]
257    fn test_on_end_called_when_boundary_hit() {
258        let runtime = Runtime::new(Arc::new(DefaultScheduler));
259        let handle = runtime.handle();
260        let fling = FlingAnimation::new(handle.clone());
261        let finished = Rc::new(Cell::new(false));
262        let finished_flag = Rc::clone(&finished);
263
264        fling.start_fling(0.0, 10_000.0, 1.0, |_| 0.0, move || finished_flag.set(true));
265
266        handle.drain_frame_callbacks(0);
267        handle.drain_frame_callbacks(16_000_000);
268
269        assert!(finished.get());
270    }
271}