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}