Skip to main content

frus_widgets/
stretch.rs

1//! The **overscroll stretch**: the content itself lengthens towards the edge a finger
2//! pulls past, and springs back when it lets go.
3//!
4//! It is the other answer to "there is nothing more that way", beside the
5//! [glow](crate::overscroll). A glow is light drawn over the content; a stretch is the
6//! content deformed. Current touch platforms stretch, and an application that glows on
7//! them looks older than it is.
8//!
9//! The amount follows the reference's controller, which ports the platform's own edge
10//! effect: a pull is normalised by the viewport's length and shaped by a linear and an
11//! exponential term; a fling that lands on the edge, or a finger that lets go, hands the
12//! stretch to a damped spring that brings it back to rest. The deformation is the
13//! reference's portable one: a **scale along the axis, anchored at the edge pulled**.
14
15use frus_core::animation::{Simulation, SpringDescription, SpringSimulation, Tolerance};
16
17use crate::overscroll::GlowEdge;
18
19/// The largest stretch, either way: the content at most doubles along the axis.
20const MAX_STRETCH: f32 = 1.0;
21/// The strength of both terms of a pull (`_stretchIntensity`).
22const INTENSITY: f32 = 0.016;
23/// How fast the exponential term saturates (`_exponentialScalar`, `e / 0.33`).
24const EXPONENTIAL_SCALAR: f32 = std::f32::consts::E / 0.33;
25/// Turns a fling's velocity, in px/s, into the spring's (`_absorbImpactVelocityFriction`).
26const IMPACT_FRICTION: f32 = 1.0 / 3000.0;
27/// The most an impact may throw the stretch (`_maxAbsorbImpactVelocity`).
28const MAX_IMPACT_VELOCITY: f32 = 1.25;
29/// The platform's edge-effect spring: its natural frequency, in rad/s, and its damping.
30const NATURAL_FREQUENCY: f32 = 24.657;
31const DAMPING_RATIO: f32 = 0.98;
32/// The reference slows the platform's spring by this much to match it by eye
33/// (`kTimeCorrectionFactor`): the stiffness by its square, the initial velocity by it.
34const TIME_CORRECTION: f32 = 0.8;
35
36/// The spring that brings a stretch back to rest.
37fn spring() -> SpringDescription {
38    let stiffness = NATURAL_FREQUENCY * NATURAL_FREQUENCY * TIME_CORRECTION * TIME_CORRECTION;
39    SpringDescription::with_damping_ratio(1.0, stiffness, DAMPING_RATIO)
40}
41
42/// The stretch along one axis of one scrollable.
43///
44/// Its amount is signed: **negative towards the start** of the axis (the top, or the
45/// left), positive towards the end — the physical edge, whichever way the content scrolls.
46#[derive(Copy, Clone, Debug, Default)]
47pub struct OverscrollStretch {
48    amount: f32,
49    /// The pull accumulated since the gesture started, in pixels, signed like `amount`.
50    pulled: f32,
51    /// The spring running, and how long it has run, while the stretch returns.
52    spring: Option<(SpringSimulation, f32)>,
53    /// What a spring interrupted by a new pull had reached: the pull carries on from it,
54    /// rather than jumping (`_interruptedOverscroll`).
55    interrupted: f32,
56}
57
58impl OverscrollStretch {
59    /// The stretch now, in `-1..=1`: `0` is the content as it is laid out.
60    pub fn amount(&self) -> f32 {
61        self.amount
62    }
63
64    /// Is there nothing to deform and nothing moving?
65    pub fn is_idle(&self) -> bool {
66        self.spring.is_none() && self.amount == 0.0
67    }
68
69    fn set(&mut self, amount: f32) {
70        self.amount = amount.clamp(-MAX_STRETCH, MAX_STRETCH);
71    }
72
73    /// A finger dragged `overscroll` pixels past the edge (a magnitude), towards the
74    /// start of the axis or its end, in a viewport `extent` long.
75    pub fn pull(&mut self, overscroll: f32, towards_start: bool, extent: f32) {
76        if let Some((spring, t)) = self.spring.take() {
77            self.interrupted = spring.x(t);
78        }
79        let sign = if towards_start { -1.0 } else { 1.0 };
80        self.pulled += sign * overscroll.abs();
81        // Clamped to one viewport, the furthest a single finger could pull.
82        let distance = (self.pulled / extent.max(1.0)).clamp(-1.0, 1.0);
83        let far = distance.abs();
84        let linear = INTENSITY * far;
85        let exponential = INTENSITY * (1.0 - (-far * EXPONENTIAL_SCALAR).exp());
86        self.set(distance.signum() * (linear + exponential) + self.interrupted);
87    }
88
89    /// A fling reached the edge at `velocity` px/s (a magnitude), towards the start or the
90    /// end: the stretch is thrown that way and springs back.
91    pub fn absorb_impact(&mut self, velocity: f32, towards_start: bool) {
92        if velocity == 0.0 {
93            return;
94        }
95        let sign = if towards_start { -1.0 } else { 1.0 };
96        let thrown = (sign * velocity.abs() * IMPACT_FRICTION)
97            .clamp(-MAX_IMPACT_VELOCITY, MAX_IMPACT_VELOCITY);
98        self.release_with(thrown);
99    }
100
101    /// The gesture is over, or the content moved again: whatever is stretched returns.
102    pub fn release(&mut self) {
103        self.pulled = 0.0;
104        if self.amount == 0.0 || self.spring.is_some() {
105            return;
106        }
107        self.release_with(0.0);
108    }
109
110    fn release_with(&mut self, velocity: f32) {
111        self.pulled = 0.0;
112        self.spring = Some((
113            SpringSimulation::new(
114                spring(),
115                self.amount,
116                0.0,
117                velocity * TIME_CORRECTION,
118                Tolerance::default(),
119            ),
120            0.0,
121        ));
122    }
123
124    /// Advances the spring by `dt` seconds. Returns `true` while it is still moving.
125    pub fn advance(&mut self, dt: f32) -> bool {
126        let Some((spring, t)) = self.spring.as_mut() else {
127            return false;
128        };
129        *t += dt;
130        if spring.is_done(*t) {
131            self.spring = None;
132            self.amount = 0.0;
133            self.interrupted = 0.0;
134            return false;
135        }
136        let x = spring.x(*t);
137        self.set(x);
138        true
139    }
140}
141
142/// Both axes of one scrollable's stretch.
143#[derive(Copy, Clone, Debug, Default)]
144pub struct ScrollStretch {
145    /// Along the horizontal axis.
146    pub horizontal: OverscrollStretch,
147    /// Along the vertical axis.
148    pub vertical: OverscrollStretch,
149}
150
151impl ScrollStretch {
152    /// The axis that `edge` lies across, to drive.
153    pub fn axis_mut(&mut self, edge: GlowEdge) -> &mut OverscrollStretch {
154        match edge {
155            GlowEdge::Top | GlowEdge::Bottom => &mut self.vertical,
156            GlowEdge::Left | GlowEdge::Right => &mut self.horizontal,
157        }
158    }
159
160    /// Is neither axis stretched nor moving?
161    pub fn is_idle(&self) -> bool {
162        self.horizontal.is_idle() && self.vertical.is_idle()
163    }
164
165    /// Advances both. Returns `true` while either is still moving.
166    pub fn advance(&mut self, dt: f32) -> bool {
167        let x = self.horizontal.advance(dt);
168        let y = self.vertical.advance(dt);
169        x || y
170    }
171
172    /// Lets both go.
173    pub fn release(&mut self) {
174        self.horizontal.release();
175        self.vertical.release();
176    }
177
178    /// The scale to draw the content with, `(sx, sy)`, and the point it is anchored at,
179    /// in `viewport`: the edge each axis is stretched from stays where it is.
180    pub fn transform(&self, viewport: frus_core::Rect) -> Option<frus_core::Affine> {
181        let (h, v) = (self.horizontal.amount(), self.vertical.amount());
182        if h == 0.0 && v == 0.0 {
183            return None;
184        }
185        let anchor = frus_core::Point::new(
186            if h > 0.0 {
187                viewport.x + viewport.width
188            } else {
189                viewport.x
190            },
191            if v > 0.0 {
192                viewport.y + viewport.height
193            } else {
194                viewport.y
195            },
196        );
197        Some(frus_core::Affine::scale(1.0 + h.abs(), 1.0 + v.abs()).about(anchor))
198    }
199}
200
201/// Whether `edge` is the start of its axis — the top, or the left.
202pub(crate) fn is_start(edge: GlowEdge) -> bool {
203    matches!(edge, GlowEdge::Top | GlowEdge::Left)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn settle(stretch: &mut OverscrollStretch) -> f32 {
211        let mut t = 0.0;
212        while stretch.advance(1.0 / 60.0) {
213            t += 1.0 / 60.0;
214            assert!(t < 5.0, "a stretch comes to rest");
215        }
216        t
217    }
218
219    /// The reference's numbers: a pull of a whole viewport is `0.016 · (1 + 1 − e^(−e/0.33))`.
220    #[test]
221    fn a_pull_follows_the_platform_curve() {
222        let mut stretch = OverscrollStretch::default();
223        stretch.pull(600.0, true, 600.0);
224        let expected = -(0.016 + 0.016 * (1.0 - (-EXPONENTIAL_SCALAR).exp()));
225        assert!(
226            (stretch.amount() - expected).abs() < 1e-6,
227            "{}",
228            stretch.amount()
229        );
230    }
231
232    /// A pull accumulates: two halves are one whole, and more than a viewport is capped.
233    #[test]
234    fn a_pull_accumulates_up_to_one_viewport() {
235        let mut halves = OverscrollStretch::default();
236        halves.pull(300.0, false, 600.0);
237        halves.pull(300.0, false, 600.0);
238        let mut whole = OverscrollStretch::default();
239        whole.pull(600.0, false, 600.0);
240        assert!((halves.amount() - whole.amount()).abs() < 1e-6);
241        assert!(whole.amount() > 0.0, "towards the end is positive");
242        let mut more = OverscrollStretch::default();
243        more.pull(2000.0, false, 600.0);
244        assert_eq!(more.amount(), whole.amount());
245    }
246
247    /// A longer pull stretches more, and the start and the end stretch opposite ways.
248    #[test]
249    fn a_longer_pull_stretches_more() {
250        let mut short = OverscrollStretch::default();
251        short.pull(20.0, true, 600.0);
252        let mut long = OverscrollStretch::default();
253        long.pull(200.0, true, 600.0);
254        assert!(long.amount() < short.amount() && short.amount() < 0.0);
255    }
256
257    /// Letting go springs back to nothing, and quickly: the platform's spring settles in
258    /// well under a second.
259    #[test]
260    fn letting_go_springs_back() {
261        let mut stretch = OverscrollStretch::default();
262        stretch.pull(300.0, true, 600.0);
263        stretch.release();
264        assert!(!stretch.is_idle());
265        let took = settle(&mut stretch);
266        assert!(stretch.is_idle() && stretch.amount() == 0.0);
267        assert!(took < 1.0, "{took}");
268    }
269
270    /// A held pull stays: nothing moves it until the finger lets go.
271    #[test]
272    fn a_held_pull_stays() {
273        let mut stretch = OverscrollStretch::default();
274        stretch.pull(300.0, true, 600.0);
275        let held = stretch.amount();
276        assert!(!stretch.advance(0.5));
277        assert_eq!(stretch.amount(), held);
278    }
279
280    /// A fling that lands throws the stretch out towards its edge, then it returns — and a
281    /// harder landing throws it further.
282    #[test]
283    fn an_impact_throws_the_stretch_and_it_returns() {
284        let peak = |velocity: f32| {
285            let mut stretch = OverscrollStretch::default();
286            stretch.absorb_impact(velocity, false);
287            let mut peak: f32 = 0.0;
288            while stretch.advance(1.0 / 240.0) {
289                peak = peak.max(stretch.amount());
290            }
291            assert_eq!(stretch.amount(), 0.0);
292            peak
293        };
294        let soft = peak(1000.0);
295        let hard = peak(3000.0);
296        assert!(soft > 0.0 && hard > soft, "{soft} {hard}");
297    }
298
299    /// A pull during the return picks up from where the spring had got to, rather than
300    /// snapping back first.
301    #[test]
302    fn a_pull_mid_return_carries_on_from_there() {
303        let mut stretch = OverscrollStretch::default();
304        stretch.pull(600.0, true, 600.0);
305        stretch.release();
306        stretch.advance(0.02);
307        let reached = stretch.amount();
308        stretch.pull(1.0, true, 600.0);
309        assert!(stretch.amount() < reached, "{} {reached}", stretch.amount());
310    }
311
312    /// The content is scaled along the stretched axis only, from the edge pulled.
313    #[test]
314    fn the_transform_holds_the_pulled_edge() {
315        let viewport = frus_core::Rect::new(10.0, 20.0, 100.0, 200.0);
316        let mut stretch = ScrollStretch::default();
317        assert!(stretch.transform(viewport).is_none());
318        stretch.axis_mut(GlowEdge::Top).pull(200.0, true, 200.0);
319        let m = stretch.transform(viewport).expect("stretched");
320        let top = m.apply(frus_core::Point::new(10.0, 20.0));
321        let bottom = m.apply(frus_core::Point::new(110.0, 220.0));
322        assert!(
323            (top.x - 10.0).abs() < 1e-4 && (top.y - 20.0).abs() < 1e-4,
324            "{top:?}"
325        );
326        assert!((bottom.x - 110.0).abs() < 1e-4, "not across: {bottom:?}");
327        assert!(bottom.y > 220.0, "down the axis: {bottom:?}");
328
329        let mut end = ScrollStretch::default();
330        end.axis_mut(GlowEdge::Bottom).pull(200.0, false, 200.0);
331        let m = end.transform(viewport).unwrap();
332        let bottom = m.apply(frus_core::Point::new(10.0, 220.0));
333        let top = m.apply(frus_core::Point::new(10.0, 20.0));
334        assert!((bottom.y - 220.0).abs() < 1e-4, "{bottom:?}");
335        assert!(top.y < 20.0, "{top:?}");
336    }
337}