Skip to main content

frus_widgets/
physics.rs

1//! SingleChildScrollView physics: **how a scrollable behaves at its edges and after the finger
2//! lifts**.
3//!
4//! Two families, because the platforms disagree and users notice:
5//!
6//! - [`ScrollPhysics::BOUNCING`] — dragging past the end is allowed but grows
7//!   progressively harder, and letting go springs the content back. A fling rolls
8//!   on friction and hands over to that spring at the edge.
9//! - [`ScrollPhysics::Clamping`] — the offset never leaves the content at all. A
10//!   fling follows the platform's spline deceleration and stops dead at the edge.
11//!
12//! [`ScrollPhysics::platform_default`] picks one at compile time, so an app that
13//! says nothing already feels native on each target. A [`crate::scroll::SingleChildScrollView`]
14//! can override it, and so can the application (`Application::scroll_physics`),
15//! for the cases where the content wants a particular feel.
16//!
17//! Everything here is a **pure function of the metrics**: no retained state, no
18//! clock. The runtime samples the returned [`Ballistic`] frame by frame; the shell
19//! calls [`ScrollPhysics::apply_user_offset`] while a finger is down. That split is
20//! what keeps the policy unit-testable without a window.
21
22use frus_core::{
23    BouncingScrollSimulation, ClampingScrollSimulation, Simulation, SpringDescription,
24    SpringSimulation, Tolerance,
25};
26
27/// The state of one scroll axis, as the physics sees it. All in logical pixels.
28///
29/// `pixels` is the current offset, `min`/`max` the offsets the content may rest
30/// at (`min` is 0 in practice, `max` the overflow), and `viewport` the visible
31/// extent along that axis — the yardstick the rubber band is measured against.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct ScrollMetrics {
34    /// The current scroll offset.
35    pub pixels: f32,
36    /// The smallest offset the content may rest at; 0 in practice.
37    pub min: f32,
38    /// The largest offset the content may rest at: how far the content overflows.
39    pub max: f32,
40    /// The visible extent along the axis.
41    pub viewport: f32,
42}
43
44impl ScrollMetrics {
45    /// Metrics for an axis scrolling from 0 to `max` in a `viewport`-long window.
46    pub fn new(pixels: f32, max: f32, viewport: f32) -> Self {
47        Self {
48            pixels,
49            min: 0.0,
50            max: max.max(0.0),
51            viewport: viewport.max(1.0),
52        }
53    }
54
55    /// Is the offset outside the range the content may rest at?
56    pub fn out_of_range(&self) -> bool {
57        self.pixels < self.min || self.pixels > self.max
58    }
59
60    /// How far past an edge the offset has been dragged; 0 while inside.
61    pub fn overscroll_past(&self) -> f32 {
62        (self.min - self.pixels)
63            .max(self.pixels - self.max)
64            .max(0.0)
65    }
66}
67
68/// The default minimum release speed, in px/s, below which nothing is flung.
69pub const MIN_FLING_VELOCITY: f32 = 50.0;
70/// The cap applied to a release speed, in px/s.
71pub const MAX_FLING_VELOCITY: f32 = 8000.0;
72
73/// **When a scroll area draws a scrollbar** (`app.dart:857`).
74///
75/// The reference asks this question per platform and answers it *no* on every touch
76/// screen: a finger already knows where it is on the page, and a permanent bar over the
77/// content is a desktop affordance for a pointer that cannot feel the edges. Along the
78/// **horizontal** axis the answer is no everywhere, on every platform.
79#[derive(Copy, Clone, PartialEq, Eq, Debug)]
80pub enum Scrollbars {
81    /// Never — what a touch screen gets (`app.dart:870`).
82    Never,
83    /// One bar down the inner edge (`app.dart:865`), for the platforms whose own scroll
84    /// views have one.
85    Always,
86}
87
88impl Default for Scrollbars {
89    fn default() -> Self {
90        Self::platform_default()
91    }
92}
93
94impl Scrollbars {
95    /// What the running platform expects: nothing on a touch screen, a bar on a desktop.
96    ///
97    /// Resolved at **compile time** from the target, like
98    /// [`ScrollPhysics::platform_default`] beside it — a build is for one platform, and a
99    /// constant keeps the choice out of the frame loop.
100    pub const fn platform_default() -> Self {
101        #[cfg(any(target_os = "android", target_os = "ios"))]
102        {
103            Scrollbars::Never
104        }
105        #[cfg(not(any(target_os = "android", target_os = "ios")))]
106        {
107            Scrollbars::Always
108        }
109    }
110}
111
112/// **How fast a fling's momentum is taken away** — the bouncing family's second
113/// deceleration profile.
114///
115/// It is chosen by **what is doing the scrolling**, not by how fast. A finger
116/// throws a surface and lets go of it; a trackpad or a wheel is a hand resting on a
117/// device that reports motion, and the two want different endings. So this is a
118/// property of the physics a scrollable is built with — settled once, from the
119/// platform — and not something recomputed per fling.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
121pub enum ScrollDecelerationRate {
122    /// A finger's. What a touch screen expects, and the default.
123    #[default]
124    Normal,
125    /// A trackpad's or a wheel's — what desktop software expects from a pointing
126    /// device more precise than a fingertip.
127    ///
128    /// Four things differ, not one: the fling carries a constant deceleration so
129    /// that it actually stops rather than coasting to a halt at infinity, the
130    /// rubber band is twice as stiff, a fling may be twice again as fast, and
131    /// **easing back out of an overscroll is not resisted at all** — which is a
132    /// behaviour rather than a number, and the one a reader notices.
133    Fast,
134}
135
136/// The behaviour of a scrollable at its edges and after a fling.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138pub enum ScrollPhysics {
139    /// Overscroll is allowed, resisted, and springs back — at the given rate.
140    Bouncing(ScrollDecelerationRate),
141    /// Overscroll is refused; a fling stops at the edge.
142    Clamping,
143}
144
145impl ScrollPhysics {
146    /// Bouncing physics at a finger's deceleration — the common case, and what
147    /// `ScrollPhysics::BOUNCING` meant before there were two.
148    pub const BOUNCING: Self = Self::Bouncing(ScrollDecelerationRate::Normal);
149
150    /// The deceleration profile in force, or `None` for a family that has none.
151    pub fn deceleration_rate(self) -> Option<ScrollDecelerationRate> {
152        match self {
153            ScrollPhysics::Bouncing(rate) => Some(rate),
154            ScrollPhysics::Clamping => None,
155        }
156    }
157}
158
159impl Default for ScrollPhysics {
160    fn default() -> Self {
161        Self::platform_default()
162    }
163}
164
165impl ScrollPhysics {
166    /// What the running platform expects. Bouncing where the system scroll views
167    /// bounce, clamping everywhere else.
168    ///
169    /// This is resolved at **compile time** from the target, not at runtime: a
170    /// build is for one platform, and a constant keeps the choice out of the frame
171    /// loop.
172    pub const fn platform_default() -> Self {
173        // A hand-held screen is scrolled with a finger; a desktop one with a
174        // trackpad or a wheel. That, and not the operating system's name, is what
175        // the two profiles are about — which is why the two bouncing platforms
176        // answer differently here.
177        #[cfg(target_os = "ios")]
178        {
179            ScrollPhysics::Bouncing(ScrollDecelerationRate::Normal)
180        }
181        #[cfg(target_os = "macos")]
182        {
183            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast)
184        }
185        #[cfg(not(any(target_os = "ios", target_os = "macos")))]
186        {
187            ScrollPhysics::Clamping
188        }
189    }
190
191    /// The spring that returns overscrolled content to its edge.
192    pub fn spring(self) -> SpringDescription {
193        SpringDescription::with_damping_ratio(0.5, 100.0, 1.1)
194    }
195
196    /// The thresholds under which the motion counts as finished.
197    pub fn tolerance(self) -> Tolerance {
198        Tolerance::PIXELS
199    }
200
201    /// The release speed under which a drag does not fling at all.
202    ///
203    /// Bouncing physics decelerates far more gently, so it asks for a more
204    /// deliberate gesture before it commits to a fling.
205    pub fn min_fling_velocity(self) -> f32 {
206        match self {
207            ScrollPhysics::Bouncing(_) => MIN_FLING_VELOCITY * 2.0,
208            ScrollPhysics::Clamping => MIN_FLING_VELOCITY,
209        }
210    }
211
212    /// The cap on a release speed.
213    ///
214    /// A pointing device can be flicked far harder than a finger can — a wheel
215    /// notch or a two-finger flick on a trackpad reports speeds no fingertip
216    /// reaches — so the fast profile lifts the ceiling eightfold rather than
217    /// clipping what the hardware actually said.
218    pub fn max_fling_velocity(self) -> f32 {
219        match self {
220            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast) => MAX_FLING_VELOCITY * 8.0,
221            _ => MAX_FLING_VELOCITY,
222        }
223    }
224
225    /// The distance a finger must cover before the content starts to move, in px.
226    ///
227    /// Bouncing physics asks for a little more, to swallow the small unintended
228    /// motion of a finger lifting off.
229    pub fn drag_start_distance_threshold(self) -> f32 {
230        match self {
231            ScrollPhysics::Bouncing(_) => 3.5,
232            ScrollPhysics::Clamping => 0.0,
233        }
234    }
235
236    /// The speed carried over when a fling is thrown on top of one already
237    /// running — the repeated-swipe speed-up.
238    ///
239    /// Only the bouncing family builds momentum this way; clamping starts each
240    /// fling from the gesture alone.
241    pub fn carried_momentum(self, existing_velocity: f32) -> f32 {
242        match self {
243            ScrollPhysics::Bouncing(_) => {
244                existing_velocity.signum()
245                    * (0.000_816 * existing_velocity.abs().powf(1.967)).min(40_000.0)
246            }
247            ScrollPhysics::Clamping => 0.0,
248        }
249    }
250
251    /// How much of a finger's `delta` actually reaches the offset.
252    ///
253    /// Inside the content it is all of it. Past an edge, the bouncing family
254    /// applies a friction that falls off quadratically with how far out we already
255    /// are: the further you pull, the less each pixel of finger buys — the rubber
256    /// band. Easing back towards the content is measured at the position the
257    /// gesture is heading *to*, so releasing tension is easier than adding it.
258    pub fn apply_user_offset(self, metrics: ScrollMetrics, delta: f32) -> f32 {
259        let Some(rate) = self.deceleration_rate() else {
260            return delta;
261        };
262        if delta == 0.0 || !metrics.out_of_range() {
263            return delta;
264        }
265        let past_start = (metrics.min - metrics.pixels).max(0.0);
266        let past_end = (metrics.pixels - metrics.max).max(0.0);
267        let past = past_start.max(past_end);
268        // `delta` is a change **to the offset**: past the start (`pixels < min`) a
269        // positive delta walks back towards the content, and past the end a
270        // negative one does. Those are the directions that release the band.
271        let easing = (past_start > 0.0 && delta > 0.0) || (past_end > 0.0 && delta < 0.0);
272        // **Easing out of an overscroll is unresisted on the fast profile**, and
273        // that is the difference a reader actually notices. A finger that pulled a
274        // list past its end is still holding the band and lets it back gradually; a
275        // trackpad has nothing to hold, so making the return sticky reads as the
276        // content refusing to come back rather than as tension.
277        if easing && rate == ScrollDecelerationRate::Fast {
278            return delta;
279        }
280        let fraction = if easing {
281            (past - delta.abs()) / metrics.viewport
282        } else {
283            past / metrics.viewport
284        };
285        delta.signum() * apply_friction(past, delta.abs(), friction_factor(rate, fraction))
286    }
287
288    /// The part of a proposed move that must be **refused**, so that the caller
289    /// can apply `value - refused`.
290    ///
291    /// Clamping physics refuses everything that would leave the content; bouncing
292    /// physics refuses nothing, and lets the spring deal with it.
293    pub fn apply_boundary_conditions(self, metrics: ScrollMetrics, value: f32) -> f32 {
294        if self != ScrollPhysics::Clamping {
295            return 0.0;
296        }
297        let p = metrics.pixels;
298        if value < p && p <= metrics.min {
299            return value - p; // already under the start: refuse the whole move
300        }
301        if metrics.max <= p && p < value {
302            return value - p; // already past the end
303        }
304        if value < metrics.min && metrics.min < p {
305            return value - metrics.min; // this move hits the start
306        }
307        if p < metrics.max && metrics.max < value {
308            return value - metrics.max; // this move hits the end
309        }
310        0.0
311    }
312
313    /// May the content be dragged past its edges at all?
314    pub fn allows_overscroll(self) -> bool {
315        matches!(self, ScrollPhysics::Bouncing(_))
316    }
317
318    /// The motion that follows the finger lifting off at `velocity` (px/s, positive
319    /// meaning the offset grows). `None` when nothing should move.
320    pub fn ballistic(self, metrics: ScrollMetrics, velocity: f32) -> Option<Ballistic> {
321        let tolerance = self.tolerance();
322        let velocity = velocity.clamp(-self.max_fling_velocity(), self.max_fling_velocity());
323        match self {
324            ScrollPhysics::Bouncing(rate) => {
325                if velocity.abs() >= tolerance.velocity || metrics.out_of_range() {
326                    Some(Ballistic::Bouncing(
327                        BouncingScrollSimulation::with_constant_deceleration(
328                            metrics.pixels,
329                            velocity,
330                            metrics.min,
331                            metrics.max,
332                            self.spring(),
333                            tolerance,
334                            match rate {
335                                ScrollDecelerationRate::Normal => 0.0,
336                                ScrollDecelerationRate::Fast => CONSTANT_DECELERATION_FAST,
337                            },
338                        ),
339                    ))
340                } else {
341                    None
342                }
343            }
344            ScrollPhysics::Clamping => {
345                // Out of range at all — after a resize, say — is a return to the
346                // edge, never a fling. The velocity may only help it home.
347                if metrics.out_of_range() {
348                    let end = if metrics.pixels > metrics.max {
349                        metrics.max
350                    } else {
351                        metrics.min
352                    };
353                    return Some(Ballistic::Spring(SpringSimulation::new(
354                        self.spring(),
355                        metrics.pixels,
356                        end,
357                        velocity.min(0.0),
358                        tolerance,
359                    )));
360                }
361                if velocity.abs() < tolerance.velocity
362                    || (velocity > 0.0 && metrics.pixels >= metrics.max)
363                    || (velocity < 0.0 && metrics.pixels <= metrics.min)
364                {
365                    return None;
366                }
367                Some(Ballistic::Clamping(ClampingScrollSimulation::new(
368                    metrics.pixels,
369                    velocity,
370                    tolerance,
371                )))
372            }
373        }
374    }
375
376    /// The motion that follows the finger lifting off a **paged** area, where the
377    /// content may not come to rest between two pages. `extent` is the distance
378    /// from one page to the next.
379    ///
380    /// This replaces the fling rather than correcting it afterwards: a paged view
381    /// never coasts. Whatever the release speed, the content springs to **one**
382    /// page — the next one on a flick, the nearer one otherwise. A fling that
383    /// crossed three pages and then had to be caught and pulled back would be a
384    /// different, worse gesture.
385    ///
386    /// Past an edge and still heading out, there is no page to go to, so the
387    /// ordinary physics takes over — which is what brings the overscroll home,
388    /// with the platform's own bounce or clamp.
389    pub fn page_ballistic(
390        self,
391        metrics: ScrollMetrics,
392        velocity: f32,
393        extent: f32,
394    ) -> Option<Ballistic> {
395        if (velocity <= 0.0 && metrics.pixels <= metrics.min)
396            || (velocity >= 0.0 && metrics.pixels >= metrics.max)
397        {
398            return self.ballistic(metrics, velocity);
399        }
400        let tolerance = self.tolerance();
401        let target = page_target(metrics, extent, velocity, tolerance.velocity);
402        if (target - metrics.pixels).abs() < tolerance.distance {
403            return None;
404        }
405        Some(Ballistic::Spring(SpringSimulation::new(
406            self.spring(),
407            metrics.pixels,
408            target,
409            velocity,
410            tolerance,
411        )))
412    }
413}
414
415/// Which page an offset sits on, as a **fraction**: `1.5` is halfway between the
416/// second and the third. `extent` is the distance from one page to the next.
417pub fn page_of(pixels: f32, extent: f32) -> f32 {
418    pixels / extent.max(1.0)
419}
420
421/// The offset the content must come to rest at after a release at `velocity`.
422///
423/// The rule is the one every paged view uses, and it is not "nearest page":
424/// **any** release with speed above the tolerance counts as a flick and moves the
425/// page half a unit *the way the finger went* before rounding. So a flick that
426/// covered a tenth of the viewport still turns the page, while letting go slowly
427/// falls back to whichever page is nearer. A gesture is an intention, not a
428/// measurement.
429pub fn page_target(metrics: ScrollMetrics, extent: f32, velocity: f32, tolerance: f32) -> f32 {
430    let mut page = page_of(metrics.pixels, extent);
431    if velocity < -tolerance {
432        page -= 0.5;
433    } else if velocity > tolerance {
434        page += 0.5;
435    }
436    // A view whose content is not a whole number of pages — every one of them once
437    // the pages are narrower than the viewport — has a last page that is not at a
438    // page boundary. Resting past the end is not a page, it is an overscroll.
439    (page.round() * extent.max(1.0)).clamp(metrics.min, metrics.max)
440}
441
442/// The resting place a **snapping sheet** heads for when the finger lets go at `position`
443/// moving at `velocity` — both in the sheet's own units, growing as it rises. `snaps`
444/// are the positions it may rest at, in increasing order.
445///
446/// The rule is the paged view's, stated on uneven stops rather than a fixed extent: a
447/// release slower than `tolerance` goes to the **nearer** of the two stops around it,
448/// and any faster one to the next stop **the way it was going**, however little of the
449/// way it had come. A position already on a stop stays there, and one outside the stops
450/// comes back to the nearest end.
451pub fn snap_target(position: f32, velocity: f32, snaps: &[f32], tolerance: f32) -> f32 {
452    let (Some(&first), Some(&last)) = (snaps.first(), snaps.last()) else {
453        return position;
454    };
455    let Some(next) = snaps.iter().position(|&snap| snap >= position) else {
456        return last;
457    };
458    if next == 0 {
459        return first;
460    }
461    let (below, above) = (snaps[next - 1], snaps[next]);
462    if above == position {
463        return above;
464    }
465    if velocity.abs() <= tolerance {
466        return if position - below < above - position {
467            below
468        } else {
469            above
470        };
471    }
472    if velocity < 0.0 {
473        below
474    } else {
475        above
476    }
477}
478
479/// The constant deceleration the **fast** profile adds on top of the drag, in
480/// px·s⁻² — the reference's own figure.
481///
482/// Drag alone is geometric: the velocity is multiplied down each second and reaches
483/// nought only at infinity, which is exactly right for a surface that was thrown.
484/// A trackpad gesture is not a throw, and a motion that never quite settles under
485/// one reads as the content drifting. This term is what gives it an end.
486pub const CONSTANT_DECELERATION_FAST: f32 = 1400.0;
487
488/// The friction applied to the first pixels of overscroll, before the band
489/// stiffens. It falls off quadratically with how far out the drag already is.
490///
491/// The **fast** profile halves the starting figure, which is to say it doubles the
492/// stiffness: a trackpad reports a great deal of motion for a small gesture, and a
493/// band as slack as a finger's would let a flick throw the content a long way out.
494fn friction_factor(rate: ScrollDecelerationRate, overscroll_fraction: f32) -> f32 {
495    let base = match rate {
496        ScrollDecelerationRate::Normal => 0.52,
497        ScrollDecelerationRate::Fast => 0.26,
498    };
499    (1.0 - overscroll_fraction).powi(2) * base
500}
501
502/// Applies `gamma` to the part of `delta` that is spent outside the content, and
503/// none of it to the part that is spent inside — a drag that crosses the edge
504/// mid-way is resisted only for the half that is out.
505fn apply_friction(extent_outside: f32, mut delta: f32, gamma: f32) -> f32 {
506    let mut total = 0.0;
507    if extent_outside > 0.0 && gamma > 0.0 {
508        let delta_to_limit = extent_outside / gamma;
509        if delta < delta_to_limit {
510            return delta * gamma;
511        }
512        total += extent_outside;
513        delta -= delta_to_limit;
514    }
515    total + delta
516}
517
518/// The motion a scrollable follows once the finger has let go.
519///
520/// One enum rather than a boxed trait object: it stays `Copy`, so the runtime can
521/// keep one per axis in a plain map and sample it without an allocation per frame.
522#[derive(Clone, Copy, Debug)]
523pub enum Ballistic {
524    /// Friction then spring-back — the bouncing family.
525    Bouncing(BouncingScrollSimulation),
526    /// The platform spline deceleration — the clamping family.
527    Clamping(ClampingScrollSimulation),
528    /// A plain return to an edge, with no fling.
529    Spring(SpringSimulation),
530}
531
532impl Simulation for Ballistic {
533    fn x(&self, time: f32) -> f32 {
534        match self {
535            Ballistic::Bouncing(s) => s.x(time),
536            Ballistic::Clamping(s) => s.x(time),
537            Ballistic::Spring(s) => s.x(time),
538        }
539    }
540
541    fn dx(&self, time: f32) -> f32 {
542        match self {
543            Ballistic::Bouncing(s) => s.dx(time),
544            Ballistic::Clamping(s) => s.dx(time),
545            Ballistic::Spring(s) => s.dx(time),
546        }
547    }
548
549    fn is_done(&self, time: f32) -> bool {
550        match self {
551            Ballistic::Bouncing(s) => s.is_done(time),
552            Ballistic::Clamping(s) => s.is_done(time),
553            Ballistic::Spring(s) => s.is_done(time),
554        }
555    }
556
557    fn tolerance(&self) -> Tolerance {
558        match self {
559            Ballistic::Bouncing(s) => s.tolerance(),
560            Ballistic::Clamping(s) => s.tolerance(),
561            Ballistic::Spring(s) => s.tolerance(),
562        }
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    const STOPS: [f32; 3] = [200.0, 400.0, 800.0];
571
572    /// **Let go slowly, and a sheet rests at the nearer stop** — measured on the uneven
573    /// gaps a sheet has, not on a page extent: 290 is nearer 200, 310 nearer 400, and 700
574    /// nearer 800 although it is past the middle of nothing a page view would recognise.
575    #[test]
576    fn a_slow_release_rests_at_the_nearer_stop() {
577        assert_eq!(snap_target(290.0, 0.0, &STOPS, 1.0), 200.0);
578        assert_eq!(snap_target(310.0, 0.0, &STOPS, 1.0), 400.0);
579        assert_eq!(snap_target(550.0, 0.0, &STOPS, 1.0), 400.0);
580        assert_eq!(snap_target(700.0, 0.5, &STOPS, 1.0), 800.0);
581    }
582
583    /// **Any flick goes to the next stop the way it went**, however little of the way it
584    /// had come — and never further than the next one, however hard.
585    #[test]
586    fn a_flick_goes_to_the_next_stop_its_way_and_no_further() {
587        assert_eq!(snap_target(210.0, 50.0, &STOPS, 1.0), 400.0);
588        assert_eq!(snap_target(790.0, -50.0, &STOPS, 1.0), 400.0);
589        assert_eq!(snap_target(210.0, 9000.0, &STOPS, 1.0), 400.0);
590        assert_eq!(snap_target(390.0, -9000.0, &STOPS, 1.0), 200.0);
591    }
592
593    /// On a stop it stays, whichever way it was nudged; outside them it comes back to the
594    /// nearest end; with no stops it rests where it is.
595    #[test]
596    fn a_stop_holds_and_the_ends_bound() {
597        assert_eq!(snap_target(400.0, 500.0, &STOPS, 1.0), 400.0);
598        assert_eq!(snap_target(150.0, -500.0, &STOPS, 1.0), 200.0);
599        assert_eq!(snap_target(900.0, 500.0, &STOPS, 1.0), 800.0);
600        assert_eq!(snap_target(333.0, 500.0, &[], 1.0), 333.0);
601    }
602
603    #[test]
604    fn inside_the_content_every_pixel_of_finger_reaches_the_offset() {
605        let metrics = ScrollMetrics::new(120.0, 400.0, 600.0);
606        for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::Clamping] {
607            assert_eq!(physics.apply_user_offset(metrics, 25.0), 25.0);
608            assert_eq!(physics.apply_user_offset(metrics, -25.0), -25.0);
609        }
610    }
611
612    #[test]
613    fn bouncing_resists_more_the_further_out_you_pull() {
614        let viewport = 600.0;
615        let near = ScrollMetrics::new(-10.0, 400.0, viewport);
616        let far = ScrollMetrics::new(-200.0, 400.0, viewport);
617        // Pulling further out (a negative delta past the start) is resisted, and
618        // resisted harder the further out we already are.
619        let a = ScrollPhysics::BOUNCING.apply_user_offset(near, -20.0);
620        let b = ScrollPhysics::BOUNCING.apply_user_offset(far, -20.0);
621        assert!(a < 0.0 && b < 0.0);
622        assert!(a.abs() < 20.0, "the band already resists: {a}");
623        assert!(b.abs() < a.abs(), "further out must be harder: {b} vs {a}");
624        // Clamping physics has no band at all.
625        assert_eq!(ScrollPhysics::Clamping.apply_user_offset(far, -20.0), -20.0);
626    }
627
628    #[test]
629    fn bouncing_lets_you_ease_back_more_easily_than_you_pulled() {
630        let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
631        let out = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
632        let back = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
633        assert!(
634            back.abs() > out.abs(),
635            "releasing the band ({back}) should be easier than loading it ({out})"
636        );
637    }
638
639    #[test]
640    fn clamping_refuses_exactly_what_would_leave_the_content() {
641        let physics = ScrollPhysics::Clamping;
642        let metrics = ScrollMetrics::new(390.0, 400.0, 600.0);
643        // A move that stays inside is untouched.
644        assert_eq!(physics.apply_boundary_conditions(metrics, 395.0), 0.0);
645        // One that crosses the end is trimmed to the end.
646        assert_eq!(physics.apply_boundary_conditions(metrics, 430.0), 30.0);
647        // Already at the end, pushing further: the whole move is refused.
648        let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
649        assert_eq!(physics.apply_boundary_conditions(at_end, 420.0), 20.0);
650        // The start behaves symmetrically.
651        let near_start = ScrollMetrics::new(5.0, 400.0, 600.0);
652        assert_eq!(physics.apply_boundary_conditions(near_start, -15.0), -15.0);
653        // Bouncing physics refuses nothing.
654        assert_eq!(
655            ScrollPhysics::BOUNCING.apply_boundary_conditions(at_end, 420.0),
656            0.0
657        );
658    }
659
660    #[test]
661    fn a_slow_release_flings_nothing() {
662        let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
663        assert!(ScrollPhysics::Clamping.ballistic(metrics, 1.0).is_none());
664        assert!(ScrollPhysics::BOUNCING.ballistic(metrics, 1.0).is_none());
665    }
666
667    #[test]
668    fn clamping_fling_stops_dead_inside_the_content() {
669        let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
670        let sim = ScrollPhysics::Clamping.ballistic(metrics, 800.0).unwrap();
671        assert!(matches!(sim, Ballistic::Clamping(_)));
672        // It travels, then finishes; the runtime clamps the tail into the content.
673        assert!(sim.x(0.1) > 100.0);
674        assert!(sim.is_done(2.0));
675        assert!(sim.dx(2.0).abs() < 1.0, "no residual speed");
676    }
677
678    #[test]
679    fn clamping_at_the_edge_pushing_outwards_does_nothing() {
680        let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
681        assert!(ScrollPhysics::Clamping.ballistic(at_end, 900.0).is_none());
682        let at_start = ScrollMetrics::new(0.0, 400.0, 600.0);
683        assert!(ScrollPhysics::Clamping
684            .ballistic(at_start, -900.0)
685            .is_none());
686    }
687
688    #[test]
689    fn clamping_out_of_range_returns_to_the_edge() {
690        // Overscroll can still happen without a drag — the content shrank under a
691        // resting offset. Clamping physics springs it home rather than flinging.
692        let past = ScrollMetrics::new(460.0, 400.0, 600.0);
693        let sim = ScrollPhysics::Clamping.ballistic(past, 0.0).unwrap();
694        assert!(matches!(sim, Ballistic::Spring(_)));
695        assert!(
696            (sim.x(2.0) - 400.0).abs() < 0.5,
697            "settles at {}",
698            sim.x(2.0)
699        );
700    }
701
702    #[test]
703    fn bouncing_overscroll_springs_back_even_with_no_velocity() {
704        let past = ScrollMetrics::new(-40.0, 400.0, 600.0);
705        let sim = ScrollPhysics::BOUNCING.ballistic(past, 0.0).unwrap();
706        assert!((sim.x(2.0) - 0.0).abs() < 0.5, "settles at {}", sim.x(2.0));
707        assert!(sim.is_done(2.0));
708    }
709
710    #[test]
711    fn a_fling_never_exceeds_the_velocity_cap() {
712        let metrics = ScrollMetrics::new(0.0, 100_000.0, 600.0);
713        let sim = ScrollPhysics::Clamping
714            .ballistic(metrics, 50_000.0)
715            .unwrap();
716        assert!(
717            sim.dx(0.0) <= MAX_FLING_VELOCITY + 1.0,
718            "clipped to the cap, got {}",
719            sim.dx(0.0)
720        );
721    }
722
723    #[test]
724    fn bouncing_asks_for_a_more_deliberate_gesture() {
725        assert!(
726            ScrollPhysics::BOUNCING.min_fling_velocity()
727                > ScrollPhysics::Clamping.min_fling_velocity()
728        );
729        // Repeated swipes build speed only where the platform does.
730        assert!(ScrollPhysics::BOUNCING.carried_momentum(1000.0) > 0.0);
731        assert_eq!(ScrollPhysics::Clamping.carried_momentum(1000.0), 0.0);
732        // …and the carry is signed like the motion it continues, and capped.
733        assert!(ScrollPhysics::BOUNCING.carried_momentum(-1000.0) < 0.0);
734        assert!(ScrollPhysics::BOUNCING.carried_momentum(100_000.0) <= 40_000.0);
735    }
736
737    #[test]
738    fn metrics_report_how_far_out_of_range_they_are() {
739        let inside = ScrollMetrics::new(50.0, 400.0, 600.0);
740        assert!(!inside.out_of_range());
741        assert_eq!(inside.overscroll_past(), 0.0);
742        let before = ScrollMetrics::new(-12.0, 400.0, 600.0);
743        assert!(before.out_of_range());
744        assert_eq!(before.overscroll_past(), 12.0);
745        let after = ScrollMetrics::new(430.0, 400.0, 600.0);
746        assert!(after.out_of_range());
747        assert_eq!(after.overscroll_past(), 30.0);
748    }
749
750    /// A three-page view, 300 px per page: offsets 0, 300, 600.
751    fn paged(pixels: f32) -> ScrollMetrics {
752        ScrollMetrics::new(pixels, 600.0, 300.0)
753    }
754
755    #[test]
756    fn letting_go_slowly_falls_back_to_the_nearer_page() {
757        assert_eq!(page_target(paged(130.0), 300.0, 0.0, 2.0), 0.0);
758        assert_eq!(page_target(paged(170.0), 300.0, 0.0, 2.0), 300.0);
759    }
760
761    #[test]
762    fn a_flick_turns_the_page_however_short_it_was() {
763        // A tenth of a page, thrown forward: the page turns.
764        assert_eq!(page_target(paged(30.0), 300.0, 900.0, 2.0), 300.0);
765        // And the same distance thrown back stays put rather than going forward.
766        assert_eq!(page_target(paged(30.0), 300.0, -900.0, 2.0), 0.0);
767        // Dragged most of the way and flicked back: the flick wins over the drag.
768        assert_eq!(page_target(paged(260.0), 300.0, -900.0, 2.0), 0.0);
769    }
770
771    #[test]
772    fn a_page_target_never_lands_outside_the_content() {
773        // Pages narrower than the viewport: the last one is not on a boundary.
774        let metrics = ScrollMetrics::new(430.0, 450.0, 300.0);
775        assert_eq!(page_target(metrics, 240.0, 900.0, 2.0), 450.0);
776        assert_eq!(
777            page_target(ScrollMetrics::new(5.0, 600.0, 300.0), 300.0, -900.0, 2.0),
778            0.0
779        );
780    }
781
782    #[test]
783    fn a_paged_release_always_settles_on_a_page() {
784        for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::Clamping] {
785            // Released mid-page with no speed at all: it still has to go somewhere.
786            let sim = physics.page_ballistic(paged(190.0), 0.0, 300.0).unwrap();
787            assert!(
788                (sim.x(3.0) - 300.0).abs() < 1.0,
789                "settled at {}",
790                sim.x(3.0)
791            );
792            // A hard fling crosses exactly one page, not three.
793            let sim = physics.page_ballistic(paged(10.0), 6000.0, 300.0).unwrap();
794            assert!(
795                (sim.x(3.0) - 300.0).abs() < 1.0,
796                "settled at {}",
797                sim.x(3.0)
798            );
799        }
800    }
801
802    #[test]
803    fn resting_on_a_page_starts_nothing() {
804        assert!(ScrollPhysics::Clamping
805            .page_ballistic(paged(300.0), 0.0, 300.0)
806            .is_none());
807    }
808
809    #[test]
810    fn past_an_edge_the_ordinary_physics_takes_the_content_home() {
811        // No page out there: bouncing springs back, clamping has nothing to do.
812        let out = ScrollMetrics::new(-40.0, 600.0, 300.0);
813        let sim = ScrollPhysics::BOUNCING
814            .page_ballistic(out, -50.0, 300.0)
815            .unwrap();
816        assert!((sim.x(3.0) - 0.0).abs() < 1.0, "settled at {}", sim.x(3.0));
817        assert!(ScrollPhysics::Clamping
818            .page_ballistic(ScrollMetrics::new(0.0, 600.0, 300.0), -900.0, 300.0)
819            .is_none());
820    }
821
822    #[test]
823    fn the_platform_default_is_the_one_this_build_targets() {
824        // The two bouncing platforms have not bounced alike since milestone 499 — a finger
825        // on the one, a trackpad on the other — and this said one `BOUNCING` for both. It
826        // was left behind, and only a macOS runner could see it: every build here is for
827        // Windows, Android or Linux, where the branch taken is the last one.
828        let expected = if cfg!(target_os = "ios") {
829            ScrollPhysics::Bouncing(ScrollDecelerationRate::Normal)
830        } else if cfg!(target_os = "macos") {
831            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast)
832        } else {
833            ScrollPhysics::Clamping
834        };
835        assert_eq!(ScrollPhysics::platform_default(), expected);
836        assert_eq!(ScrollPhysics::default(), expected);
837    }
838
839    // --- The second deceleration profile (milestone 499) ---
840
841    /// **The profile is a property of the device, not of the speed** — which is
842    /// the decision this milestone turns on, and the opposite of what #55 asked for.
843    ///
844    /// A finger's fling coasts: drag multiplies the velocity down each second and
845    /// only reaches nought at infinity, which is exactly what a thrown surface does.
846    /// A trackpad's stops. If the two were chosen by velocity, a hard fling and a
847    /// gentle one on the same device would end differently — so this asks the same
848    /// physics at two speeds three orders of magnitude apart and requires the same
849    /// kind of ending from both.
850    #[test]
851    fn the_profile_is_the_devices_and_never_the_speeds() {
852        // Content long enough that no fling here reaches an edge: what is being
853        // compared is the deceleration, and a spring taking over would be a
854        // different motion answering a different question.
855        let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
856        // Five seconds: long past where the fast profile has come to rest, and
857        // short of where `0.135^t` underflows a 32-bit float — which it does around
858        // twenty, and which would make a coasting fling look stopped for a reason
859        // that is about the arithmetic rather than about the physics.
860        let still_moving = |physics: ScrollPhysics, v: f32| {
861            let sim = physics.ballistic(metrics, v).expect("a fling");
862            sim.dx(5.0).abs() > 0.0
863        };
864        // Both within the finger cap, so the two speeds differ by nothing but
865        // themselves — the cap is one of the things the profiles disagree about.
866        for velocity in [300.0, 7000.0] {
867            assert!(
868                still_moving(ScrollPhysics::BOUNCING, velocity),
869                "a finger coasts, at {velocity} as at any other speed"
870            );
871            assert!(
872                !still_moving(
873                    ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast),
874                    velocity
875                ),
876                "and a trackpad stops, at {velocity} as at any other speed"
877            );
878        }
879    }
880
881    /// And the stop is not merely eventual: the fast profile comes to rest **short
882    /// of** where coasting would have carried it, which is the whole reason a
883    /// desktop list does not drift on after the hand has left the trackpad.
884    #[test]
885    fn a_trackpad_fling_stops_short_of_where_a_finger_would_coast() {
886        let metrics = ScrollMetrics::new(200.0, 40_000.0, 600.0);
887        let travel = |physics: ScrollPhysics| {
888            let sim = physics.ballistic(metrics, 3000.0).expect("a fling");
889            sim.x(30.0) - 200.0
890        };
891        let finger = travel(ScrollPhysics::BOUNCING);
892        let trackpad = travel(ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast));
893        assert!(finger > 0.0 && trackpad > 0.0, "both travel forwards");
894        assert!(
895            trackpad < finger,
896            "the trackpad stops short: {trackpad} vs {finger}"
897        );
898    }
899
900    /// **The fast band is twice as stiff.** A trackpad reports a great deal of
901    /// motion for a small gesture, and a band as slack as a finger's would let one
902    /// flick throw the content a long way out of its content.
903    #[test]
904    fn the_fast_band_resists_twice_as_hard() {
905        let metrics = ScrollMetrics::new(-10.0, 400.0, 600.0);
906        let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
907        let trackpad =
908            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, -20.0);
909        assert!(finger < 0.0 && trackpad < 0.0, "both pull further out");
910        assert!(
911            (trackpad / finger - 0.5).abs() < 0.05,
912            "half as much movement for the same gesture: {trackpad} vs {finger}"
913        );
914    }
915
916    /// **Easing back out of an overscroll is free on a trackpad**, and this is the
917    /// one difference that is a behaviour rather than a number.
918    ///
919    /// A finger that pulled a list past its end is still holding the band, and
920    /// letting it back gradually is what tension feels like. A trackpad is holding
921    /// nothing; the same stickiness reads as the content refusing to come back.
922    #[test]
923    fn easing_out_of_an_overscroll_is_unresisted_on_a_trackpad() {
924        let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
925        // Past the start, a positive delta walks back towards the content.
926        let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
927        let trackpad =
928            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, 20.0);
929        assert_eq!(trackpad, 20.0, "every pixel of the gesture comes back");
930        assert!(
931            finger < 20.0,
932            "where a finger still meets some resistance: {finger}"
933        );
934        // And tensioning is still resisted on both — it is the direction that
935        // differs, not the family.
936        assert!(
937            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast)
938                .apply_user_offset(metrics, -20.0)
939                .abs()
940                < 20.0,
941            "pulling further out is still resisted"
942        );
943    }
944
945    /// A pointing device reports speeds no fingertip reaches, so the fast profile
946    /// lifts the ceiling rather than clipping what the hardware actually said.
947    #[test]
948    fn a_trackpad_may_be_flung_eight_times_as_hard() {
949        assert_eq!(
950            ScrollPhysics::Bouncing(ScrollDecelerationRate::Fast).max_fling_velocity(),
951            ScrollPhysics::BOUNCING.max_fling_velocity() * 8.0
952        );
953        assert_eq!(
954            ScrollPhysics::Clamping.max_fling_velocity(),
955            ScrollPhysics::BOUNCING.max_fling_velocity(),
956            "and the clamping family is untouched"
957        );
958    }
959
960    /// **The finger profile is exactly what it always was.** Everything above adds
961    /// a second answer; none of it may change the first, which is what every
962    /// hand-held build of this framework runs.
963    #[test]
964    fn the_finger_profile_is_unchanged() {
965        assert_eq!(friction_factor(ScrollDecelerationRate::Normal, 0.0), 0.52);
966        assert_eq!(
967            ScrollPhysics::BOUNCING.max_fling_velocity(),
968            MAX_FLING_VELOCITY
969        );
970        // Its fling carries no constant deceleration, so it coasts — the property
971        // the closed-form friction has always had.
972        let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
973        let sim = ScrollPhysics::BOUNCING
974            .ballistic(metrics, 3000.0)
975            .expect("a fling");
976        assert!(
977            sim.dx(5.0).abs() > 0.0,
978            "still creeping at five seconds, as before"
979        );
980    }
981}