Skip to main content

frust_widgets/physics/
rubber_band.rs

1//! [`RubberBand`] — the iOS-style rubber-band feel `ScrollView`/`ListView`
2//! used to have wired in, lifted out of those two widgets and behind the
3//! [`ScrollPhysics`] seam unchanged. It is **no longer any platform's
4//! default**: both surfaces now install
5//! [`crate::physics::default_physics`]'s platform-parity choice, and this is
6//! the opt-in an app names (`.physics(RubberBand::new())`) to keep the
7//! pre-seam feel — a flat resistance with the widgets' own legacy
8//! fling/settle, rather than a depth-aware curve with a ballistic spring.
9//!
10//! # What lives here, and what deliberately does not
11//!
12//! Only the parts of the feel the trait has a seat for:
13//!
14//! * [`ScrollPhysics::apply_physics_to_user_offset`] — the past-edge portion of
15//!   a drag scaled by [`OVERSCROLL_RESISTANCE`], the in-range portion passed
16//!   through untouched.
17//! * [`ScrollPhysics::apply_boundary_conditions`] — nothing rejected: a
18//!   rubber-band surface is *allowed* to sit out of range, which is the whole
19//!   point of it.
20//! * [`ScrollPhysics::should_accept_user_offset`] — always `true`, overriding
21//!   the trait's has-scrollable-content default (below).
22//!
23//! The release-settle constants (`SETTLE_DECAY` 0.988 per ms, `SETTLE_STOP_PX`
24//! 0.5) stay in `scroll.rs` with the widgets, because they belong to the
25//! **legacy hand-rolled settle path**, not to this trait: the seam expresses
26//! post-release motion as a [`Simulation`] out of
27//! [`ScrollPhysics::create_ballistic_simulation`], and this physics returns
28//! `None` there on purpose (see the impl) so both widgets keep running their
29//! existing fling/settle code verbatim. Unifying the two is future work, not a
30//! behavior-preserving extraction. [`OVERSCROLL_RESISTANCE`] likewise keeps its
31//! existing home beside them: it is one definition, imported here, so both
32//! widgets' `pub(crate)` paths to it are untouched.
33//!
34//! # Under the surfaces' per-move drag convention
35//!
36//! The scroll surfaces hand every physics *this move's* delta measured from
37//! the live position (`scroll.rs`'s *Drag convention*). Because the mapping
38//! above is linear, a pull delivered over any number of moves telescopes to
39//! exactly the `0.5 × raw pull` the pre-seam whole-excursion re-map produced —
40//! every shipped rubber-band number is unchanged. The one place the two
41//! conventions part is a **pull-and-return inside a single gesture**: with no
42//! easing/tensioning split of its own (unlike `Bouncing`), this physics
43//! resists the past-edge part of a *returning* delta too, so bringing the
44//! finger all the way back leaves the surface slightly scrolled instead of
45//! exactly at rest. Pinned by `per_move_deltas_telescope_to_the_pre_seam_pull`
46//! rather than papered over — it is the cost of one convention for every
47//! physics, and the tensioning direction (all of pull-to-refresh, overscroll
48//! and stretch) is untouched by it.
49
50use super::{ScrollMetrics, ScrollPhysics, Simulation};
51use crate::scroll::OVERSCROLL_RESISTANCE;
52
53/// The iOS-style rubber-band scroll feel: a drag may pull the position past an
54/// edge, resisted by [`OVERSCROLL_RESISTANCE`], and nothing about a boundary is
55/// rejected. See the [module docs](self) for what the trait does *not* carry
56/// for this physics.
57#[derive(Debug, Default)]
58pub struct RubberBand {
59    parent: Option<Box<dyn ScrollPhysics>>,
60}
61
62impl RubberBand {
63    /// A standalone rubber-band physics (no chained parent).
64    pub fn new() -> Self {
65        Self { parent: None }
66    }
67
68    /// Chain `parent` behind this physics, the module-wide composition
69    /// convention ([`ScrollPhysics`]' *Chaining*): every method this type does
70    /// not override walks up to `parent`.
71    pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
72        Self {
73            parent: Some(Box::new(parent)),
74        }
75    }
76}
77
78impl ScrollPhysics for RubberBand {
79    fn parent(&self) -> Option<&dyn ScrollPhysics> {
80        self.parent.as_deref()
81    }
82
83    /// The past-edge portion of the drag is scaled by
84    /// [`OVERSCROLL_RESISTANCE`]; the portion that keeps the position inside
85    /// `[min, max]` passes through untouched. A delta straddling an edge is
86    /// split between the two rather than resisted whole.
87    fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
88        let min = metrics.min_scroll_extent;
89        let max = metrics.max_scroll_extent;
90        let start = metrics.pixels.clamp(min, max);
91        let end = (metrics.pixels + offset).clamp(min, max);
92        // The part of the delta that moves the position *within* range, and
93        // whatever is left over — the part that would leave it.
94        let in_range = end - start;
95        let past_edge = offset - in_range;
96        in_range + past_edge * OVERSCROLL_RESISTANCE
97    }
98
99    /// Nothing is ever rejected: a rubber-band surface holds an out-of-range
100    /// position for as long as the finger asks it to, and the release-settle
101    /// (not a boundary rule) is what brings it back.
102    fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
103        0.0
104    }
105
106    /// **No generic ballistic simulation** — both scroll surfaces keep their
107    /// own hand-rolled fling (`fling_decay`/`fling_displacement`) and
108    /// release-settle for this physics, which is exactly what makes installing
109    /// it a no-op on the shipped feel. A `None` here is the documented contract
110    /// for "the widget's legacy path owns post-release motion", not a gap to
111    /// fill in later by this type: a physics that *does* want the generic
112    /// driver returns a [`Simulation`], and the widget then drives that instead.
113    fn create_ballistic_simulation(
114        &self,
115        _metrics: &ScrollMetrics,
116        _velocity: f64,
117    ) -> Option<Box<dyn Simulation>> {
118        None
119    }
120
121    /// A re-fling during live motion starts cold, matching both widgets'
122    /// shipped behavior (a new `Down` kills the fling outright).
123    fn carried_momentum(&self, _existing_velocity: f64) -> f64 {
124        0.0
125    }
126
127    /// Always `true`, deliberately overriding the trait's
128    /// has-scrollable-content default: a `ScrollView`/`ListView` whose content
129    /// fits its viewport still rubber-bands under the finger today, and
130    /// matching the shipped feel wins over the more principled default.
131    fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
132        true
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn metrics(pixels: f64, max: f64) -> ScrollMetrics {
141        ScrollMetrics {
142            pixels,
143            min_scroll_extent: 0.0,
144            max_scroll_extent: max,
145            viewport_dimension: 100.0,
146            device_pixel_ratio: 1.0,
147        }
148    }
149
150    #[test]
151    fn rubber_band_drag_mapping_matches_legacy_math() {
152        let physics = RubberBand::new();
153
154        // Wholly in range: the identity mapping, both directions.
155        assert_eq!(
156            physics.apply_physics_to_user_offset(&metrics(0.0, 900.0), 30.0),
157            30.0
158        );
159        assert_eq!(
160            physics.apply_physics_to_user_offset(&metrics(500.0, 900.0), -120.0),
161            -120.0
162        );
163
164        // Pinned at the top edge, pulled 20px further past it: the widgets'
165        // `raw * OVERSCROLL_RESISTANCE`.
166        assert_eq!(
167            physics.apply_physics_to_user_offset(&metrics(0.0, 900.0), -20.0),
168            -10.0
169        );
170        // Pinned at the bottom edge: `(raw - max) * OVERSCROLL_RESISTANCE`.
171        assert_eq!(
172            physics.apply_physics_to_user_offset(&metrics(900.0, 900.0), 60.0),
173            30.0
174        );
175
176        // A delta straddling the top edge resists only its past-edge part:
177        // 5px of in-range travel, then 20px resisted to 10px.
178        assert_eq!(
179            physics.apply_physics_to_user_offset(&metrics(5.0, 900.0), -25.0),
180            -15.0
181        );
182
183        // Content that fits (max == min) is all past-edge in both directions.
184        assert_eq!(
185            physics.apply_physics_to_user_offset(&metrics(0.0, 0.0), 40.0),
186            20.0
187        );
188    }
189
190    /// The per-move delta convention, traced across a whole gesture — see the
191    /// [module docs](self)' section on it.
192    #[test]
193    fn per_move_deltas_telescope_to_the_pre_seam_pull() {
194        let physics = RubberBand::new();
195
196        // Four 10px moves past the top, each mapped from where the last left
197        // the position: 5px each, summing to exactly the 0.5 × 40 the
198        // whole-excursion re-map produced in one go.
199        let mut position = 0.0;
200        for _ in 0..4 {
201            position += physics.apply_physics_to_user_offset(&metrics(position, 900.0), -10.0);
202        }
203        assert_eq!(position, -20.0);
204        assert_eq!(
205            physics.apply_physics_to_user_offset(&metrics(0.0, 900.0), -40.0),
206            -20.0,
207            "…the same number the whole pull in one move gives"
208        );
209
210        // Easing back is not symmetric: the past-edge part of the returning
211        // delta is resisted too, so the finger coming all the way back leaves
212        // the surface 10px scrolled rather than exactly at rest.
213        let returned =
214            position + physics.apply_physics_to_user_offset(&metrics(position, 900.0), 40.0);
215        assert_eq!(returned, 10.0);
216    }
217
218    #[test]
219    fn rubber_band_rejects_nothing_and_carries_no_momentum() {
220        let physics = RubberBand::new();
221        let m = metrics(0.0, 900.0);
222        assert_eq!(physics.apply_boundary_conditions(&m, -500.0), 0.0);
223        assert_eq!(physics.apply_boundary_conditions(&m, 1400.0), 0.0);
224        assert_eq!(physics.carried_momentum(2000.0), 0.0);
225        assert!(physics.create_ballistic_simulation(&m, 2000.0).is_none());
226    }
227
228    #[test]
229    fn rubber_band_accepts_a_drag_even_when_content_fits() {
230        // The trait default would refuse (max == min, nothing to scroll); the
231        // shipped feel rubber-bands anyway, so this override wins.
232        assert!(RubberBand::new().should_accept_user_offset(&metrics(0.0, 0.0)));
233    }
234
235    /// A toy parent proving the chain is wired: `RubberBand` overrides neither
236    /// `min_fling_velocity` nor `spring`, so both must reach the parent.
237    #[derive(Debug)]
238    struct Slow;
239    impl ScrollPhysics for Slow {
240        fn min_fling_velocity(&self) -> f64 {
241            5.0
242        }
243    }
244
245    #[test]
246    fn chaining_delegates_unoverridden_methods_to_the_parent() {
247        let chained = RubberBand::new().chain(Slow);
248        assert_eq!(chained.min_fling_velocity(), 5.0);
249        // …while its own domain still answers locally.
250        assert_eq!(
251            chained.apply_physics_to_user_offset(&metrics(0.0, 900.0), -20.0),
252            -10.0
253        );
254        assert_eq!(
255            RubberBand::new().min_fling_velocity(),
256            crate::physics::MIN_FLING_VELOCITY
257        );
258    }
259}