Skip to main content

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(&not_scrollable));
531    }
532}