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/// Predicts where a fling starting at `initial_value` with `velocity` would
244/// naturally come to rest, using the same decay physics as
245/// [`FlingAnimation::start_fling`]. Settle policies remap this proposed rest
246/// position before the deceleration starts (the `UIScrollView
247/// targetContentOffset` analog).
248pub fn fling_rest_position(initial_value: f32, velocity: f32, density: f32) -> f32 {
249    if velocity.abs() < MIN_FLING_VELOCITY {
250        return initial_value;
251    }
252    let calc = cranpose_animation::FlingCalculator::new(DEFAULT_FLING_FRICTION, density);
253    let spec = SplineBasedDecaySpec::with_calculator(calc);
254    spec.get_target_value(initial_value, velocity)
255}
256
257/// Spring stiffness for settle animations (critically damped ≈ 0.35 s), the
258/// iOS large-title snap feel.
259const SETTLE_STIFFNESS: f32 = 300.0;
260
261/// Position/velocity epsilons below which a settle animation finishes.
262const SETTLE_REST_DISTANCE: f32 = 0.1;
263const SETTLE_REST_VELOCITY: f32 = 4.0;
264
265struct SettleAnimationState {
266    value: Cell<f32>,
267    velocity: Cell<f32>,
268    target: f32,
269    last_frame_time_nanos: Cell<Option<u64>>,
270    registration: Option<FrameCallbackRegistration>,
271    is_running: Cell<bool>,
272}
273
274/// Drives a critically-damped spring toward a settle target on a scroll
275/// container, taking over the gesture's release velocity so a policy-adjusted
276/// rest position still reads as one continuous deceleration.
277pub struct SettleAnimation {
278    state: Rc<RefCell<Option<SettleAnimationState>>>,
279    frame_clock: FrameClock,
280}
281
282impl SettleAnimation {
283    pub fn new(runtime: RuntimeHandle) -> Self {
284        Self {
285            state: Rc::new(RefCell::new(None)),
286            frame_clock: runtime.frame_clock(),
287        }
288    }
289
290    /// Starts settling from `initial_value` (with `initial_velocity`, in
291    /// offset units/sec) toward `target`. `on_scroll` receives per-frame
292    /// deltas and returns the consumed amount; `on_end` fires once when the
293    /// spring rests or the target stops consuming (boundary hit).
294    pub fn start_settle<F, G>(
295        &self,
296        initial_value: f32,
297        initial_velocity: f32,
298        target: f32,
299        on_scroll: F,
300        on_end: G,
301    ) where
302        F: Fn(f32) -> f32 + 'static,
303        G: FnOnce() + 'static,
304    {
305        self.cancel();
306        *self.state.borrow_mut() = Some(SettleAnimationState {
307            value: Cell::new(initial_value),
308            velocity: Cell::new(initial_velocity),
309            target,
310            last_frame_time_nanos: Cell::new(None),
311            registration: None,
312            is_running: Cell::new(true),
313        });
314        schedule_next_settle_frame(
315            self.state.clone(),
316            self.frame_clock.clone(),
317            on_scroll,
318            on_end,
319        );
320    }
321
322    pub fn cancel(&self) {
323        if let Some(state) = self.state.borrow_mut().take() {
324            state.is_running.set(false);
325            drop(state.registration);
326        }
327    }
328
329    pub fn is_running(&self) -> bool {
330        self.state
331            .borrow()
332            .as_ref()
333            .is_some_and(|s| s.is_running.get())
334    }
335}
336
337impl Clone for SettleAnimation {
338    fn clone(&self) -> Self {
339        Self {
340            state: self.state.clone(),
341            frame_clock: self.frame_clock.clone(),
342        }
343    }
344}
345
346fn schedule_next_settle_frame<F, G>(
347    state: Rc<RefCell<Option<SettleAnimationState>>>,
348    frame_clock: FrameClock,
349    on_scroll: F,
350    on_end: G,
351) where
352    F: Fn(f32) -> f32 + 'static,
353    G: FnOnce() + 'static,
354{
355    let state_for_closure = state.clone();
356    let frame_clock_for_closure = frame_clock.clone();
357    let on_end = RefCell::new(Some(on_end));
358
359    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
360        let should_continue = {
361            let state_guard = state_for_closure.borrow();
362            let Some(anim_state) = state_guard.as_ref() else {
363                return;
364            };
365            if !anim_state.is_running.get() {
366                return;
367            }
368
369            let dt = match anim_state.last_frame_time_nanos.get() {
370                Some(last) => (frame_time_nanos.saturating_sub(last) as f32) / 1_000_000_000.0,
371                None => 0.0,
372            };
373            anim_state.last_frame_time_nanos.set(Some(frame_time_nanos));
374
375            let (mut next_value, next_velocity) = cranpose_animation::advance_spring(
376                anim_state.value.get(),
377                anim_state.velocity.get(),
378                anim_state.target,
379                1.0,
380                SETTLE_STIFFNESS,
381                dt.max(0.0),
382            );
383
384            let is_finished = (next_value - anim_state.target).abs() < SETTLE_REST_DISTANCE
385                && next_velocity.abs() < SETTLE_REST_VELOCITY;
386            if is_finished {
387                next_value = anim_state.target;
388                anim_state.is_running.set(false);
389            }
390
391            let delta = next_value - anim_state.value.get();
392            anim_state.value.set(next_value);
393            anim_state.velocity.set(next_velocity);
394
395            let consumed = if delta.abs() > 0.0001 {
396                on_scroll(delta)
397            } else {
398                delta
399            };
400            let hit_boundary = (delta - consumed).abs() > BOUNDARY_EPSILON;
401            if hit_boundary {
402                anim_state.is_running.set(false);
403            }
404
405            !is_finished && !hit_boundary
406        };
407
408        if should_continue {
409            if let Some(on_end_fn) = on_end.borrow_mut().take() {
410                schedule_next_settle_frame(
411                    state_for_closure.clone(),
412                    frame_clock_for_closure.clone(),
413                    on_scroll,
414                    on_end_fn,
415                );
416            }
417        } else if let Some(end_fn) = on_end.borrow_mut().take() {
418            end_fn();
419        }
420    });
421
422    if let Some(anim_state) = state.borrow_mut().as_mut() {
423        anim_state.registration = Some(registration);
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use cranpose_core::DefaultScheduler;
431    use cranpose_core::Runtime;
432    use std::cell::Cell;
433    use std::rc::Rc;
434    use std::sync::Arc;
435
436    #[test]
437    fn test_min_velocity_threshold() {
438        assert_eq!(MIN_FLING_VELOCITY, 1.0);
439    }
440
441    #[test]
442    fn settle_animation_springs_to_target_and_ends() {
443        let runtime = Runtime::new(Arc::new(DefaultScheduler));
444        let handle = runtime.handle();
445        let settle = SettleAnimation::new(handle.clone());
446        let position = Rc::new(Cell::new(30.0f32));
447        let ended = Rc::new(Cell::new(false));
448        let position_for_scroll = Rc::clone(&position);
449        let ended_for_end = Rc::clone(&ended);
450        settle.start_settle(
451            30.0,
452            0.0,
453            52.0,
454            move |delta| {
455                position_for_scroll.set(position_for_scroll.get() + delta);
456                delta
457            },
458            move || ended_for_end.set(true),
459        );
460        for frame in 0..240u64 {
461            handle.drain_frame_callbacks(frame * 16_000_000);
462            if ended.get() {
463                break;
464            }
465        }
466        assert!(ended.get(), "settle animation must finish");
467        assert!(
468            (position.get() - 52.0).abs() < 0.2,
469            "settle must land on the target, got {}",
470            position.get()
471        );
472    }
473
474    #[test]
475    fn fling_rest_position_is_beyond_start_in_fling_direction() {
476        let rest = fling_rest_position(100.0, 900.0, 1.0);
477        assert!(rest > 100.0, "rest {rest} must be past the start");
478        assert_eq!(fling_rest_position(100.0, 0.0, 1.0), 100.0);
479    }
480
481    #[test]
482    fn test_on_end_called_when_boundary_hit() {
483        let runtime = Runtime::new(Arc::new(DefaultScheduler));
484        let handle = runtime.handle();
485        let fling = FlingAnimation::new(handle.clone());
486        let finished = Rc::new(Cell::new(false));
487        let finished_flag = Rc::clone(&finished);
488
489        fling.start_fling(0.0, 10_000.0, 1.0, |_| 0.0, move || finished_flag.set(true));
490
491        handle.drain_frame_callbacks(0);
492        handle.drain_frame_callbacks(16_000_000);
493
494        assert!(finished.get());
495    }
496}