frust_widgets/physics/parity.rs
1//! The platform-parity physics: ports of Flutter's `BouncingScrollPhysics`
2//! (iOS), `ClampingScrollPhysics` (Android), `AlwaysScrollableScrollPhysics`
3//! and `NeverScrollableScrollPhysics` from `widgets/scroll_physics.dart`,
4//! driving the simulations in [`super::simulation`].
5//!
6//! Every tuning constant is Flutter's own, stated as the expression its source
7//! states it as and pinned by a test below. The two behavioral ones — the
8//! bouncing friction curve and the clamping hard stop — are what make a fling
9//! land where the platform's own would.
10//!
11//! # How a drag reaches [`Bouncing`]
12//!
13//! Flutter feeds `applyPhysicsToUserOffset` a *per-frame delta* against a
14//! position that may itself be out of range, so its friction tightens as the
15//! pull deepens. Both frust scroll surfaces now use that same convention
16//! ([`crate::scroll::ScrollWidget`]'s drag path, its module docs' *Drag
17//! convention*): each `Move` hands over its own raw finger delta with metrics
18//! reporting the live position, displacement and all. [`Bouncing`] derives the
19//! resisted portion from the metrics it is handed rather than assuming a
20//! convention, so what it reads is a real depth and the factor it applies
21//! genuinely tightens from [`DecelerationRate::NORMAL_FRICTION`] at the edge
22//! toward zero a viewport out.
23//!
24//! # Why [`Clamping`] needs no clamped simulation adapter
25//!
26//! Its ballistic curve ([`ClampingScrollSimulation`]) is free to run far past
27//! an edge — Android's spline knows nothing about extents. Nothing here clamps
28//! it, because the widget's generic ballistic driver subtracts what
29//! [`ScrollPhysics::apply_boundary_conditions`] rejects from *every* simulation
30//! position it paints (`ScrollWidget`'s `drive_ballistic`), and this physics
31//! rejects the whole excess — so the painted offset stops dead at the edge
32//! while the raw curve keeps going. `clamping_ballistic_returns_android_curve_sim`
33//! re-derives that contract in-file so a driver change cannot silently break it.
34//!
35//! # Deviations from the Dart source
36//!
37//! Four, each narrow and deliberate:
38//!
39//! * **Easing back out of an overscroll is unresisted at both rates.** Flutter
40//! returns the delta untouched only at [`DecelerationRate::Fast`]; at
41//! `Normal` it still applies a reduced-depth friction factor. One rule for
42//! both rates keeps the asymmetry that carries the feel (resist deepening,
43//! never resist returning) without the second curve.
44//! * **[`Clamping::apply_boundary_conditions`] is the two-branch form** (past
45//! an extent → reject the whole excess) rather than Flutter's four-branch
46//! one. The two differ only for a position that is *already* out of range,
47//! which this physics never produces: there, Flutter rejects only further
48//! outward travel and lets the position linger outside, while this form pulls
49//! it back to the extent on the next proposal.
50//! * **A fling starts at [`ScrollPhysics::min_fling_velocity`], not at the
51//! tolerance velocity.** Flutter gates `createBallisticSimulation` on
52//! `tolerance.velocity` (~20 px/s at 1.0 dpr). The surfaces here already zero
53//! any release below the physics' own minimum before asking, so the two gates
54//! answer identically at that seam; stating the real threshold is honest
55//! about what a direct caller gets.
56//! * **[`Clamping`]'s out-of-range spring keeps the release velocity.** Flutter
57//! passes `min(0.0, velocity)`, which is only meaningful at a trailing edge;
58//! the actual velocity is symmetric and is what the bouncing port already
59//! does.
60
61use super::simulation::{
62 BouncingScrollSimulation, ClampingScrollSimulation, ScrollSpringSimulation,
63};
64use super::{ScrollMetrics, ScrollPhysics, Simulation, SpringDescription};
65
66/// How quickly a [`Bouncing`] surface's fling decays — Flutter's
67/// `ScrollDecelerationRate`.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum DecelerationRate {
70 /// iOS's own rate: a long, low-friction glide with no constant
71 /// deceleration term and the default edge spring.
72 #[default]
73 Normal,
74 /// A shorter, snappier glide: half the overscroll friction, a stiffer edge
75 /// spring, and a constant deceleration on top of the drag curve.
76 Fast,
77}
78
79impl DecelerationRate {
80 /// The overscroll friction factor at zero depth for
81 /// [`DecelerationRate::Normal`] — the `0.52` in Flutter's
82 /// `frictionFactor`.
83 pub const NORMAL_FRICTION: f64 = 0.52;
84
85 /// The same factor for [`DecelerationRate::Fast`]: half as much of a
86 /// past-edge pull reaches the position.
87 pub const FAST_FRICTION: f64 = 0.26;
88
89 /// The constant deceleration (px/s²) [`DecelerationRate::Fast`] adds to its
90 /// fling's drag curve, which is what stops it early instead of letting it
91 /// approach an asymptote. `Normal` adds none.
92 pub const FAST_CONSTANT_DECELERATION: f64 = 1400.0;
93
94 /// The factor a past-edge pull is scaled by at zero overscroll depth.
95 fn friction_coefficient(self) -> f64 {
96 match self {
97 DecelerationRate::Normal => Self::NORMAL_FRICTION,
98 DecelerationRate::Fast => Self::FAST_FRICTION,
99 }
100 }
101
102 /// The constant deceleration term this rate's ballistic curve carries.
103 fn constant_deceleration(self) -> f64 {
104 match self {
105 DecelerationRate::Normal => 0.0,
106 DecelerationRate::Fast => Self::FAST_CONSTANT_DECELERATION,
107 }
108 }
109
110 /// The edge spring this rate bounces back on.
111 ///
112 /// `Normal` has no spring of its own in the Dart source —
113 /// `BouncingScrollPhysics.spring` overrides only the fast case, so the
114 /// normal one inherits `ScrollPhysics.spring`, i.e. `_kDefaultSpring`
115 /// ([`SpringDescription::default_scroll_spring`], mass 0.5 / stiffness 100
116 /// / damping ratio 1.1). A mass 0.3 / stiffness 100 / ratio 0.7 pairing
117 /// circulates for it second-hand; the inheritance above is the primary
118 /// source and refutes it.
119 fn spring(self) -> SpringDescription {
120 match self {
121 DecelerationRate::Normal => SpringDescription::default_scroll_spring(),
122 DecelerationRate::Fast => SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
123 }
124 }
125}
126
127/// Split out-of-range travel between two signed past-edge displacements into
128/// the part that eases back toward the range and the part that deepens the
129/// overscroll. Both terms are signed like the travel itself and sum to
130/// `end − start`.
131fn split_overscroll_travel(start: f64, end: f64) -> (f64, f64) {
132 if start * end < 0.0 {
133 // Off one edge and past the other in a single delta: all the way back
134 // in first, then the whole of the new excursion is tensioning.
135 (-start, end)
136 } else if end.abs() >= start.abs() {
137 (0.0, end - start)
138 } else {
139 (end - start, 0.0)
140 }
141}
142
143/// iOS's scroll feel — Flutter's `BouncingScrollPhysics`: a drag may pull the
144/// position past an edge against a friction factor that tightens with depth,
145/// nothing is ever boundary-rejected, and a release runs an exponential
146/// friction curve that hands over to a rubber-band spring at whichever edge it
147/// reaches.
148///
149/// See the [module docs](self) for the drag convention the surfaces hand this
150/// curve its depth through, and for the deviations from the Dart source.
151#[derive(Debug, Default)]
152pub struct Bouncing {
153 rate: DecelerationRate,
154 parent: Option<Box<dyn ScrollPhysics>>,
155}
156
157impl Bouncing {
158 /// Flutter's `BouncingScrollPhysics.minFlingVelocity`: twice
159 /// [`super::MIN_FLING_VELOCITY`], because this physics' ballistic curve
160 /// decelerates more slowly than the clamping one, so a fling has to be a
161 /// more deliberate flick to be worth starting.
162 pub const MIN_FLING_VELOCITY: f64 = super::MIN_FLING_VELOCITY * 2.0;
163
164 /// The scale of Flutter's power-curve fit for momentum carried into a
165 /// re-fling (`carriedMomentum`), fitted against superimposed platform
166 /// scroll views rather than derived.
167 const MOMENTUM_COEFFICIENT: f64 = 0.000_816;
168
169 /// That fit's exponent.
170 const MOMENTUM_EXPONENT: f64 = 1.967;
171
172 /// The most momentum (px/s) a re-fling can carry over, so a fast enough
173 /// existing motion cannot compound without bound.
174 const MOMENTUM_CAP: f64 = 40_000.0;
175
176 /// A standalone bouncing physics at [`DecelerationRate::Normal`].
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 /// A standalone bouncing physics at `rate`.
182 pub fn with_rate(rate: DecelerationRate) -> Self {
183 Self { rate, parent: None }
184 }
185
186 /// Chain `parent` behind this physics, the module-wide composition
187 /// convention ([`ScrollPhysics`]' *Chaining*).
188 pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
189 Self {
190 rate: self.rate,
191 parent: Some(Box::new(parent)),
192 }
193 }
194
195 /// The fraction of a past-edge pull that reaches the position at an
196 /// overscroll of `overscroll_fraction` viewports — Flutter's
197 /// `frictionFactor`: `0.52·(1 − f)²` at [`DecelerationRate::Normal`],
198 /// `0.26·(1 − f)²` at [`DecelerationRate::Fast`].
199 pub fn friction_factor(&self, overscroll_fraction: f64) -> f64 {
200 let remaining = 1.0 - overscroll_fraction;
201 self.rate.friction_coefficient() * remaining * remaining
202 }
203
204 /// How deep the metrics' position already sits past an edge, as a fraction
205 /// of the viewport — the argument to [`Self::friction_factor`]. A surface
206 /// with no viewport yet reports `0.0` rather than dividing by zero.
207 fn overscroll_fraction(metrics: &ScrollMetrics, displacement: f64) -> f64 {
208 if metrics.viewport_dimension > 0.0 {
209 displacement.abs() / metrics.viewport_dimension
210 } else {
211 0.0
212 }
213 }
214}
215
216impl ScrollPhysics for Bouncing {
217 fn parent(&self) -> Option<&dyn ScrollPhysics> {
218 self.parent.as_deref()
219 }
220
221 /// Travel inside the range passes through untouched, travel that *deepens*
222 /// an overscroll is scaled by [`Self::friction_factor`] at the depth
223 /// already held, and travel easing back toward the range passes through
224 /// untouched too. That asymmetry is the signature of the feel: pulling
225 /// further out gets progressively heavier, letting it back never fights
226 /// the finger.
227 fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
228 let min = metrics.min_scroll_extent;
229 let max = metrics.max_scroll_extent;
230 let start = metrics.pixels;
231 let end = start + offset;
232 // The three parts of the delta: travel within the range, travel that
233 // reduces an out-of-range displacement, and travel that grows one.
234 let in_range = end.clamp(min, max) - start.clamp(min, max);
235 let displacement_start = start - start.clamp(min, max);
236 let displacement_end = end - end.clamp(min, max);
237 let (easing, tensioning) = split_overscroll_travel(displacement_start, displacement_end);
238 let friction = self.friction_factor(Self::overscroll_fraction(metrics, displacement_start));
239 in_range + easing + tensioning * friction
240 }
241
242 /// Nothing is ever rejected — holding an out-of-range position is the whole
243 /// point of a bouncing surface, and the edge spring (not a boundary rule)
244 /// is what returns it.
245 fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
246 0.0
247 }
248
249 /// An overscrolled surface always gets its spring back, in range a fling
250 /// needs [`Self::MIN_FLING_VELOCITY`], and anything else is no motion at
251 /// all.
252 fn create_ballistic_simulation(
253 &self,
254 metrics: &ScrollMetrics,
255 velocity: f64,
256 ) -> Option<Box<dyn Simulation>> {
257 if !metrics.out_of_range() && velocity.abs() < self.min_fling_velocity() {
258 return None;
259 }
260 Some(Box::new(BouncingScrollSimulation::new(
261 metrics.pixels,
262 velocity,
263 metrics.min_scroll_extent,
264 metrics.max_scroll_extent,
265 self.spring(),
266 self.tolerance_for(metrics),
267 self.rate.constant_deceleration(),
268 )))
269 }
270
271 /// Always `true`: an iOS surface whose content fits its viewport still
272 /// scrolls — it just bounces straight back.
273 fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
274 true
275 }
276
277 /// Flutter's fitted power curve, capped at `MOMENTUM_CAP` (40000 px/s) and
278 /// signed like the motion it carries over.
279 fn carried_momentum(&self, existing_velocity: f64) -> f64 {
280 let magnitude =
281 Self::MOMENTUM_COEFFICIENT * existing_velocity.abs().powf(Self::MOMENTUM_EXPONENT);
282 existing_velocity.signum() * magnitude.min(Self::MOMENTUM_CAP)
283 }
284
285 fn min_fling_velocity(&self) -> f64 {
286 Self::MIN_FLING_VELOCITY
287 }
288
289 fn spring(&self) -> SpringDescription {
290 self.rate.spring()
291 }
292}
293
294/// Android's scroll feel — Flutter's `ClampingScrollPhysics`: a drag never
295/// leaves the range (the excess is rejected outright, for the surface to show
296/// as a stretch or a glow instead), and a release runs Android's own
297/// `SplineOverScroller` deceleration to a hard stop.
298///
299/// See the [module docs](self) for why nothing here clamps that curve itself.
300#[derive(Debug, Default)]
301pub struct Clamping {
302 parent: Option<Box<dyn ScrollPhysics>>,
303}
304
305impl Clamping {
306 /// A standalone clamping physics (no chained parent).
307 pub fn new() -> Self {
308 Self::default()
309 }
310
311 /// Chain `parent` behind this physics, the module-wide composition
312 /// convention ([`ScrollPhysics`]' *Chaining*).
313 pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
314 Self {
315 parent: Some(Box::new(parent)),
316 }
317 }
318}
319
320impl ScrollPhysics for Clamping {
321 fn parent(&self) -> Option<&dyn ScrollPhysics> {
322 self.parent.as_deref()
323 }
324
325 /// The whole of any past-extent excess is rejected, so the position itself
326 /// never leaves `[min, max]`. The caller still learns how far the proposal
327 /// went — that rejected distance is what a stretch or glow effect reads
328 /// (this module's parent's *design ruling*).
329 fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
330 if value < metrics.min_scroll_extent {
331 value - metrics.min_scroll_extent
332 } else if value > metrics.max_scroll_extent {
333 value - metrics.max_scroll_extent
334 } else {
335 0.0
336 }
337 }
338
339 /// Android's fling curve for a release with room to travel, and — as a
340 /// safety arm this physics' own boundary rule makes unreachable — a spring
341 /// back to the nearest extent for a position that starts out of range.
342 fn create_ballistic_simulation(
343 &self,
344 metrics: &ScrollMetrics,
345 velocity: f64,
346 ) -> Option<Box<dyn Simulation>> {
347 let tolerance = self.tolerance_for(metrics);
348 if metrics.out_of_range() {
349 let end = if metrics.pixels > metrics.max_scroll_extent {
350 metrics.max_scroll_extent
351 } else {
352 metrics.min_scroll_extent
353 };
354 return Some(Box::new(ScrollSpringSimulation::new(
355 self.spring(),
356 metrics.pixels,
357 end,
358 velocity,
359 tolerance,
360 )));
361 }
362 if velocity.abs() < self.min_fling_velocity() {
363 return None;
364 }
365 // Already pinned against the edge the release is heading for: there is
366 // nothing to fling into.
367 if velocity > 0.0 && metrics.pixels >= metrics.max_scroll_extent {
368 return None;
369 }
370 if velocity < 0.0 && metrics.pixels <= metrics.min_scroll_extent {
371 return None;
372 }
373 Some(Box::new(ClampingScrollSimulation::new(
374 metrics.pixels,
375 velocity,
376 ClampingScrollSimulation::DEFAULT_FRICTION,
377 tolerance,
378 )))
379 }
380}
381
382/// Flutter's `AlwaysScrollableScrollPhysics`: accept a drag whether or not
383/// there is anything to scroll, and defer everything else to the chained
384/// parent.
385///
386/// Composed rather than used alone — `AlwaysScrollable::new().chain(Clamping::new())`
387/// is a clamping surface that a pull-to-refresh gesture can still reach on a
388/// short page.
389#[derive(Debug, Default)]
390pub struct AlwaysScrollable {
391 parent: Option<Box<dyn ScrollPhysics>>,
392}
393
394impl AlwaysScrollable {
395 /// A standalone always-scrollable physics (no chained parent).
396 pub fn new() -> Self {
397 Self::default()
398 }
399
400 /// Chain `parent` behind this physics, the module-wide composition
401 /// convention ([`ScrollPhysics`]' *Chaining*).
402 pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
403 Self {
404 parent: Some(Box::new(parent)),
405 }
406 }
407}
408
409impl ScrollPhysics for AlwaysScrollable {
410 fn parent(&self) -> Option<&dyn ScrollPhysics> {
411 self.parent.as_deref()
412 }
413
414 /// The one thing this physics has an opinion about.
415 fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
416 true
417 }
418}
419
420/// Flutter's `NeverScrollableScrollPhysics`: refuse every drag, deferring
421/// everything else to the chained parent — an inner list inside an outer
422/// scroller, or a temporarily locked surface.
423#[derive(Debug, Default)]
424pub struct NeverScrollable {
425 parent: Option<Box<dyn ScrollPhysics>>,
426}
427
428impl NeverScrollable {
429 /// A standalone never-scrollable physics (no chained parent).
430 pub fn new() -> Self {
431 Self::default()
432 }
433
434 /// Chain `parent` behind this physics, the module-wide composition
435 /// convention ([`ScrollPhysics`]' *Chaining*).
436 pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
437 Self {
438 parent: Some(Box::new(parent)),
439 }
440 }
441}
442
443impl ScrollPhysics for NeverScrollable {
444 fn parent(&self) -> Option<&dyn ScrollPhysics> {
445 self.parent.as_deref()
446 }
447
448 /// The one thing this physics has an opinion about.
449 fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
450 false
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::physics::Tolerance;
458
459 /// A 100px viewport at 1.0 dpr, so an overscroll fraction reads as
460 /// hundredths and the tolerance is a round 20 px/s.
461 fn metrics(pixels: f64, max: f64) -> ScrollMetrics {
462 ScrollMetrics {
463 pixels,
464 min_scroll_extent: 0.0,
465 max_scroll_extent: max,
466 viewport_dimension: 100.0,
467 device_pixel_ratio: 1.0,
468 }
469 }
470
471 fn tol() -> Tolerance {
472 Tolerance::for_device_pixel_ratio(1.0)
473 }
474
475 fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
476 assert!(
477 (actual - expected).abs() < epsilon,
478 "{what}: {actual} is not within {epsilon} of {expected}"
479 );
480 }
481
482 #[test]
483 fn bouncing_friction_factor_curve() {
484 let normal = Bouncing::new();
485 assert_close(normal.friction_factor(0.0), 0.52, 1e-12, "normal at rest");
486 assert_close(normal.friction_factor(0.5), 0.13, 1e-12, "normal half out");
487 assert_close(
488 normal.friction_factor(1.0),
489 0.0,
490 1e-12,
491 "normal a viewport out",
492 );
493
494 let fast = Bouncing::with_rate(DecelerationRate::Fast);
495 assert_close(fast.friction_factor(0.0), 0.26, 1e-12, "fast at rest");
496 assert_close(fast.friction_factor(0.5), 0.065, 1e-12, "fast half out");
497
498 // The two coefficients the curve is built from, stated directly.
499 assert_eq!(DecelerationRate::NORMAL_FRICTION, 0.52);
500 assert_eq!(DecelerationRate::FAST_FRICTION, 0.26);
501 }
502
503 #[test]
504 fn bouncing_resists_increasing_overscroll_only() {
505 let physics = Bouncing::new();
506
507 // Wholly in range: the identity mapping, both directions.
508 assert_eq!(
509 physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), 30.0),
510 30.0
511 );
512 assert_eq!(
513 physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), -50.0),
514 -50.0
515 );
516
517 // At the leading edge, pulled 20px past it: the whole delta is
518 // tensioning, at zero depth (0.52).
519 assert_close(
520 physics.apply_physics_to_user_offset(&metrics(0.0, 500.0), -20.0),
521 -20.0 * 0.52,
522 1e-12,
523 "tensioning off the leading edge",
524 );
525 // Same at the trailing edge.
526 assert_close(
527 physics.apply_physics_to_user_offset(&metrics(500.0, 500.0), 20.0),
528 20.0 * 0.52,
529 1e-12,
530 "tensioning off the trailing edge",
531 );
532
533 // Already 30px out of a 100px viewport, pulled 10px deeper: the factor
534 // has tightened to 0.52·(1 − 0.3)².
535 assert_close(
536 physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), -10.0),
537 -10.0 * 0.52 * 0.49,
538 1e-12,
539 "tensioning deeper",
540 );
541
542 // The same 30px out, easing back: untouched, in both the partial and
543 // the all-the-way-back-into-range cases.
544 assert_eq!(
545 physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 10.0),
546 10.0
547 );
548 assert_eq!(
549 physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 50.0),
550 50.0
551 );
552
553 // A delta straddling the edge resists only its past-edge part: 5px of
554 // in-range travel, then 20px tensioned.
555 assert_close(
556 physics.apply_physics_to_user_offset(&metrics(5.0, 500.0), -25.0),
557 -5.0 + -20.0 * 0.52,
558 1e-12,
559 "straddling the leading edge",
560 );
561 }
562
563 #[test]
564 fn bouncing_carried_momentum_formula() {
565 let physics = Bouncing::new();
566 let expected = 0.000_816 * 1000.0_f64.powf(1.967);
567 let carried = physics.carried_momentum(1000.0);
568 assert!(
569 (carried - expected).abs() / expected < 1e-6,
570 "carried {carried} is not within 1e-6 relative of {expected}"
571 );
572 // Sign follows the motion being carried over.
573 assert_close(
574 physics.carried_momentum(-1000.0),
575 -expected,
576 1e-9,
577 "negative carry",
578 );
579 assert_eq!(physics.carried_momentum(0.0), 0.0);
580 // Fast enough and the fit saturates rather than compounding.
581 assert_eq!(physics.carried_momentum(1e6), 40_000.0);
582 assert_eq!(physics.carried_momentum(-1e6), -40_000.0);
583 }
584
585 #[test]
586 fn bouncing_min_fling_is_100() {
587 assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
588 assert_eq!(
589 Bouncing::MIN_FLING_VELOCITY,
590 crate::physics::MIN_FLING_VELOCITY * 2.0
591 );
592 // …twice what the clamping physics (the trait default) asks for.
593 assert_eq!(Clamping::new().min_fling_velocity(), 50.0);
594 }
595
596 #[test]
597 fn bouncing_spring_per_rate() {
598 let normal = Bouncing::new().spring();
599 assert_eq!(normal.mass, 0.5);
600 assert_eq!(normal.stiffness, 100.0);
601 assert_close(
602 normal.damping,
603 2.0 * 1.1 * (0.5 * 100.0_f64).sqrt(),
604 1e-12,
605 "normal damping",
606 );
607 assert_eq!(normal, SpringDescription::default_scroll_spring());
608
609 let fast = Bouncing::with_rate(DecelerationRate::Fast).spring();
610 assert_eq!(fast.mass, 0.3);
611 assert_eq!(fast.stiffness, 75.0);
612 assert_close(
613 fast.damping,
614 2.0 * 1.3 * (0.3 * 75.0_f64).sqrt(),
615 1e-12,
616 "fast damping",
617 );
618 }
619
620 #[test]
621 fn bouncing_ballistic_in_range_low_velocity_is_none() {
622 let physics = Bouncing::new();
623 let m = metrics(100.0, 500.0);
624 assert!(physics.create_ballistic_simulation(&m, 0.0).is_none());
625 assert!(physics.create_ballistic_simulation(&m, 99.0).is_none());
626 // Exactly at the threshold is a fling.
627 assert!(physics.create_ballistic_simulation(&m, 100.0).is_some());
628 }
629
630 #[test]
631 fn bouncing_ballistic_out_of_range_is_some() {
632 let physics = Bouncing::new();
633 // Resting 30px past the leading edge: no fling velocity at all, but the
634 // spring still has to bring it home.
635 let sim = physics
636 .create_ballistic_simulation(&metrics(-30.0, 500.0), 0.0)
637 .expect("an overscrolled release must spring back");
638 assert_close(sim.x(0.0), -30.0, 1e-9, "starts where released");
639 assert!(
640 sim.x(0.1) > sim.x(0.0),
641 "must travel back toward the leading extent"
642 );
643 assert_close(sim.x(3.0), 0.0, 1e-9, "settles on the extent");
644 assert!(sim.is_done(3.0), "the bounce-back never settled");
645 }
646
647 #[test]
648 fn bouncing_fast_rate_carries_the_constant_deceleration() {
649 assert_eq!(DecelerationRate::FAST_CONSTANT_DECELERATION, 1400.0);
650 let m = metrics(100.0, 5000.0);
651 let fast = Bouncing::with_rate(DecelerationRate::Fast)
652 .create_ballistic_simulation(&m, 2000.0)
653 .expect("a fast fling must be ballistic");
654 // The same curve, spelled out: the fast spring plus a 1400 px/s²
655 // constant deceleration on top of the drag.
656 let expected = BouncingScrollSimulation::new(
657 100.0,
658 2000.0,
659 0.0,
660 5000.0,
661 SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
662 tol(),
663 1400.0,
664 );
665 assert_close(fast.x(0.3), expected.x(0.3), 1e-9, "fast position");
666 assert_close(fast.dx(0.3), expected.dx(0.3), 1e-9, "fast velocity");
667
668 // …and it is genuinely slower than the normal rate's pure-drag curve.
669 let normal = Bouncing::new()
670 .create_ballistic_simulation(&m, 2000.0)
671 .expect("a normal fling must be ballistic");
672 assert!(
673 normal.x(0.3) > fast.x(0.3),
674 "the fast rate reached {}, no nearer than the normal rate's {}",
675 fast.x(0.3),
676 normal.x(0.3)
677 );
678 }
679
680 #[test]
681 fn clamping_boundary_conditions_reject_excess() {
682 let physics = Clamping::new();
683 let m = metrics(0.0, 500.0);
684 assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
685 assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
686 assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
687 // The extents themselves are in range.
688 assert_eq!(physics.apply_boundary_conditions(&m, 0.0), 0.0);
689 assert_eq!(physics.apply_boundary_conditions(&m, 500.0), 0.0);
690 }
691
692 #[test]
693 fn clamping_ballistic_returns_android_curve_sim() {
694 let physics = Clamping::new();
695 let m = metrics(0.0, 500.0);
696 let sim = physics
697 .create_ballistic_simulation(&m, 3000.0)
698 .expect("a fast in-range fling must be ballistic");
699
700 // Android's own spline, at this crate's default friction.
701 let expected = ClampingScrollSimulation::new(
702 0.0,
703 3000.0,
704 ClampingScrollSimulation::DEFAULT_FRICTION,
705 tol(),
706 );
707 assert_close(sim.dx(0.0), 3000.0, 1e-9, "release velocity");
708 assert_close(sim.x(0.1), expected.x(0.1), 1e-9, "position on the curve");
709 assert_close(sim.x(0.5), expected.x(0.5), 1e-9, "position later on");
710
711 // The curve itself overshoots the extent — nothing in this physics
712 // clamps it. What keeps the surface in range is the driver contract:
713 // `ScrollWidget::drive_ballistic` paints `x − apply_boundary_conditions(x)`
714 // for every simulation position, and this physics rejects the whole
715 // excess. Re-derived here so a driver change cannot silently break it.
716 let mut overshot = false;
717 for step in 0..=200 {
718 let time = f64::from(step) / 100.0;
719 let raw = sim.x(time);
720 overshot |= raw > 500.0;
721 let painted = raw - physics.apply_boundary_conditions(&m, raw);
722 assert!(
723 (0.0..=500.0).contains(&painted),
724 "painted offset {painted} left the range at t={time}s (raw {raw})"
725 );
726 }
727 assert!(
728 overshot,
729 "the raw curve must overshoot for this test to mean anything"
730 );
731
732 // A release with nowhere to go is not a fling.
733 assert!(
734 physics
735 .create_ballistic_simulation(&metrics(500.0, 500.0), 3000.0)
736 .is_none()
737 );
738 assert!(
739 physics
740 .create_ballistic_simulation(&metrics(0.0, 500.0), -3000.0)
741 .is_none()
742 );
743 assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
744 }
745
746 #[test]
747 fn clamping_out_of_range_springs_back() {
748 // The safety arm: this physics' own boundary rule never lets a position
749 // get here, but a metrics change (or a physics swap) can.
750 let sim = Clamping::new()
751 .create_ballistic_simulation(&metrics(560.0, 500.0), 0.0)
752 .expect("an out-of-range release must spring back");
753 assert_close(sim.x(0.0), 560.0, 1e-9, "starts where released");
754 assert_close(sim.x(3.0), 500.0, 1e-9, "settles on the nearest extent");
755 }
756
757 #[test]
758 fn always_scrollable_accepts_on_short_content() {
759 // Content that fits: the trait default (and a bare clamping physics)
760 // refuses the drag outright.
761 let short = metrics(0.0, 0.0);
762 assert!(!Clamping::new().should_accept_user_offset(&short));
763 assert!(AlwaysScrollable::new().should_accept_user_offset(&short));
764 }
765
766 #[test]
767 fn never_scrollable_rejects_user_offset() {
768 let physics = NeverScrollable::new();
769 assert!(!physics.should_accept_user_offset(&metrics(0.0, 500.0)));
770 // Even chained onto a physics that would accept.
771 let chained = NeverScrollable::new().chain(Bouncing::new());
772 assert!(!chained.should_accept_user_offset(&metrics(0.0, 500.0)));
773 }
774
775 #[test]
776 fn chain_composition_defers_boundary_to_parent() {
777 let physics = AlwaysScrollable::new().chain(Clamping::new());
778
779 // Its own domain: a drag is accepted even with nothing to scroll.
780 assert!(physics.should_accept_user_offset(&metrics(0.0, 0.0)));
781
782 // Everything else walks up to the clamping parent — the boundary rule…
783 let m = metrics(0.0, 500.0);
784 assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
785 assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
786 assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
787
788 // …the ballistic curve…
789 assert!(physics.create_ballistic_simulation(&m, 3000.0).is_some());
790 assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
791
792 // …and the fling threshold, which the parent leaves at the default.
793 assert_eq!(physics.min_fling_velocity(), 50.0);
794
795 // Chained the other way the bouncing parent's answers come through
796 // instead, including its doubled minimum.
797 let bouncing = AlwaysScrollable::new().chain(Bouncing::new());
798 assert_eq!(bouncing.min_fling_velocity(), Bouncing::MIN_FLING_VELOCITY);
799 assert_eq!(bouncing.apply_boundary_conditions(&m, 510.0), 0.0);
800 }
801}