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