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 boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
96            if boundary_hit {
97                anim_state.is_running.set(false);
98            }
99
100            !is_finished && !boundary_hit
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
274pub(crate) struct SettleEnd {
275    pub(crate) velocity: f32,
276    pub(crate) hit_boundary: bool,
277}
278
279/// Drives a critically-damped spring toward a settle target on a scroll
280/// container, taking over the gesture's release velocity so a policy-adjusted
281/// rest position still reads as one continuous deceleration.
282pub struct SettleAnimation {
283    state: Rc<RefCell<Option<SettleAnimationState>>>,
284    frame_clock: FrameClock,
285}
286
287impl SettleAnimation {
288    pub fn new(runtime: RuntimeHandle) -> Self {
289        Self {
290            state: Rc::new(RefCell::new(None)),
291            frame_clock: runtime.frame_clock(),
292        }
293    }
294
295    /// Starts settling from `initial_value` (with `initial_velocity`, in
296    /// offset units/sec) toward `target`. `on_scroll` receives per-frame
297    /// deltas and returns the consumed amount; `on_end` fires once when the
298    /// spring rests or the target stops consuming (boundary hit).
299    pub(crate) fn start_settle<F, G>(
300        &self,
301        initial_value: f32,
302        initial_velocity: f32,
303        target: f32,
304        on_scroll: F,
305        on_end: G,
306    ) where
307        F: Fn(f32) -> f32 + 'static,
308        G: FnOnce(SettleEnd) + 'static,
309    {
310        self.cancel();
311        *self.state.borrow_mut() = Some(SettleAnimationState {
312            value: Cell::new(initial_value),
313            velocity: Cell::new(initial_velocity),
314            target,
315            last_frame_time_nanos: Cell::new(None),
316            registration: None,
317            is_running: Cell::new(true),
318        });
319        schedule_next_settle_frame(
320            self.state.clone(),
321            self.frame_clock.clone(),
322            on_scroll,
323            on_end,
324        );
325    }
326
327    pub fn cancel(&self) {
328        if let Some(state) = self.state.borrow_mut().take() {
329            state.is_running.set(false);
330            drop(state.registration);
331        }
332    }
333
334    pub fn is_running(&self) -> bool {
335        self.state
336            .borrow()
337            .as_ref()
338            .is_some_and(|s| s.is_running.get())
339    }
340}
341
342impl Clone for SettleAnimation {
343    fn clone(&self) -> Self {
344        Self {
345            state: self.state.clone(),
346            frame_clock: self.frame_clock.clone(),
347        }
348    }
349}
350
351fn schedule_next_settle_frame<F, G>(
352    state: Rc<RefCell<Option<SettleAnimationState>>>,
353    frame_clock: FrameClock,
354    on_scroll: F,
355    on_end: G,
356) where
357    F: Fn(f32) -> f32 + 'static,
358    G: FnOnce(SettleEnd) + 'static,
359{
360    let state_for_closure = state.clone();
361    let frame_clock_for_closure = frame_clock.clone();
362    let on_end = RefCell::new(Some(on_end));
363    let hit_boundary = Cell::new(false);
364
365    let registration = frame_clock.with_frame_nanos(move |frame_time_nanos| {
366        let should_continue = {
367            let state_guard = state_for_closure.borrow();
368            let Some(anim_state) = state_guard.as_ref() else {
369                return;
370            };
371            if !anim_state.is_running.get() {
372                return;
373            }
374
375            let dt = match anim_state.last_frame_time_nanos.get() {
376                Some(last) => (frame_time_nanos.saturating_sub(last) as f32) / 1_000_000_000.0,
377                None => 0.0,
378            };
379            anim_state.last_frame_time_nanos.set(Some(frame_time_nanos));
380
381            let (mut next_value, next_velocity) = cranpose_animation::advance_spring(
382                anim_state.value.get(),
383                anim_state.velocity.get(),
384                anim_state.target,
385                1.0,
386                SETTLE_STIFFNESS,
387                dt.max(0.0),
388            );
389
390            let is_finished = (next_value - anim_state.target).abs() < SETTLE_REST_DISTANCE
391                && next_velocity.abs() < SETTLE_REST_VELOCITY;
392            if is_finished {
393                next_value = anim_state.target;
394                anim_state.is_running.set(false);
395            }
396
397            let delta = next_value - anim_state.value.get();
398            anim_state.value.set(next_value);
399            anim_state.velocity.set(next_velocity);
400
401            let consumed = if delta.abs() > 0.0001 {
402                on_scroll(delta)
403            } else {
404                delta
405            };
406            let boundary_hit = (delta - consumed).abs() > BOUNDARY_EPSILON;
407            if boundary_hit {
408                anim_state.is_running.set(false);
409                hit_boundary.set(true);
410            }
411
412            !is_finished && !boundary_hit
413        };
414
415        if should_continue {
416            if let Some(on_end_fn) = on_end.borrow_mut().take() {
417                schedule_next_settle_frame(
418                    state_for_closure.clone(),
419                    frame_clock_for_closure.clone(),
420                    on_scroll,
421                    on_end_fn,
422                );
423            }
424        } else if let Some(end_fn) = on_end.borrow_mut().take() {
425            let state_guard = state_for_closure.borrow();
426            let velocity = state_guard
427                .as_ref()
428                .map_or(0.0, |anim_state| anim_state.velocity.get());
429            end_fn(SettleEnd {
430                velocity,
431                hit_boundary: hit_boundary.get(),
432            });
433        }
434    });
435
436    if let Some(anim_state) = state.borrow_mut().as_mut() {
437        anim_state.registration = Some(registration);
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use cranpose_core::DefaultScheduler;
445    use cranpose_core::Runtime;
446    use std::cell::Cell;
447    use std::rc::Rc;
448    use std::sync::Arc;
449
450    #[test]
451    fn test_min_velocity_threshold() {
452        assert_eq!(MIN_FLING_VELOCITY, 1.0);
453    }
454
455    #[test]
456    fn settle_animation_springs_to_target_and_ends() {
457        let runtime = Runtime::new(Arc::new(DefaultScheduler));
458        let handle = runtime.handle();
459        let settle = SettleAnimation::new(handle.clone());
460        let position = Rc::new(Cell::new(30.0f32));
461        let ended = Rc::new(Cell::new(false));
462        let position_for_scroll = Rc::clone(&position);
463        let ended_for_end = Rc::clone(&ended);
464        settle.start_settle(
465            30.0,
466            0.0,
467            52.0,
468            move |delta| {
469                position_for_scroll.set(position_for_scroll.get() + delta);
470                delta
471            },
472            move |_| ended_for_end.set(true),
473        );
474        for frame in 0..240u64 {
475            handle.drain_frame_callbacks(frame * 16_000_000);
476            if ended.get() {
477                break;
478            }
479        }
480        assert!(ended.get(), "settle animation must finish");
481        assert!(
482            (position.get() - 52.0).abs() < 0.2,
483            "settle must land on the target, got {}",
484            position.get()
485        );
486    }
487
488    #[test]
489    fn settle_reports_reduced_velocity_when_crossing_boundary() {
490        let runtime = Runtime::new(Arc::new(DefaultScheduler));
491        let handle = runtime.handle();
492        let settle = SettleAnimation::new(handle.clone());
493        let position = Rc::new(Cell::new(30.0f32));
494        let ended = Rc::new(Cell::new(None::<(f32, bool)>));
495        let position_for_scroll = Rc::clone(&position);
496        let ended_for_end = Rc::clone(&ended);
497        settle.start_settle(
498            30.0,
499            -1_200.0,
500            0.0,
501            move |delta| {
502                let previous = position_for_scroll.get();
503                let next = (previous + delta).max(0.0);
504                position_for_scroll.set(next);
505                next - previous
506            },
507            move |end| ended_for_end.set(Some((end.velocity, end.hit_boundary))),
508        );
509        for frame in 0..240u64 {
510            handle.drain_frame_callbacks(frame * 16_000_000);
511            if ended.get().is_some() {
512                break;
513            }
514        }
515
516        let (velocity, hit_boundary) = ended.get().expect("settle must finish");
517        assert!(hit_boundary);
518        assert!(velocity < 0.0 && velocity.abs() < 1_200.0);
519        assert_eq!(position.get(), 0.0);
520    }
521
522    #[test]
523    fn fling_rest_position_is_beyond_start_in_fling_direction() {
524        let rest = fling_rest_position(100.0, 900.0, 1.0);
525        assert!(rest > 100.0, "rest {rest} must be past the start");
526        assert_eq!(fling_rest_position(100.0, 0.0, 1.0), 100.0);
527    }
528
529    #[test]
530    fn test_on_end_called_when_boundary_hit() {
531        let runtime = Runtime::new(Arc::new(DefaultScheduler));
532        let handle = runtime.handle();
533        let fling = FlingAnimation::new(handle.clone());
534        let finished = Rc::new(Cell::new(false));
535        let finished_flag = Rc::clone(&finished);
536
537        fling.start_fling(0.0, 10_000.0, 1.0, |_| 0.0, move || finished_flag.set(true));
538
539        handle.drain_frame_callbacks(0);
540        handle.drain_frame_callbacks(16_000_000);
541
542        assert!(finished.get());
543    }
544}