frust_widgets/physics/mod.rs
1//! Pluggable scroll physics, mirroring Flutter's `ScrollPhysics` contract: a
2//! chainable strategy object a scroll surface (today [`crate::ScrollView`]/
3//! [`crate::ListView`], hard-coded to one behavior) consults for user-offset
4//! mapping, boundary rejection, and post-release ballistic motion instead of
5//! having that math wired in directly.
6//!
7//! # Module map
8//!
9//! Three seams, each its own file:
10//!
11//! * This file — the shared vocabulary every physics implementation and
12//! consumer builds against: [`ScrollMetrics`], [`Tolerance`],
13//! [`SpringDescription`], the [`Simulation`] trait, the fling velocity
14//! constants, and the [`ScrollPhysics`] trait itself.
15//! * [`effect`] — [`OverscrollEffect`], how boundary-rejected displacement is
16//! *visualized* (translate vs. paint-side stretch vs. none) — orthogonal to
17//! the physics that computes the displacement in the first place.
18//! * [`simulation`] — concrete [`Simulation`] implementations (the ballistic
19//! decay/spring curves a physics hands back from
20//! [`ScrollPhysics::create_ballistic_simulation`]).
21//! * [`rubber_band`] — the pre-seam rubber-band feel, now an opt-in.
22//! * [`parity`] — the platform-parity physics (`Bouncing`/`Clamping`/
23//! `AlwaysScrollable`/`NeverScrollable`) both scroll surfaces default to.
24//! * This file also carries the platform-adaptive default selection itself —
25//! [`default_physics`]/[`default_overscroll_effect`], what a `ScrollView`/
26//! `ListView` installs when the app names no physics of its own.
27//!
28//! # Design ruling: rejected excess is reported, not absorbed
29//!
30//! [`ScrollPhysics::apply_boundary_conditions`] returns the portion of a
31//! proposed position a physics *rejects* — the part the scroll position must
32//! not move to — separately from anything about how that rejection looks on
33//! screen. A widget accumulates the rejected excess itself (as its own
34//! `edge_pull` state, outside this module) rather than this trait owning any
35//! visual displacement. This is deliberate: pull-to-refresh triggering and the
36//! [`effect::OverscrollEffect::Stretch`] paint effect both need to read how far
37//! *past* the edge a gesture is pulling even under a **clamping** physics
38//! (`apply_boundary_conditions` rejecting 100% of the excess, i.e. the
39//! position itself never leaves range) — so a clamping physics still supports
40//! both features, it just never lets `edge_pull` show up as a position change.
41
42pub mod effect;
43pub mod parity;
44pub mod rubber_band;
45pub mod simulation;
46
47use std::rc::Rc;
48
49/// The physics a scroll surface installs when the app names none: **Android →
50/// [`parity::Clamping`]** (paired with [`effect::OverscrollEffect::Stretch`],
51/// the Material-3-Expressive edge stretch), **everywhere else →
52/// [`parity::Bouncing`]** at [`parity::DecelerationRate::Normal`] (paired with
53/// [`effect::OverscrollEffect::Translate`]).
54///
55/// [`rubber_band::RubberBand`] — the feel both surfaces used to hard-code — is
56/// no longer any platform's default; it stays reachable as an explicit
57/// `.physics(RubberBand::new())` opt-in.
58///
59/// `Rc<dyn ScrollPhysics>`, matching what the two scroll widgets store, so
60/// installing the default is one allocation and every later (re)install is an
61/// `Rc::clone`.
62///
63/// Selection is a `cfg!` **expression**, not a `#[cfg]` block: both arms
64/// type-check on every host, so a change here cannot compile on desktop and
65/// break the Android build.
66pub fn default_physics() -> Rc<dyn ScrollPhysics> {
67 if cfg!(target_os = "android") {
68 Rc::new(parity::Clamping::new())
69 } else {
70 Rc::new(parity::Bouncing::new())
71 }
72}
73
74/// The overscroll visual paired with [`default_physics`]: Android's clamping
75/// position never leaves the range, so the pull shows as
76/// [`effect::OverscrollEffect::Stretch`]; a bouncing surface moves with the
77/// pull instead, so it shows as [`effect::OverscrollEffect::Translate`].
78///
79/// Independent of [`effect::OverscrollEffect::default()`] (`Translate`, the
80/// enum's own neutral value) on purpose: this is the *platform* pairing, and a
81/// caller naming an effect explicitly always wins over it.
82pub fn default_overscroll_effect() -> effect::OverscrollEffect {
83 if cfg!(target_os = "android") {
84 effect::OverscrollEffect::Stretch
85 } else {
86 effect::OverscrollEffect::Translate
87 }
88}
89
90/// A read-only snapshot of a scroll surface's extent/position, the argument
91/// every [`ScrollPhysics`] method reasons over (Flutter's `ScrollMetrics`).
92///
93/// `min_scroll_extent` is carried for parity/chaining even though frust's
94/// scroll surfaces always run it at `0.0` today — nothing in this crate
95/// produces a nonzero value yet.
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct ScrollMetrics {
98 /// The current scroll position (px scrolled down/along the axis).
99 pub pixels: f64,
100 /// The minimum in-range position. Always `0.0` in this crate today.
101 pub min_scroll_extent: f64,
102 /// The maximum in-range position (`content − viewport`, never negative).
103 pub max_scroll_extent: f64,
104 /// The visible extent along the scroll axis.
105 pub viewport_dimension: f64,
106 /// The device's logical-to-physical pixel scale, feeding
107 /// [`Tolerance::for_device_pixel_ratio`].
108 pub device_pixel_ratio: f64,
109}
110
111impl ScrollMetrics {
112 /// Whether `pixels` currently sits outside `[min_scroll_extent,
113 /// max_scroll_extent]`.
114 pub fn out_of_range(&self) -> bool {
115 self.pixels < self.min_scroll_extent || self.pixels > self.max_scroll_extent
116 }
117
118 /// How far past the leading (top/start) edge `pixels` currently sits,
119 /// `0.0` if not past it.
120 pub fn overscroll_past_leading(&self) -> f64 {
121 (self.min_scroll_extent - self.pixels).max(0.0)
122 }
123
124 /// How far past the trailing (bottom/end) edge `pixels` currently sits,
125 /// `0.0` if not past it.
126 pub fn overscroll_past_trailing(&self) -> f64 {
127 (self.pixels - self.max_scroll_extent).max(0.0)
128 }
129}
130
131/// The velocity/distance thresholds below which a ballistic simulation is
132/// considered settled — Flutter's `Tolerance`, produced by `toleranceFor`.
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct Tolerance {
135 /// Velocity (px/s) below which motion counts as stopped.
136 pub velocity: f64,
137 /// Distance (logical px) below which position counts as arrived.
138 pub distance: f64,
139}
140
141impl Tolerance {
142 /// Flutter's `toleranceFor` formula exactly: velocity tolerance tightens
143 /// (and distance tolerance loosens) as `dpr` grows, since a physical pixel
144 /// covers less logical distance on a denser screen.
145 pub fn for_device_pixel_ratio(dpr: f64) -> Self {
146 Tolerance {
147 velocity: 1.0 / (0.050 * dpr),
148 distance: 1.0 / dpr,
149 }
150 }
151}
152
153/// A critically-damped-family spring's physical parameters, feeding a
154/// [`Simulation`] built from [`ScrollPhysics::spring`] (Flutter's
155/// `SpringDescription`).
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct SpringDescription {
158 /// The spring's mass.
159 pub mass: f64,
160 /// The spring's stiffness.
161 pub stiffness: f64,
162 /// The spring's damping coefficient.
163 pub damping: f64,
164}
165
166impl SpringDescription {
167 /// Derive `damping` from a damping *ratio* instead of stating it directly
168 /// — `damping = 2 · ratio · sqrt(mass · stiffness)` (Flutter's
169 /// `SpringDescription.withDampingRatio`).
170 pub fn with_damping_ratio(mass: f64, stiffness: f64, ratio: f64) -> Self {
171 SpringDescription {
172 mass,
173 stiffness,
174 damping: 2.0 * ratio * (mass * stiffness).sqrt(),
175 }
176 }
177
178 /// Flutter's `_kDefaultSpring`: the spring an overscrolled bouncing
179 /// surface uses to return to its edge.
180 pub fn default_scroll_spring() -> Self {
181 Self::with_damping_ratio(0.5, 100.0, 1.1)
182 }
183}
184
185/// A ballistic motion curve over time, produced by
186/// [`ScrollPhysics::create_ballistic_simulation`] and driven by the consuming
187/// widget after a gesture release (fling decay, a spring-back, or any other
188/// closed-form or iterative curve).
189///
190/// `time` is in **seconds** from the simulation's own start (`0.0` at
191/// creation) — a scroll surface that ticks in milliseconds (frust's scroll
192/// surfaces do) converts to seconds before calling in. An implementation
193/// carries its own [`Tolerance`] (typically threaded in at construction from
194/// [`ScrollPhysics::tolerance_for`]) rather than this trait supplying one.
195pub trait Simulation {
196 /// The position at `time` seconds.
197 fn x(&self, time: f64) -> f64;
198 /// The velocity at `time` seconds.
199 fn dx(&self, time: f64) -> f64;
200 /// Whether the simulation has settled (within its own tolerance) by `time`
201 /// seconds.
202 fn is_done(&self, time: f64) -> bool;
203}
204
205/// The minimum release speed (px/s) that starts a fling — Flutter's
206/// `kMinFlingVelocity`. A release slower than this is treated as a plain
207/// drag-end, never a fling.
208pub const MIN_FLING_VELOCITY: f64 = 50.0;
209
210/// The fastest fling speed (px/s) a physics honors — Flutter's
211/// `kMaxFlingVelocity`. A release faster than this clamps to it.
212pub const MAX_FLING_VELOCITY: f64 = 8000.0;
213
214/// The fraction of an interrupted motion's speed a new release must exceed
215/// before any [`ScrollPhysics::carried_momentum`] is added to it — Flutter's
216/// `ScrollDragController.momentumRetainVelocityThresholdFactor`. Paired with a
217/// same-sign check at the carry site, it keeps a release that is not plainly
218/// continuing the interrupted motion from inheriting that motion's momentum.
219///
220/// `pub(crate)`: the two scroll surfaces apply it, each in its own
221/// `fling_start_velocity` — never redeclare a second copy of the number.
222pub(crate) const MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR: f64 = 0.5;
223
224/// A pluggable scroll-motion strategy — Flutter's `ScrollPhysics` contract.
225///
226/// # Chaining
227///
228/// Physics compose by **parenting**, not inheritance: Flutter's
229/// `const BouncingScrollPhysics().applyTo(const AlwaysScrollableScrollPhysics())`
230/// idiom becomes a concrete type storing an optional boxed parent
231/// (`Option<Box<dyn ScrollPhysics>>`) behind [`ScrollPhysics::parent`], and a
232/// child overrides only the method(s) its own domain cares about — every other
233/// method's **default body here** asks the parent for its answer, and only
234/// falls back to a hardcoded value when there is no parent at all. A type that
235/// overrides a method entirely opts out of that delegation for that method
236/// only (e.g. `Snap(parent: Bouncing)` overrides just
237/// `create_ballistic_simulation`, so every other method — including
238/// `apply_boundary_conditions` — still walks up to `Bouncing`).
239///
240/// Concrete physics expose a `pub fn chain(self, parent: impl ScrollPhysics +
241/// 'static) -> Self` building `Some(Box::new(parent))` for
242/// [`ScrollPhysics::parent`] to return — every later physics type in this
243/// module follows that exact convention (same method name, same signature
244/// shape) so they compose with each other and with a caller's own type
245/// uniformly.
246pub trait ScrollPhysics: std::fmt::Debug {
247 /// The chained parent physics, if this one was built via `chain(...)`.
248 /// `None` for a physics built standalone (the chain's root).
249 fn parent(&self) -> Option<&dyn ScrollPhysics> {
250 None
251 }
252
253 /// Map a raw user drag delta (finger px, signed in the content's own
254 /// direction) to the delta actually applied to the position. Must not
255 /// alter the in-bounds portion of a drag — only a physics with an
256 /// out-of-bounds opinion (e.g. added resistance) touches this.
257 ///
258 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
259 /// the identity mapping (`offset` unchanged).
260 fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
261 self.parent().map_or(offset, |parent| {
262 parent.apply_physics_to_user_offset(metrics, offset)
263 })
264 }
265
266 /// Given a proposed new `pixels` value, return the portion the position
267 /// must **not** absorb — the boundary-rejected excess. `0.0` means the
268 /// proposal is fully allowed (a bouncing physics past an edge); the full
269 /// `proposed − clamped` distance means none of it is (a clamping
270 /// physics).
271 ///
272 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
273 /// `0.0` (nothing rejected).
274 fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
275 self.parent().map_or(0.0, |parent| {
276 parent.apply_boundary_conditions(metrics, value)
277 })
278 }
279
280 /// Build the ballistic motion to run after a gesture releases with
281 /// `velocity` (px/s, signed like [`ScrollMetrics::pixels`]). `None` means
282 /// no animation — the consuming widget falls back to its own legacy
283 /// path/rest handling.
284 ///
285 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
286 /// `None`.
287 fn create_ballistic_simulation(
288 &self,
289 metrics: &ScrollMetrics,
290 velocity: f64,
291 ) -> Option<Box<dyn Simulation>> {
292 self.parent()
293 .and_then(|parent| parent.create_ballistic_simulation(metrics, velocity))
294 }
295
296 /// Whether a user drag is allowed to move the position at all.
297 ///
298 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
299 /// whether the surface actually has scrollable content
300 /// (`max_scroll_extent > min_scroll_extent`).
301 fn should_accept_user_offset(&self, metrics: &ScrollMetrics) -> bool {
302 self.parent().map_or_else(
303 || metrics.max_scroll_extent > metrics.min_scroll_extent,
304 |parent| parent.should_accept_user_offset(metrics),
305 )
306 }
307
308 /// Momentum (px/s) to add onto a new fling's initial velocity when motion
309 /// is already live (a re-fling mid-animation carries some of the old
310 /// velocity forward rather than starting cold).
311 ///
312 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
313 /// `0.0`.
314 fn carried_momentum(&self, existing_velocity: f64) -> f64 {
315 self.parent()
316 .map_or(0.0, |parent| parent.carried_momentum(existing_velocity))
317 }
318
319 /// The minimum release speed that starts a fling.
320 ///
321 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
322 /// [`MIN_FLING_VELOCITY`].
323 fn min_fling_velocity(&self) -> f64 {
324 self.parent()
325 .map_or(MIN_FLING_VELOCITY, |parent| parent.min_fling_velocity())
326 }
327
328 /// The fastest fling speed this physics honors.
329 ///
330 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
331 /// [`MAX_FLING_VELOCITY`].
332 fn max_fling_velocity(&self) -> f64 {
333 self.parent()
334 .map_or(MAX_FLING_VELOCITY, |parent| parent.max_fling_velocity())
335 }
336
337 /// The velocity/distance tolerance a ballistic simulation settles within.
338 ///
339 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
340 /// [`Tolerance::for_device_pixel_ratio`] over `metrics.device_pixel_ratio`.
341 fn tolerance_for(&self, metrics: &ScrollMetrics) -> Tolerance {
342 self.parent().map_or_else(
343 || Tolerance::for_device_pixel_ratio(metrics.device_pixel_ratio),
344 |parent| parent.tolerance_for(metrics),
345 )
346 }
347
348 /// The spring a bounce-back/snap simulation is built from.
349 ///
350 /// Default: delegates to [`ScrollPhysics::parent`] if chained, otherwise
351 /// [`SpringDescription::default_scroll_spring`].
352 fn spring(&self) -> SpringDescription {
353 self.parent()
354 .map_or_else(SpringDescription::default_scroll_spring, |parent| {
355 parent.spring()
356 })
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn metrics(pixels: f64, max: f64, dpr: f64) -> ScrollMetrics {
365 ScrollMetrics {
366 pixels,
367 min_scroll_extent: 0.0,
368 max_scroll_extent: max,
369 viewport_dimension: 100.0,
370 device_pixel_ratio: dpr,
371 }
372 }
373
374 #[test]
375 fn tolerance_matches_flutter_formula() {
376 let t = Tolerance::for_device_pixel_ratio(2.0);
377 assert_eq!(t.velocity, 10.0);
378 assert_eq!(t.distance, 0.5);
379 }
380
381 #[test]
382 fn spring_damping_ratio_math() {
383 let spring = SpringDescription::with_damping_ratio(0.5, 100.0, 1.1);
384 let expected = 2.0 * 1.1 * (50.0f64).sqrt();
385 assert!(
386 (spring.damping - expected).abs() < 1e-9,
387 "damping {} did not match expected {}",
388 spring.damping,
389 expected
390 );
391 }
392
393 /// A toy leaf physics overriding nothing — every method should fall
394 /// through to its parent when one is chained.
395 #[derive(Debug)]
396 struct Bare;
397 impl ScrollPhysics for Bare {}
398
399 /// A toy parent physics: rejects the full excess of any boundary proposal
400 /// (a stand-in "clamping" answer distinct from the trait's own `0.0`
401 /// no-parent fallback, so a test can tell the two apart) and reports an
402 /// unusually low minimum fling velocity.
403 #[derive(Debug)]
404 struct TestParent;
405 impl ScrollPhysics for TestParent {
406 fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
407 7.0
408 }
409 }
410
411 /// A child chained onto [`TestParent`] that overrides nothing itself.
412 #[derive(Debug)]
413 struct DelegatingChild {
414 parent: Box<dyn ScrollPhysics>,
415 }
416 impl ScrollPhysics for DelegatingChild {
417 fn parent(&self) -> Option<&dyn ScrollPhysics> {
418 Some(self.parent.as_ref())
419 }
420 }
421
422 /// A child chained onto [`TestParent`] that overrides
423 /// `min_fling_velocity` itself — its own answer must win over the
424 /// parent's.
425 #[derive(Debug)]
426 struct OverridingChild {
427 parent: Box<dyn ScrollPhysics>,
428 }
429 impl ScrollPhysics for OverridingChild {
430 fn parent(&self) -> Option<&dyn ScrollPhysics> {
431 Some(self.parent.as_ref())
432 }
433 fn min_fling_velocity(&self) -> f64 {
434 999.0
435 }
436 }
437
438 #[test]
439 fn chaining_delegates_through_parent() {
440 let m = metrics(10.0, 100.0, 1.0);
441
442 // A leaf with no parent at all falls back to the trait's own default.
443 assert_eq!(Bare.apply_boundary_conditions(&m, 50.0), 0.0);
444
445 // A child overriding nothing delegates straight through to the parent.
446 let child = DelegatingChild {
447 parent: Box::new(TestParent),
448 };
449 assert_eq!(child.apply_boundary_conditions(&m, 50.0), 7.0);
450
451 // A child overriding one method wins over the parent for that method,
452 // while an un-overridden method still walks up to the parent's own
453 // fallback (the trait default, since TestParent doesn't override it
454 // either).
455 let overriding = OverridingChild {
456 parent: Box::new(TestParent),
457 };
458 assert_eq!(overriding.min_fling_velocity(), 999.0);
459 assert_eq!(overriding.apply_boundary_conditions(&m, 50.0), 7.0);
460 }
461
462 /// The non-Android arm of [`default_physics`]/[`default_overscroll_effect`]
463 /// — read back behaviorally (the doubled fling minimum, nothing rejected
464 /// past an edge, the depth-aware friction at zero depth) rather than by
465 /// downcasting, plus the `Debug` name so a swap to another
466 /// nothing-rejected physics still trips this.
467 #[cfg(not(target_os = "android"))]
468 #[test]
469 fn non_android_default_is_bouncing_plus_translate() {
470 let physics = default_physics();
471 assert!(
472 format!("{physics:?}").starts_with("Bouncing"),
473 "the desktop/iOS default is Bouncing, got {physics:?}"
474 );
475 assert_eq!(
476 physics.min_fling_velocity(),
477 parity::Bouncing::MIN_FLING_VELOCITY
478 );
479 let m = metrics(0.0, 500.0, 1.0);
480 assert_eq!(
481 physics.apply_boundary_conditions(&m, -30.0),
482 0.0,
483 "a bouncing surface rejects nothing — it holds the displacement"
484 );
485 let mapped = physics.apply_physics_to_user_offset(&m, -20.0);
486 assert!(
487 (mapped - -20.0 * parity::DecelerationRate::NORMAL_FRICTION).abs() < 1e-12,
488 "the past-edge pull is scaled by the normal-rate friction: {mapped}"
489 );
490 assert_eq!(
491 default_overscroll_effect(),
492 effect::OverscrollEffect::Translate
493 );
494 }
495
496 /// The Android arm of the same pair. Compiled only for an Android target,
497 /// so an ordinary host `cargo test` never runs it — a real device/emulator
498 /// build is what exercises this half.
499 #[cfg(target_os = "android")]
500 #[test]
501 fn android_default_is_clamping_plus_stretch() {
502 let physics = default_physics();
503 assert!(
504 format!("{physics:?}").starts_with("Clamping"),
505 "the Android default is Clamping, got {physics:?}"
506 );
507 let m = metrics(0.0, 500.0, 1.0);
508 assert_eq!(
509 physics.apply_boundary_conditions(&m, -30.0),
510 -30.0,
511 "a clamping surface rejects the whole past-edge excess"
512 );
513 assert_eq!(
514 physics.apply_physics_to_user_offset(&m, -20.0),
515 -20.0,
516 "…and resists the drag itself not at all (the trait's identity map)"
517 );
518 assert_eq!(
519 default_overscroll_effect(),
520 effect::OverscrollEffect::Stretch
521 );
522 }
523
524 #[test]
525 fn default_should_accept_requires_scrollable_content() {
526 let scrollable = metrics(0.0, 500.0, 1.0);
527 assert!(Bare.should_accept_user_offset(&scrollable));
528
529 let not_scrollable = metrics(0.0, 0.0, 1.0);
530 assert!(!Bare.should_accept_user_offset(¬_scrollable));
531 }
532}