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)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn ios_deceleration_rates_match_apple_documented_constants() {
134        assert_eq!(IOS_DECELERATION_RATE_NORMAL, 0.998);
135        assert_eq!(IOS_DECELERATION_RATE_FAST, 0.99);
136    }
137
138    #[test]
139    fn value_at_zero_time_is_initial_value() {
140        let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
141        assert_eq!(spec.get_value_from_nanos(0, 100.0, 900.0), 100.0);
142    }
143
144    #[test]
145    fn velocity_at_zero_time_is_initial_velocity() {
146        let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
147        assert!((spec.get_velocity_from_nanos(0, 0.0, 900.0) - 900.0).abs() < 1e-3);
148    }
149
150    #[test]
151    fn value_converges_to_target_by_the_reported_duration() {
152        let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
153        let initial_value = 100.0;
154        let velocity = 5000.0;
155        let duration = spec.get_duration_nanos(initial_value, velocity);
156        let target = spec.get_target_value(initial_value, velocity);
157        let pos_end = spec.get_value_from_nanos(duration, initial_value, velocity);
158        assert!(
159            (pos_end - target).abs() < 1.0,
160            "end position {pos_end} should be near target {target}"
161        );
162    }
163
164    #[test]
165    fn negative_velocity_moves_target_backward() {
166        let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
167        let target = spec.get_target_value(0.0, -5000.0);
168        assert!(target < 0.0, "target {target} must be negative");
169    }
170
171    #[test]
172    fn fast_rate_decays_faster_than_normal_rate_for_the_same_velocity() {
173        let normal = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
174        let fast = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_FAST);
175        let velocity = 3000.0;
176        assert!(
177            fast.get_target_value(0.0, velocity) < normal.get_target_value(0.0, velocity),
178            "fast deceleration must travel a shorter distance than normal"
179        );
180        assert!(fast.get_duration_nanos(0.0, velocity) < normal.get_duration_nanos(0.0, velocity));
181    }
182
183    #[test]
184    fn target_matches_recorded_ios_target_content_offset_within_measured_tolerance() {
185        let spec = ExponentialDecaySpec::new(IOS_DECELERATION_RATE_NORMAL);
186        let target = spec.get_target_value(400.0, 480.8);
187        assert!(
188            (target - 635.33).abs() < 5.2,
189            "target {target} outside the measured 5.2pt tolerance"
190        );
191    }
192}