Skip to main content

cranpose_animation/
decay_spec.rs

1//! Decay animation specification for fling animations.
2//!
3//! Port of `UIScrollView`'s deceleration: a fling's velocity decays by a
4//! constant fraction every millisecond (`v(t) = v0 * rate^t`), which
5//! integrates to a closed-form position and rest offset. `rate` is exactly
6//! `UIScrollView.DecelerationRate`, read on-device in
7//! `cranpose-ui/src/tests/ios_fling_measurement.rs`; the exponential law
8//! itself, the rubber-band resistance curve, and the overscroll bounce spring
9//! were all fit against traces recorded from a real `UIScrollView` in the iOS
10//! Simulator (see that test module and the PR that introduced it for the
11//! recorded traces and residual error against iOS's own
12//! `targetContentOffset` predictions).
13
14/// `UIScrollView.DecelerationRate.normal.rawValue`, read at runtime on iOS
15/// 26.5 (matches Apple's documented constant).
16pub const IOS_DECELERATION_RATE_NORMAL: f32 = 0.998;
17
18/// `UIScrollView.DecelerationRate.fast.rawValue`, read at runtime on iOS
19/// 26.5 (matches Apple's documented constant).
20pub const IOS_DECELERATION_RATE_FAST: f32 = 0.99;
21
22/// Trait for decay animation specifications.
23///
24/// A decay animation has no fixed target - it starts with a velocity and
25/// decelerates to zero. The final position depends on the initial velocity.
26pub trait FloatDecayAnimationSpec {
27    /// Velocity threshold below which animation is considered finished.
28    fn abs_velocity_threshold(&self) -> f32;
29
30    /// Get position at a given time.
31    fn get_value_from_nanos(
32        &self,
33        play_time_nanos: i64,
34        initial_value: f32,
35        initial_velocity: f32,
36    ) -> f32;
37
38    /// Get velocity at a given time.
39    fn get_velocity_from_nanos(
40        &self,
41        play_time_nanos: i64,
42        initial_value: f32,
43        initial_velocity: f32,
44    ) -> f32;
45
46    /// Get total animation duration in nanoseconds.
47    fn get_duration_nanos(&self, initial_value: f32, initial_velocity: f32) -> i64;
48
49    /// Get the target value (final position) of the animation.
50    fn get_target_value(&self, initial_value: f32, initial_velocity: f32) -> f32;
51}
52
53const REST_VELOCITY_PTS_PER_SEC: f64 = 0.1;
54
55/// Exponential decay animation spec matching `UIScrollView`'s deceleration.
56///
57/// `initial_velocity` and the values returned by `get_velocity_from_nanos`
58/// are in points/sec (Cranpose's velocity-tracker convention throughout the
59/// gesture pipeline); internally the law is evaluated in points/ms, which is
60/// the unit `UIScrollView.DecelerationRate` and
61/// `scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)` use.
62#[derive(Debug, Clone, Copy)]
63pub struct ExponentialDecaySpec {
64    rate: f32,
65}
66
67impl ExponentialDecaySpec {
68    /// Creates a decay spec for the given per-millisecond decay `rate`
69    /// (e.g. [`IOS_DECELERATION_RATE_NORMAL`]).
70    pub fn new(rate: f32) -> Self {
71        Self { rate }
72    }
73
74    fn ln_rate(&self) -> f64 {
75        (self.rate as f64).ln()
76    }
77}
78
79impl Default for ExponentialDecaySpec {
80    fn default() -> Self {
81        Self::new(IOS_DECELERATION_RATE_NORMAL)
82    }
83}
84
85impl FloatDecayAnimationSpec for ExponentialDecaySpec {
86    fn abs_velocity_threshold(&self) -> f32 {
87        REST_VELOCITY_PTS_PER_SEC as f32
88    }
89
90    fn get_value_from_nanos(
91        &self,
92        play_time_nanos: i64,
93        initial_value: f32,
94        initial_velocity: f32,
95    ) -> f32 {
96        let t_ms = play_time_nanos as f64 / 1_000_000.0;
97        let v0_per_ms = initial_velocity as f64 / 1000.0;
98        let ln_rate = self.ln_rate();
99        let delta = v0_per_ms * ((self.rate as f64).powf(t_ms) - 1.0) / ln_rate;
100        initial_value + delta as f32
101    }
102
103    fn get_velocity_from_nanos(
104        &self,
105        play_time_nanos: i64,
106        _initial_value: f32,
107        initial_velocity: f32,
108    ) -> f32 {
109        let t_ms = play_time_nanos as f64 / 1_000_000.0;
110        (initial_velocity as f64 * (self.rate as f64).powf(t_ms)) as f32
111    }
112
113    fn get_duration_nanos(&self, _initial_value: f32, initial_velocity: f32) -> i64 {
114        let v0 = initial_velocity.abs() as f64;
115        if v0 <= REST_VELOCITY_PTS_PER_SEC {
116            return 0;
117        }
118        let t_ms = (REST_VELOCITY_PTS_PER_SEC / v0).ln() / self.ln_rate();
119        (t_ms * 1_000_000.0) as i64
120    }
121
122    fn get_target_value(&self, initial_value: f32, initial_velocity: f32) -> f32 {
123        let v0_per_ms = initial_velocity as f64 / 1000.0;
124        initial_value - (v0_per_ms / self.ln_rate()) as f32
125    }
126}
127
128#[cfg(test)]
129#[path = "tests/decay_spec_tests.rs"]
130mod tests;