Skip to main content

cranpose_animation/
decay_spec.rs

1//! Decay animation specification for fling animations.
2//!
3//! Port of Jetpack Compose's SplineBasedDecay and FlingCalculator.
4//! This provides the physics for Android-feel fling scrolling.
5
6use std::sync::LazyLock;
7
8// ============================================================================
9// Android Fling Spline
10// ============================================================================
11
12/// Tension curve inflection point
13const INFLECTION: f32 = 0.35;
14const START_TENSION: f32 = 0.5;
15const END_TENSION: f32 = 1.0;
16const P1: f32 = START_TENSION * INFLECTION;
17const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLECTION);
18
19/// Number of samples in the spline lookup tables
20const NB_SAMPLES: usize = 100;
21
22/// Precomputed spline data for fast lookups.
23struct SplineData {
24    positions: [f32; NB_SAMPLES + 1],
25}
26
27/// Lazily computed spline tables.
28static SPLINE_DATA: LazyLock<SplineData> = LazyLock::new(|| {
29    let mut positions = [0.0f32; NB_SAMPLES + 1];
30
31    let mut x_min = 0.0f32;
32
33    for (i, position) in positions.iter_mut().enumerate().take(NB_SAMPLES) {
34        let alpha = i as f32 / NB_SAMPLES as f32;
35
36        // Find x such that bezier(x) = alpha
37        let mut x_max = 1.0f32;
38        let x;
39        let coef;
40        loop {
41            let x_mid = x_min + (x_max - x_min) / 2.0;
42            let c = 3.0 * x_mid * (1.0 - x_mid);
43            let tx = c * ((1.0 - x_mid) * P1 + x_mid * P2) + x_mid * x_mid * x_mid;
44            if (tx - alpha).abs() < 1e-5 {
45                x = x_mid;
46                coef = c;
47                break;
48            }
49            if tx > alpha {
50                x_max = x_mid;
51            } else {
52                x_min = x_mid;
53            }
54        }
55        *position = coef * ((1.0 - x) * START_TENSION + x) + x * x * x;
56    }
57
58    positions[NB_SAMPLES] = 1.0;
59
60    SplineData { positions }
61});
62
63/// Result of sampling the fling spline.
64#[derive(Debug, Clone, Copy)]
65pub struct FlingResult {
66    /// Distance coefficient (0.0 to 1.0) - fraction of total distance traveled.
67    pub distance_coefficient: f32,
68    /// Velocity coefficient - instantaneous velocity at this point.
69    pub velocity_coefficient: f32,
70}
71
72/// Android fling spline implementation.
73///
74/// This is a port of Android's native fling scroll physics from `android.widget.Scroller`.
75/// It provides smooth, natural-feeling fling deceleration.
76pub struct AndroidFlingSpline;
77
78impl AndroidFlingSpline {
79    /// Sample the spline at a given time (0.0 to 1.0).
80    ///
81    /// Returns coefficients for distance and velocity at that point in the fling.
82    pub fn fling_position(time: f32) -> FlingResult {
83        let clamped_time = time.clamp(0.0, 1.0);
84        let index = (NB_SAMPLES as f32 * clamped_time) as usize;
85
86        let (distance_coef, velocity_coef) = if index < NB_SAMPLES {
87            let t_inf = index as f32 / NB_SAMPLES as f32;
88            let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
89            let d_inf = SPLINE_DATA.positions[index];
90            let d_sup = SPLINE_DATA.positions[index + 1];
91            let vel = (d_sup - d_inf) / (t_sup - t_inf);
92            let dist = d_inf + (clamped_time - t_inf) * vel;
93            (dist, vel)
94        } else {
95            (1.0, 0.0)
96        };
97
98        FlingResult {
99            distance_coefficient: distance_coef,
100            velocity_coefficient: velocity_coef,
101        }
102    }
103
104    /// Compute deceleration rate for a given velocity and friction.
105    pub fn deceleration(velocity: f32, friction: f32) -> f64 {
106        (INFLECTION as f64 * velocity.abs() as f64 / friction as f64).ln()
107    }
108}
109
110// ============================================================================
111// Fling Calculator
112// ============================================================================
113
114/// Earth's gravity in SI units (m/s²)
115const GRAVITY_EARTH: f32 = 9.80665;
116/// Inches per meter (for density conversion)
117const INCHES_PER_METER: f32 = 39.37;
118/// Deceleration rate constant (from Android Scroller)
119const DECELERATION_RATE: f32 = 2.358_201_6; // (ln(0.78) / ln(0.9)).abs()
120
121/// Computes physical deceleration based on density and friction.
122fn compute_deceleration(friction: f32, density: f32) -> f32 {
123    GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * friction
124}
125
126/// Information about a fling animation.
127#[derive(Debug, Clone, Copy)]
128pub struct FlingInfo {
129    /// Initial velocity in px/sec.
130    pub initial_velocity: f32,
131    /// Total distance that will be traveled.
132    pub distance: f32,
133    /// Total duration in milliseconds.
134    pub duration: i64,
135}
136
137impl FlingInfo {
138    /// Get position at a given time (in milliseconds).
139    pub fn position(&self, time_ms: i64) -> f32 {
140        let spline_pos = if self.duration > 0 {
141            time_ms as f32 / self.duration as f32
142        } else {
143            1.0
144        };
145        self.distance
146            * self.initial_velocity.signum()
147            * AndroidFlingSpline::fling_position(spline_pos).distance_coefficient
148    }
149
150    /// Get velocity at a given time (in milliseconds), in px/sec.
151    pub fn velocity(&self, time_ms: i64) -> f32 {
152        let spline_pos = if self.duration > 0 {
153            time_ms as f32 / self.duration as f32
154        } else {
155            1.0
156        };
157        AndroidFlingSpline::fling_position(spline_pos).velocity_coefficient
158            * self.initial_velocity.signum()
159            * self.distance
160            / self.duration as f32
161            * 1000.0
162    }
163
164    /// Check if the fling is finished at the given time.
165    pub fn is_finished(&self, time_ms: i64) -> bool {
166        time_ms >= self.duration
167    }
168}
169
170/// Calculator for Android-feel fling animations.
171///
172/// This uses the Android Scroller physics to compute natural fling behavior
173/// based on physical constants and screen density.
174#[derive(Debug, Clone, Copy)]
175pub struct FlingCalculator {
176    friction: f32,
177    magic_physical_coefficient: f32,
178}
179
180impl FlingCalculator {
181    /// Default friction value (matches Android default)
182    pub const DEFAULT_FRICTION: f32 = 0.015;
183
184    /// Create a new fling calculator.
185    ///
186    /// # Arguments
187    /// * `friction` - Scroll friction coefficient (higher = faster deceleration)
188    /// * `density` - Screen density in dp (e.g., 1.0 for mdpi, 2.0 for xhdpi)
189    pub fn new(friction: f32, density: f32) -> Self {
190        Self {
191            friction,
192            magic_physical_coefficient: compute_deceleration(0.84, density),
193        }
194    }
195
196    /// Create a calculator with default friction for the given density.
197    pub fn with_density(density: f32) -> Self {
198        Self::new(Self::DEFAULT_FRICTION, density)
199    }
200
201    fn spline_deceleration(&self, velocity: f32) -> f64 {
202        AndroidFlingSpline::deceleration(velocity, self.friction * self.magic_physical_coefficient)
203    }
204
205    /// Compute the duration of a fling in milliseconds.
206    pub fn fling_duration(&self, velocity: f32) -> i64 {
207        let l = self.spline_deceleration(velocity);
208        let decel_minus_one = DECELERATION_RATE as f64 - 1.0;
209        (1000.0 * (l / decel_minus_one).exp()) as i64
210    }
211
212    /// Compute the total distance a fling will travel.
213    pub fn fling_distance(&self, velocity: f32) -> f32 {
214        let l = self.spline_deceleration(velocity);
215        let decel_minus_one = DECELERATION_RATE as f64 - 1.0;
216        self.friction
217            * self.magic_physical_coefficient
218            * (DECELERATION_RATE as f64 / decel_minus_one * l).exp() as f32
219    }
220
221    /// Get complete fling information for a given initial velocity.
222    pub fn fling_info(&self, velocity: f32) -> FlingInfo {
223        FlingInfo {
224            initial_velocity: velocity,
225            distance: self.fling_distance(velocity),
226            duration: self.fling_duration(velocity),
227        }
228    }
229}
230
231// ============================================================================
232// Decay Animation Spec
233// ============================================================================
234
235/// Trait for decay animation specifications.
236///
237/// A decay animation has no fixed target - it starts with a velocity and
238/// decelerates to zero. The final position depends on the initial velocity.
239pub trait FloatDecayAnimationSpec {
240    /// Velocity threshold below which animation is considered finished.
241    fn abs_velocity_threshold(&self) -> f32;
242
243    /// Get position at a given time.
244    fn get_value_from_nanos(
245        &self,
246        play_time_nanos: i64,
247        initial_value: f32,
248        initial_velocity: f32,
249    ) -> f32;
250
251    /// Get velocity at a given time.
252    fn get_velocity_from_nanos(
253        &self,
254        play_time_nanos: i64,
255        initial_value: f32,
256        initial_velocity: f32,
257    ) -> f32;
258
259    /// Get total animation duration in nanoseconds.
260    fn get_duration_nanos(&self, initial_value: f32, initial_velocity: f32) -> i64;
261
262    /// Get the target value (final position) of the animation.
263    fn get_target_value(&self, initial_value: f32, initial_velocity: f32) -> f32;
264}
265
266/// Spline-based decay animation spec matching Android fling behavior.
267#[derive(Debug, Clone, Copy)]
268pub struct SplineBasedDecaySpec {
269    calculator: FlingCalculator,
270}
271
272impl SplineBasedDecaySpec {
273    /// Create a new spline-based decay spec for the given density.
274    pub fn new(density: f32) -> Self {
275        Self {
276            calculator: FlingCalculator::with_density(density),
277        }
278    }
279
280    /// Create a spec with a custom FlingCalculator.
281    pub fn with_calculator(calculator: FlingCalculator) -> Self {
282        Self { calculator }
283    }
284}
285
286impl FloatDecayAnimationSpec for SplineBasedDecaySpec {
287    fn abs_velocity_threshold(&self) -> f32 {
288        0.0
289    }
290
291    fn get_value_from_nanos(
292        &self,
293        play_time_nanos: i64,
294        initial_value: f32,
295        initial_velocity: f32,
296    ) -> f32 {
297        let time_ms = play_time_nanos / 1_000_000;
298        let info = self.calculator.fling_info(initial_velocity);
299        initial_value + info.position(time_ms)
300    }
301
302    fn get_velocity_from_nanos(
303        &self,
304        play_time_nanos: i64,
305        _initial_value: f32,
306        initial_velocity: f32,
307    ) -> f32 {
308        let time_ms = play_time_nanos / 1_000_000;
309        let info = self.calculator.fling_info(initial_velocity);
310        info.velocity(time_ms)
311    }
312
313    fn get_duration_nanos(&self, _initial_value: f32, initial_velocity: f32) -> i64 {
314        let duration_ms = self.calculator.fling_duration(initial_velocity);
315        duration_ms * 1_000_000
316    }
317
318    fn get_target_value(&self, initial_value: f32, initial_velocity: f32) -> f32 {
319        let distance = self.calculator.fling_distance(initial_velocity);
320        initial_value + distance * initial_velocity.signum()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_spline_endpoints() {
330        let start = AndroidFlingSpline::fling_position(0.0);
331        assert!((start.distance_coefficient - 0.0).abs() < 0.01);
332
333        let end = AndroidFlingSpline::fling_position(1.0);
334        assert!((end.distance_coefficient - 1.0).abs() < 0.01);
335    }
336
337    #[test]
338    fn test_spline_monotonic() {
339        let mut prev = 0.0;
340        for i in 0..=100 {
341            let t = i as f32 / 100.0;
342            let result = AndroidFlingSpline::fling_position(t);
343            assert!(
344                result.distance_coefficient >= prev,
345                "Spline should be monotonically increasing"
346            );
347            prev = result.distance_coefficient;
348        }
349    }
350
351    #[test]
352    fn test_fling_calculator() {
353        let calc = FlingCalculator::with_density(2.0); // xhdpi
354
355        // A typical fling velocity
356        let velocity = 5000.0; // px/sec
357        let duration = calc.fling_duration(velocity);
358        let distance = calc.fling_distance(velocity);
359
360        assert!(duration > 0, "Duration should be positive");
361        assert!(distance > 0.0, "Distance should be positive");
362
363        // Higher velocity should mean longer duration and distance
364        let high_velocity = 10000.0;
365        assert!(calc.fling_duration(high_velocity) > duration);
366        assert!(calc.fling_distance(high_velocity) > distance);
367    }
368
369    #[test]
370    fn test_decay_spec() {
371        let spec = SplineBasedDecaySpec::new(2.0);
372
373        let initial_value = 100.0;
374        let velocity = 5000.0;
375
376        // At t=0, position should be initial value
377        let pos_0 = spec.get_value_from_nanos(0, initial_value, velocity);
378        assert!((pos_0 - initial_value).abs() < 1.0);
379
380        // At end, position should be at target
381        let duration = spec.get_duration_nanos(initial_value, velocity);
382        let target = spec.get_target_value(initial_value, velocity);
383        let pos_end = spec.get_value_from_nanos(duration, initial_value, velocity);
384        assert!(
385            (pos_end - target).abs() < 10.0,
386            "End position {} should be near target {}",
387            pos_end,
388            target
389        );
390    }
391
392    #[test]
393    fn test_negative_velocity() {
394        let calc = FlingCalculator::with_density(2.0);
395
396        let velocity = -5000.0;
397        let info = calc.fling_info(velocity);
398
399        // Position should move in negative direction
400        let pos_mid = info.position(info.duration / 2);
401        assert!(pos_mid < 0.0, "Should move in negative direction");
402    }
403}