frus_widgets/physics.rs
1//! SingleChildScrollView physics: **how a scrollable behaves at its edges and after the finger
2//! lifts**.
3//!
4//! Two families, because the platforms disagree and users notice:
5//!
6//! - [`ScrollPhysics::BOUNCING`] — dragging past the end is allowed but grows
7//! progressively harder, and letting go springs the content back. A fling rolls
8//! on friction and hands over to that spring at the edge.
9//! - [`ScrollPhysics::CLAMPING`] — the offset never leaves the content at all. A
10//! fling follows the platform's spline deceleration and stops dead at the edge.
11//!
12//! The [`ScrollBehavior`](crate::ScrollBehavior) in force picks one from the platform it
13//! follows, so an app that says nothing already feels native on each platform. A
14//! [`crate::scroll::SingleChildScrollView`] can ask for its own, and an application or a
15//! subtree can change the behaviour, for the cases where the content wants a particular
16//! feel.
17//!
18//! Everything here is a **pure function of the metrics**: no retained state, no
19//! clock. The runtime samples the returned [`Ballistic`] frame by frame; the shell
20//! calls [`ScrollPhysics::apply_user_offset`] while a finger is down. That split is
21//! what keeps the policy unit-testable without a window.
22
23use frus_core::{
24 BouncingScrollSimulation, ClampingScrollSimulation, Simulation, SpringDescription,
25 SpringSimulation, Tolerance,
26};
27
28/// The state of one scroll axis, as the physics sees it. All in logical pixels.
29///
30/// `pixels` is the current offset, `min`/`max` the offsets the content may rest
31/// at (`min` is 0 in practice, `max` the overflow), and `viewport` the visible
32/// extent along that axis — the yardstick the rubber band is measured against.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ScrollMetrics {
35 /// The current scroll offset.
36 pub pixels: f32,
37 /// The smallest offset the content may rest at; 0 in practice.
38 pub min: f32,
39 /// The largest offset the content may rest at: how far the content overflows.
40 pub max: f32,
41 /// The visible extent along the axis.
42 pub viewport: f32,
43}
44
45impl ScrollMetrics {
46 /// Metrics for an axis scrolling from 0 to `max` in a `viewport`-long window.
47 pub fn new(pixels: f32, max: f32, viewport: f32) -> Self {
48 Self {
49 pixels,
50 min: 0.0,
51 max: max.max(0.0),
52 viewport: viewport.max(1.0),
53 }
54 }
55
56 /// Is the offset outside the range the content may rest at?
57 pub fn out_of_range(&self) -> bool {
58 self.pixels < self.min || self.pixels > self.max
59 }
60
61 /// How far past an edge the offset has been dragged; 0 while inside.
62 pub fn overscroll_past(&self) -> f32 {
63 (self.min - self.pixels)
64 .max(self.pixels - self.max)
65 .max(0.0)
66 }
67}
68
69/// The default minimum release speed, in px/s, below which nothing is flung.
70pub const MIN_FLING_VELOCITY: f32 = 50.0;
71/// The cap applied to a release speed, in px/s.
72pub const MAX_FLING_VELOCITY: f32 = 8000.0;
73
74/// **What a scroll area shows when it is pulled past its edge** and its physics refuse
75/// to move (milestone 591).
76///
77/// Current touch platforms **stretch** the content towards the edge and spring it back;
78/// others **glow**, an arc of light over the edge. The
79/// [`ScrollBehavior`](crate::ScrollBehavior) in force picks one per platform, or none.
80#[derive(Copy, Clone, PartialEq, Eq, Debug)]
81pub enum OverscrollIndicator {
82 /// An arc of light over the edge, fading as the pull lets go.
83 Glow,
84 /// The content lengthens towards the edge, and springs back.
85 Stretch,
86}
87
88/// **When a scroll area draws a scrollbar** (`app.dart:857`).
89///
90/// The reference asks this question per platform and answers it *no* on every touch
91/// screen: a finger already knows where it is on the page, and a permanent bar over the
92/// content is a desktop affordance for a pointer that cannot feel the edges. Along the
93/// **horizontal** axis the answer is no everywhere, on every platform.
94#[derive(Copy, Clone, PartialEq, Eq, Debug)]
95pub enum Scrollbars {
96 /// Never — what a touch screen gets (`app.dart:870`).
97 Never,
98 /// One bar down the inner edge (`app.dart:865`), for the platforms whose own scroll
99 /// views have one.
100 Always,
101}
102
103/// **How fast a fling's momentum is taken away** — the bouncing family's second
104/// deceleration profile.
105///
106/// It is chosen by **what is doing the scrolling**, not by how fast. A finger
107/// throws a surface and lets go of it; a trackpad or a wheel is a hand resting on a
108/// device that reports motion, and the two want different endings. So this is a
109/// property of the physics a scrollable is built with — settled once, from the
110/// platform — and not something recomputed per fling.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
112pub enum ScrollDecelerationRate {
113 /// A finger's. What a touch screen expects, and the default.
114 #[default]
115 Normal,
116 /// A trackpad's or a wheel's — what desktop software expects from a pointing
117 /// device more precise than a fingertip.
118 ///
119 /// Four things differ, not one: the fling carries a constant deceleration so
120 /// that it actually stops rather than coasting to a halt at infinity, the
121 /// rubber band is twice as stiff, a fling may be twice again as fast, and
122 /// **easing back out of an overscroll is not resisted at all** — which is a
123 /// behaviour rather than a number, and the one a reader notices.
124 Fast,
125}
126
127/// **How a scrollable behaves at its edges and after a fling** — what the reference's
128/// bouncing and clamping physics differ by.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
130pub enum ScrollFeel {
131 /// Overscroll is allowed, resisted, and springs back — at the given rate.
132 Bouncing(ScrollDecelerationRate),
133 /// Overscroll is refused; a fling stops at the edge.
134 Clamping,
135}
136
137/// **Whether the user may scroll an area** (milestone 649): the reference's
138/// `AlwaysScrollableScrollPhysics` and `NeverScrollableScrollPhysics`, and the rule they
139/// both override.
140///
141/// Whatever it says, the application still moves the area: a controller's jump, a
142/// "bring this into view", a page turned in code. This is about the user's drag, fling and
143/// wheel only.
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
145pub enum UserScroll {
146 /// When there is content outside the viewport to reveal, or a pull to refresh above —
147 /// the reference's default rule.
148 #[default]
149 WhenOverflowing,
150 /// Always, even with everything in sight: a short list still bounces, and still takes
151 /// the drag from the area around it.
152 Always,
153 /// Never: the drag, the fling and the wheel go to the area around it, as if it were not
154 /// a scrollable at all.
155 Never,
156}
157
158/// The behaviour of a scrollable: its feel at the edges and after a fling, and whether the
159/// user may scroll it.
160///
161/// The reference builds these as a chain, each physics a parent to the next —
162/// `AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics())`. Here the same chain
163/// is two settings, written the same way round:
164///
165/// ```ignore
166/// ScrollPhysics::BOUNCING.always_scrollable() // bounces, and always takes the drag
167/// ScrollPhysics::NEVER // never takes it, whatever the platform
168/// ScrollPhysics::ALWAYS // always takes it, the platform's feel
169/// ```
170///
171/// A setting left open is the platform's: an area's physics are laid over its
172/// [`ScrollBehavior`](crate::ScrollBehavior)'s with [`apply_to`](Self::apply_to), as the
173/// reference applies a widget's physics to the scroll configuration's.
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
175pub struct ScrollPhysics {
176 /// The feel; `None` takes the platform's.
177 feel: Option<ScrollFeel>,
178 /// Whether the user may scroll; [`UserScroll::WhenOverflowing`] takes the platform's.
179 user: UserScroll,
180}
181
182impl ScrollPhysics {
183 /// Bouncing physics at a finger's deceleration — the common case, and what
184 /// `ScrollPhysics::BOUNCING` meant before there were two.
185 pub const BOUNCING: Self = Self::bouncing(ScrollDecelerationRate::Normal);
186
187 /// Clamping physics: overscroll refused, a fling stopping at the edge.
188 pub const CLAMPING: Self = Self {
189 feel: Some(ScrollFeel::Clamping),
190 user: UserScroll::WhenOverflowing,
191 };
192
193 /// The user may never scroll it — the reference's `NeverScrollableScrollPhysics` — and
194 /// the platform's feel for anything else.
195 pub const NEVER: Self = Self {
196 feel: None,
197 user: UserScroll::Never,
198 };
199
200 /// The user may always scroll it, even with everything in sight — the reference's
201 /// `AlwaysScrollableScrollPhysics` — with the platform's feel.
202 pub const ALWAYS: Self = Self {
203 feel: None,
204 user: UserScroll::Always,
205 };
206
207 /// Bouncing physics at `rate`.
208 pub const fn bouncing(rate: ScrollDecelerationRate) -> Self {
209 Self {
210 feel: Some(ScrollFeel::Bouncing(rate)),
211 user: UserScroll::WhenOverflowing,
212 }
213 }
214
215 /// These physics, and the user may always scroll.
216 pub const fn always_scrollable(self) -> Self {
217 Self {
218 user: UserScroll::Always,
219 ..self
220 }
221 }
222
223 /// These physics, and the user may never scroll.
224 pub const fn never_scrollable(self) -> Self {
225 Self {
226 user: UserScroll::Never,
227 ..self
228 }
229 }
230
231 /// These physics laid over `ancestor`'s — the reference's `applyTo`: what these
232 /// settle, they keep; what they leave open, `ancestor` decides.
233 pub fn apply_to(self, ancestor: ScrollPhysics) -> ScrollPhysics {
234 ScrollPhysics {
235 feel: self.feel.or(ancestor.feel),
236 user: match self.user {
237 UserScroll::WhenOverflowing => ancestor.user,
238 user => user,
239 },
240 }
241 }
242
243 /// The feel in force: clamping when nothing settled one.
244 pub fn feel(self) -> ScrollFeel {
245 self.feel.unwrap_or(ScrollFeel::Clamping)
246 }
247
248 /// Whether the user may scroll.
249 pub fn user(self) -> UserScroll {
250 self.user
251 }
252
253 /// The deceleration profile in force, or `None` for a family that has none.
254 pub fn deceleration_rate(self) -> Option<ScrollDecelerationRate> {
255 match self.feel() {
256 ScrollFeel::Bouncing(rate) => Some(rate),
257 ScrollFeel::Clamping => None,
258 }
259 }
260}
261
262impl ScrollPhysics {
263 /// The spring that returns overscrolled content to its edge.
264 pub fn spring(self) -> SpringDescription {
265 SpringDescription::with_damping_ratio(0.5, 100.0, 1.1)
266 }
267
268 /// The thresholds under which the motion counts as finished.
269 pub fn tolerance(self) -> Tolerance {
270 Tolerance::PIXELS
271 }
272
273 /// The release speed under which a drag does not fling at all.
274 ///
275 /// Bouncing physics decelerates far more gently, so it asks for a more
276 /// deliberate gesture before it commits to a fling.
277 pub fn min_fling_velocity(self) -> f32 {
278 match self.feel() {
279 ScrollFeel::Bouncing(_) => MIN_FLING_VELOCITY * 2.0,
280 ScrollFeel::Clamping => MIN_FLING_VELOCITY,
281 }
282 }
283
284 /// The cap on a release speed.
285 ///
286 /// A pointing device can be flicked far harder than a finger can — a wheel
287 /// notch or a two-finger flick on a trackpad reports speeds no fingertip
288 /// reaches — so the fast profile lifts the ceiling eightfold rather than
289 /// clipping what the hardware actually said.
290 pub fn max_fling_velocity(self) -> f32 {
291 match self.feel() {
292 ScrollFeel::Bouncing(ScrollDecelerationRate::Fast) => MAX_FLING_VELOCITY * 8.0,
293 _ => MAX_FLING_VELOCITY,
294 }
295 }
296
297 /// The distance a finger must cover before the content starts to move, in px.
298 ///
299 /// Bouncing physics asks for a little more, to swallow the small unintended
300 /// motion of a finger lifting off.
301 pub fn drag_start_distance_threshold(self) -> f32 {
302 match self.feel() {
303 ScrollFeel::Bouncing(_) => 3.5,
304 ScrollFeel::Clamping => 0.0,
305 }
306 }
307
308 /// The speed carried over when a fling is thrown on top of one already
309 /// running — the repeated-swipe speed-up.
310 ///
311 /// Only the bouncing family builds momentum this way; clamping starts each
312 /// fling from the gesture alone.
313 pub fn carried_momentum(self, existing_velocity: f32) -> f32 {
314 match self.feel() {
315 ScrollFeel::Bouncing(_) => {
316 existing_velocity.signum()
317 * (0.000_816 * existing_velocity.abs().powf(1.967)).min(40_000.0)
318 }
319 ScrollFeel::Clamping => 0.0,
320 }
321 }
322
323 /// How much of a finger's `delta` actually reaches the offset.
324 ///
325 /// Inside the content it is all of it. Past an edge, the bouncing family
326 /// applies a friction that falls off quadratically with how far out we already
327 /// are: the further you pull, the less each pixel of finger buys — the rubber
328 /// band. Easing back towards the content is measured at the position the
329 /// gesture is heading *to*, so releasing tension is easier than adding it.
330 pub fn apply_user_offset(self, metrics: ScrollMetrics, delta: f32) -> f32 {
331 let Some(rate) = self.deceleration_rate() else {
332 return delta;
333 };
334 if delta == 0.0 || !metrics.out_of_range() {
335 return delta;
336 }
337 let past_start = (metrics.min - metrics.pixels).max(0.0);
338 let past_end = (metrics.pixels - metrics.max).max(0.0);
339 let past = past_start.max(past_end);
340 // `delta` is a change **to the offset**: past the start (`pixels < min`) a
341 // positive delta walks back towards the content, and past the end a
342 // negative one does. Those are the directions that release the band.
343 let easing = (past_start > 0.0 && delta > 0.0) || (past_end > 0.0 && delta < 0.0);
344 // **Easing out of an overscroll is unresisted on the fast profile**, and
345 // that is the difference a reader actually notices. A finger that pulled a
346 // list past its end is still holding the band and lets it back gradually; a
347 // trackpad has nothing to hold, so making the return sticky reads as the
348 // content refusing to come back rather than as tension.
349 if easing && rate == ScrollDecelerationRate::Fast {
350 return delta;
351 }
352 let fraction = if easing {
353 (past - delta.abs()) / metrics.viewport
354 } else {
355 past / metrics.viewport
356 };
357 delta.signum() * apply_friction(past, delta.abs(), friction_factor(rate, fraction))
358 }
359
360 /// The part of a proposed move that must be **refused**, so that the caller
361 /// can apply `value - refused`.
362 ///
363 /// Clamping physics refuses everything that would leave the content; bouncing
364 /// physics refuses nothing, and lets the spring deal with it.
365 pub fn apply_boundary_conditions(self, metrics: ScrollMetrics, value: f32) -> f32 {
366 if self.feel() != ScrollFeel::Clamping {
367 return 0.0;
368 }
369 let p = metrics.pixels;
370 if value < p && p <= metrics.min {
371 return value - p; // already under the start: refuse the whole move
372 }
373 if metrics.max <= p && p < value {
374 return value - p; // already past the end
375 }
376 if value < metrics.min && metrics.min < p {
377 return value - metrics.min; // this move hits the start
378 }
379 if p < metrics.max && metrics.max < value {
380 return value - metrics.max; // this move hits the end
381 }
382 0.0
383 }
384
385 /// May the content be dragged past its edges at all?
386 pub fn allows_overscroll(self) -> bool {
387 matches!(self.feel(), ScrollFeel::Bouncing(_))
388 }
389
390 /// The motion that follows the finger lifting off at `velocity` (px/s, positive
391 /// meaning the offset grows). `None` when nothing should move.
392 pub fn ballistic(self, metrics: ScrollMetrics, velocity: f32) -> Option<Ballistic> {
393 let tolerance = self.tolerance();
394 let velocity = velocity.clamp(-self.max_fling_velocity(), self.max_fling_velocity());
395 match self.feel() {
396 ScrollFeel::Bouncing(rate) => {
397 if velocity.abs() >= tolerance.velocity || metrics.out_of_range() {
398 Some(Ballistic::Bouncing(
399 BouncingScrollSimulation::with_constant_deceleration(
400 metrics.pixels,
401 velocity,
402 metrics.min,
403 metrics.max,
404 self.spring(),
405 tolerance,
406 match rate {
407 ScrollDecelerationRate::Normal => 0.0,
408 ScrollDecelerationRate::Fast => CONSTANT_DECELERATION_FAST,
409 },
410 ),
411 ))
412 } else {
413 None
414 }
415 }
416 ScrollFeel::Clamping => {
417 // Out of range at all — after a resize, say — is a return to the
418 // edge, never a fling. The velocity may only help it home.
419 if metrics.out_of_range() {
420 let end = if metrics.pixels > metrics.max {
421 metrics.max
422 } else {
423 metrics.min
424 };
425 return Some(Ballistic::Spring(SpringSimulation::new(
426 self.spring(),
427 metrics.pixels,
428 end,
429 velocity.min(0.0),
430 tolerance,
431 )));
432 }
433 if velocity.abs() < tolerance.velocity
434 || (velocity > 0.0 && metrics.pixels >= metrics.max)
435 || (velocity < 0.0 && metrics.pixels <= metrics.min)
436 {
437 return None;
438 }
439 Some(Ballistic::Clamping(ClampingScrollSimulation::new(
440 metrics.pixels,
441 velocity,
442 tolerance,
443 )))
444 }
445 }
446 }
447
448 /// The motion that follows the finger lifting off a **paged** area, where the
449 /// content may not come to rest between two pages. `extent` is the distance
450 /// from one page to the next.
451 ///
452 /// This replaces the fling rather than correcting it afterwards: a paged view
453 /// never coasts. Whatever the release speed, the content springs to **one**
454 /// page — the next one on a flick, the nearer one otherwise. A fling that
455 /// crossed three pages and then had to be caught and pulled back would be a
456 /// different, worse gesture.
457 ///
458 /// Past an edge and still heading out, there is no page to go to, so the
459 /// ordinary physics takes over — which is what brings the overscroll home,
460 /// with the platform's own bounce or clamp.
461 pub fn page_ballistic(
462 self,
463 metrics: ScrollMetrics,
464 velocity: f32,
465 extent: f32,
466 ) -> Option<Ballistic> {
467 if (velocity <= 0.0 && metrics.pixels <= metrics.min)
468 || (velocity >= 0.0 && metrics.pixels >= metrics.max)
469 {
470 return self.ballistic(metrics, velocity);
471 }
472 let tolerance = self.tolerance();
473 let target = page_target(metrics, extent, velocity, tolerance.velocity);
474 if (target - metrics.pixels).abs() < tolerance.distance {
475 return None;
476 }
477 Some(Ballistic::Spring(SpringSimulation::new(
478 self.spring(),
479 metrics.pixels,
480 target,
481 velocity,
482 tolerance,
483 )))
484 }
485}
486
487/// Which page an offset sits on, as a **fraction**: `1.5` is halfway between the
488/// second and the third. `extent` is the distance from one page to the next.
489pub fn page_of(pixels: f32, extent: f32) -> f32 {
490 pixels / extent.max(1.0)
491}
492
493/// The offset the content must come to rest at after a release at `velocity`.
494///
495/// The rule is the one every paged view uses, and it is not "nearest page":
496/// **any** release with speed above the tolerance counts as a flick and moves the
497/// page half a unit *the way the finger went* before rounding. So a flick that
498/// covered a tenth of the viewport still turns the page, while letting go slowly
499/// falls back to whichever page is nearer. A gesture is an intention, not a
500/// measurement.
501pub fn page_target(metrics: ScrollMetrics, extent: f32, velocity: f32, tolerance: f32) -> f32 {
502 let mut page = page_of(metrics.pixels, extent);
503 if velocity < -tolerance {
504 page -= 0.5;
505 } else if velocity > tolerance {
506 page += 0.5;
507 }
508 // A view whose content is not a whole number of pages — every one of them once
509 // the pages are narrower than the viewport — has a last page that is not at a
510 // page boundary. Resting past the end is not a page, it is an overscroll.
511 (page.round() * extent.max(1.0)).clamp(metrics.min, metrics.max)
512}
513
514/// The resting place a **snapping sheet** heads for when the finger lets go at `position`
515/// moving at `velocity` — both in the sheet's own units, growing as it rises. `snaps`
516/// are the positions it may rest at, in increasing order.
517///
518/// The rule is the paged view's, stated on uneven stops rather than a fixed extent: a
519/// release slower than `tolerance` goes to the **nearer** of the two stops around it,
520/// and any faster one to the next stop **the way it was going**, however little of the
521/// way it had come. A position already on a stop stays there, and one outside the stops
522/// comes back to the nearest end.
523pub fn snap_target(position: f32, velocity: f32, snaps: &[f32], tolerance: f32) -> f32 {
524 let (Some(&first), Some(&last)) = (snaps.first(), snaps.last()) else {
525 return position;
526 };
527 let Some(next) = snaps.iter().position(|&snap| snap >= position) else {
528 return last;
529 };
530 if next == 0 {
531 return first;
532 }
533 let (below, above) = (snaps[next - 1], snaps[next]);
534 if above == position {
535 return above;
536 }
537 if velocity.abs() <= tolerance {
538 return if position - below < above - position {
539 below
540 } else {
541 above
542 };
543 }
544 if velocity < 0.0 {
545 below
546 } else {
547 above
548 }
549}
550
551/// The constant deceleration the **fast** profile adds on top of the drag, in
552/// px·s⁻² — the reference's own figure.
553///
554/// Drag alone is geometric: the velocity is multiplied down each second and reaches
555/// nought only at infinity, which is exactly right for a surface that was thrown.
556/// A trackpad gesture is not a throw, and a motion that never quite settles under
557/// one reads as the content drifting. This term is what gives it an end.
558pub const CONSTANT_DECELERATION_FAST: f32 = 1400.0;
559
560/// The friction applied to the first pixels of overscroll, before the band
561/// stiffens. It falls off quadratically with how far out the drag already is.
562///
563/// The **fast** profile halves the starting figure, which is to say it doubles the
564/// stiffness: a trackpad reports a great deal of motion for a small gesture, and a
565/// band as slack as a finger's would let a flick throw the content a long way out.
566fn friction_factor(rate: ScrollDecelerationRate, overscroll_fraction: f32) -> f32 {
567 let base = match rate {
568 ScrollDecelerationRate::Normal => 0.52,
569 ScrollDecelerationRate::Fast => 0.26,
570 };
571 (1.0 - overscroll_fraction).powi(2) * base
572}
573
574/// Applies `gamma` to the part of `delta` that is spent outside the content, and
575/// none of it to the part that is spent inside — a drag that crosses the edge
576/// mid-way is resisted only for the half that is out.
577fn apply_friction(extent_outside: f32, mut delta: f32, gamma: f32) -> f32 {
578 let mut total = 0.0;
579 if extent_outside > 0.0 && gamma > 0.0 {
580 let delta_to_limit = extent_outside / gamma;
581 if delta < delta_to_limit {
582 return delta * gamma;
583 }
584 total += extent_outside;
585 delta -= delta_to_limit;
586 }
587 total + delta
588}
589
590/// The motion a scrollable follows once the finger has let go.
591///
592/// One enum rather than a boxed trait object: it stays `Copy`, so the runtime can
593/// keep one per axis in a plain map and sample it without an allocation per frame.
594#[derive(Clone, Copy, Debug)]
595pub enum Ballistic {
596 /// Friction then spring-back — the bouncing family.
597 Bouncing(BouncingScrollSimulation),
598 /// The platform spline deceleration — the clamping family.
599 Clamping(ClampingScrollSimulation),
600 /// A plain return to an edge, with no fling.
601 Spring(SpringSimulation),
602}
603
604impl Simulation for Ballistic {
605 fn x(&self, time: f32) -> f32 {
606 match self {
607 Ballistic::Bouncing(s) => s.x(time),
608 Ballistic::Clamping(s) => s.x(time),
609 Ballistic::Spring(s) => s.x(time),
610 }
611 }
612
613 fn dx(&self, time: f32) -> f32 {
614 match self {
615 Ballistic::Bouncing(s) => s.dx(time),
616 Ballistic::Clamping(s) => s.dx(time),
617 Ballistic::Spring(s) => s.dx(time),
618 }
619 }
620
621 fn is_done(&self, time: f32) -> bool {
622 match self {
623 Ballistic::Bouncing(s) => s.is_done(time),
624 Ballistic::Clamping(s) => s.is_done(time),
625 Ballistic::Spring(s) => s.is_done(time),
626 }
627 }
628
629 fn tolerance(&self) -> Tolerance {
630 match self {
631 Ballistic::Bouncing(s) => s.tolerance(),
632 Ballistic::Clamping(s) => s.tolerance(),
633 Ballistic::Spring(s) => s.tolerance(),
634 }
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 const STOPS: [f32; 3] = [200.0, 400.0, 800.0];
643
644 /// **Let go slowly, and a sheet rests at the nearer stop** — measured on the uneven
645 /// gaps a sheet has, not on a page extent: 290 is nearer 200, 310 nearer 400, and 700
646 /// nearer 800 although it is past the middle of nothing a page view would recognise.
647 #[test]
648 fn a_slow_release_rests_at_the_nearer_stop() {
649 assert_eq!(snap_target(290.0, 0.0, &STOPS, 1.0), 200.0);
650 assert_eq!(snap_target(310.0, 0.0, &STOPS, 1.0), 400.0);
651 assert_eq!(snap_target(550.0, 0.0, &STOPS, 1.0), 400.0);
652 assert_eq!(snap_target(700.0, 0.5, &STOPS, 1.0), 800.0);
653 }
654
655 /// **Any flick goes to the next stop the way it went**, however little of the way it
656 /// had come — and never further than the next one, however hard.
657 #[test]
658 fn a_flick_goes_to_the_next_stop_its_way_and_no_further() {
659 assert_eq!(snap_target(210.0, 50.0, &STOPS, 1.0), 400.0);
660 assert_eq!(snap_target(790.0, -50.0, &STOPS, 1.0), 400.0);
661 assert_eq!(snap_target(210.0, 9000.0, &STOPS, 1.0), 400.0);
662 assert_eq!(snap_target(390.0, -9000.0, &STOPS, 1.0), 200.0);
663 }
664
665 /// On a stop it stays, whichever way it was nudged; outside them it comes back to the
666 /// nearest end; with no stops it rests where it is.
667 #[test]
668 fn a_stop_holds_and_the_ends_bound() {
669 assert_eq!(snap_target(400.0, 500.0, &STOPS, 1.0), 400.0);
670 assert_eq!(snap_target(150.0, -500.0, &STOPS, 1.0), 200.0);
671 assert_eq!(snap_target(900.0, 500.0, &STOPS, 1.0), 800.0);
672 assert_eq!(snap_target(333.0, 500.0, &[], 1.0), 333.0);
673 }
674
675 #[test]
676 fn inside_the_content_every_pixel_of_finger_reaches_the_offset() {
677 let metrics = ScrollMetrics::new(120.0, 400.0, 600.0);
678 for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
679 assert_eq!(physics.apply_user_offset(metrics, 25.0), 25.0);
680 assert_eq!(physics.apply_user_offset(metrics, -25.0), -25.0);
681 }
682 }
683
684 #[test]
685 fn bouncing_resists_more_the_further_out_you_pull() {
686 let viewport = 600.0;
687 let near = ScrollMetrics::new(-10.0, 400.0, viewport);
688 let far = ScrollMetrics::new(-200.0, 400.0, viewport);
689 // Pulling further out (a negative delta past the start) is resisted, and
690 // resisted harder the further out we already are.
691 let a = ScrollPhysics::BOUNCING.apply_user_offset(near, -20.0);
692 let b = ScrollPhysics::BOUNCING.apply_user_offset(far, -20.0);
693 assert!(a < 0.0 && b < 0.0);
694 assert!(a.abs() < 20.0, "the band already resists: {a}");
695 assert!(b.abs() < a.abs(), "further out must be harder: {b} vs {a}");
696 // Clamping physics has no band at all.
697 assert_eq!(ScrollPhysics::CLAMPING.apply_user_offset(far, -20.0), -20.0);
698 }
699
700 #[test]
701 fn bouncing_lets_you_ease_back_more_easily_than_you_pulled() {
702 let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
703 let out = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
704 let back = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
705 assert!(
706 back.abs() > out.abs(),
707 "releasing the band ({back}) should be easier than loading it ({out})"
708 );
709 }
710
711 #[test]
712 fn clamping_refuses_exactly_what_would_leave_the_content() {
713 let physics = ScrollPhysics::CLAMPING;
714 let metrics = ScrollMetrics::new(390.0, 400.0, 600.0);
715 // A move that stays inside is untouched.
716 assert_eq!(physics.apply_boundary_conditions(metrics, 395.0), 0.0);
717 // One that crosses the end is trimmed to the end.
718 assert_eq!(physics.apply_boundary_conditions(metrics, 430.0), 30.0);
719 // Already at the end, pushing further: the whole move is refused.
720 let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
721 assert_eq!(physics.apply_boundary_conditions(at_end, 420.0), 20.0);
722 // The start behaves symmetrically.
723 let near_start = ScrollMetrics::new(5.0, 400.0, 600.0);
724 assert_eq!(physics.apply_boundary_conditions(near_start, -15.0), -15.0);
725 // Bouncing physics refuses nothing.
726 assert_eq!(
727 ScrollPhysics::BOUNCING.apply_boundary_conditions(at_end, 420.0),
728 0.0
729 );
730 }
731
732 #[test]
733 fn a_slow_release_flings_nothing() {
734 let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
735 assert!(ScrollPhysics::CLAMPING.ballistic(metrics, 1.0).is_none());
736 assert!(ScrollPhysics::BOUNCING.ballistic(metrics, 1.0).is_none());
737 }
738
739 #[test]
740 fn clamping_fling_stops_dead_inside_the_content() {
741 let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
742 let sim = ScrollPhysics::CLAMPING.ballistic(metrics, 800.0).unwrap();
743 assert!(matches!(sim, Ballistic::Clamping(_)));
744 // It travels, then finishes; the runtime clamps the tail into the content.
745 assert!(sim.x(0.1) > 100.0);
746 assert!(sim.is_done(2.0));
747 assert!(sim.dx(2.0).abs() < 1.0, "no residual speed");
748 }
749
750 #[test]
751 fn clamping_at_the_edge_pushing_outwards_does_nothing() {
752 let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
753 assert!(ScrollPhysics::CLAMPING.ballistic(at_end, 900.0).is_none());
754 let at_start = ScrollMetrics::new(0.0, 400.0, 600.0);
755 assert!(ScrollPhysics::CLAMPING
756 .ballistic(at_start, -900.0)
757 .is_none());
758 }
759
760 #[test]
761 fn clamping_out_of_range_returns_to_the_edge() {
762 // Overscroll can still happen without a drag — the content shrank under a
763 // resting offset. Clamping physics springs it home rather than flinging.
764 let past = ScrollMetrics::new(460.0, 400.0, 600.0);
765 let sim = ScrollPhysics::CLAMPING.ballistic(past, 0.0).unwrap();
766 assert!(matches!(sim, Ballistic::Spring(_)));
767 assert!(
768 (sim.x(2.0) - 400.0).abs() < 0.5,
769 "settles at {}",
770 sim.x(2.0)
771 );
772 }
773
774 #[test]
775 fn bouncing_overscroll_springs_back_even_with_no_velocity() {
776 let past = ScrollMetrics::new(-40.0, 400.0, 600.0);
777 let sim = ScrollPhysics::BOUNCING.ballistic(past, 0.0).unwrap();
778 assert!((sim.x(2.0) - 0.0).abs() < 0.5, "settles at {}", sim.x(2.0));
779 assert!(sim.is_done(2.0));
780 }
781
782 #[test]
783 fn a_fling_never_exceeds_the_velocity_cap() {
784 let metrics = ScrollMetrics::new(0.0, 100_000.0, 600.0);
785 let sim = ScrollPhysics::CLAMPING
786 .ballistic(metrics, 50_000.0)
787 .unwrap();
788 assert!(
789 sim.dx(0.0) <= MAX_FLING_VELOCITY + 1.0,
790 "clipped to the cap, got {}",
791 sim.dx(0.0)
792 );
793 }
794
795 #[test]
796 fn bouncing_asks_for_a_more_deliberate_gesture() {
797 assert!(
798 ScrollPhysics::BOUNCING.min_fling_velocity()
799 > ScrollPhysics::CLAMPING.min_fling_velocity()
800 );
801 // Repeated swipes build speed only where the platform does.
802 assert!(ScrollPhysics::BOUNCING.carried_momentum(1000.0) > 0.0);
803 assert_eq!(ScrollPhysics::CLAMPING.carried_momentum(1000.0), 0.0);
804 // …and the carry is signed like the motion it continues, and capped.
805 assert!(ScrollPhysics::BOUNCING.carried_momentum(-1000.0) < 0.0);
806 assert!(ScrollPhysics::BOUNCING.carried_momentum(100_000.0) <= 40_000.0);
807 }
808
809 #[test]
810 fn metrics_report_how_far_out_of_range_they_are() {
811 let inside = ScrollMetrics::new(50.0, 400.0, 600.0);
812 assert!(!inside.out_of_range());
813 assert_eq!(inside.overscroll_past(), 0.0);
814 let before = ScrollMetrics::new(-12.0, 400.0, 600.0);
815 assert!(before.out_of_range());
816 assert_eq!(before.overscroll_past(), 12.0);
817 let after = ScrollMetrics::new(430.0, 400.0, 600.0);
818 assert!(after.out_of_range());
819 assert_eq!(after.overscroll_past(), 30.0);
820 }
821
822 /// A three-page view, 300 px per page: offsets 0, 300, 600.
823 fn paged(pixels: f32) -> ScrollMetrics {
824 ScrollMetrics::new(pixels, 600.0, 300.0)
825 }
826
827 #[test]
828 fn letting_go_slowly_falls_back_to_the_nearer_page() {
829 assert_eq!(page_target(paged(130.0), 300.0, 0.0, 2.0), 0.0);
830 assert_eq!(page_target(paged(170.0), 300.0, 0.0, 2.0), 300.0);
831 }
832
833 #[test]
834 fn a_flick_turns_the_page_however_short_it_was() {
835 // A tenth of a page, thrown forward: the page turns.
836 assert_eq!(page_target(paged(30.0), 300.0, 900.0, 2.0), 300.0);
837 // And the same distance thrown back stays put rather than going forward.
838 assert_eq!(page_target(paged(30.0), 300.0, -900.0, 2.0), 0.0);
839 // Dragged most of the way and flicked back: the flick wins over the drag.
840 assert_eq!(page_target(paged(260.0), 300.0, -900.0, 2.0), 0.0);
841 }
842
843 #[test]
844 fn a_page_target_never_lands_outside_the_content() {
845 // Pages narrower than the viewport: the last one is not on a boundary.
846 let metrics = ScrollMetrics::new(430.0, 450.0, 300.0);
847 assert_eq!(page_target(metrics, 240.0, 900.0, 2.0), 450.0);
848 assert_eq!(
849 page_target(ScrollMetrics::new(5.0, 600.0, 300.0), 300.0, -900.0, 2.0),
850 0.0
851 );
852 }
853
854 #[test]
855 fn a_paged_release_always_settles_on_a_page() {
856 for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
857 // Released mid-page with no speed at all: it still has to go somewhere.
858 let sim = physics.page_ballistic(paged(190.0), 0.0, 300.0).unwrap();
859 assert!(
860 (sim.x(3.0) - 300.0).abs() < 1.0,
861 "settled at {}",
862 sim.x(3.0)
863 );
864 // A hard fling crosses exactly one page, not three.
865 let sim = physics.page_ballistic(paged(10.0), 6000.0, 300.0).unwrap();
866 assert!(
867 (sim.x(3.0) - 300.0).abs() < 1.0,
868 "settled at {}",
869 sim.x(3.0)
870 );
871 }
872 }
873
874 #[test]
875 fn resting_on_a_page_starts_nothing() {
876 assert!(ScrollPhysics::CLAMPING
877 .page_ballistic(paged(300.0), 0.0, 300.0)
878 .is_none());
879 }
880
881 #[test]
882 fn past_an_edge_the_ordinary_physics_takes_the_content_home() {
883 // No page out there: bouncing springs back, clamping has nothing to do.
884 let out = ScrollMetrics::new(-40.0, 600.0, 300.0);
885 let sim = ScrollPhysics::BOUNCING
886 .page_ballistic(out, -50.0, 300.0)
887 .unwrap();
888 assert!((sim.x(3.0) - 0.0).abs() < 1.0, "settled at {}", sim.x(3.0));
889 assert!(ScrollPhysics::CLAMPING
890 .page_ballistic(ScrollMetrics::new(0.0, 600.0, 300.0), -900.0, 300.0)
891 .is_none());
892 }
893
894 // --- The second deceleration profile (milestone 499) ---
895
896 /// **The profile is a property of the device, not of the speed** — which is
897 /// the decision this milestone turns on, and the opposite of what #55 asked for.
898 ///
899 /// A finger's fling coasts: drag multiplies the velocity down each second and
900 /// only reaches nought at infinity, which is exactly what a thrown surface does.
901 /// A trackpad's stops. If the two were chosen by velocity, a hard fling and a
902 /// gentle one on the same device would end differently — so this asks the same
903 /// physics at two speeds three orders of magnitude apart and requires the same
904 /// kind of ending from both.
905 #[test]
906 fn the_profile_is_the_devices_and_never_the_speeds() {
907 // Content long enough that no fling here reaches an edge: what is being
908 // compared is the deceleration, and a spring taking over would be a
909 // different motion answering a different question.
910 let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
911 // Five seconds: long past where the fast profile has come to rest, and
912 // short of where `0.135^t` underflows a 32-bit float — which it does around
913 // twenty, and which would make a coasting fling look stopped for a reason
914 // that is about the arithmetic rather than about the physics.
915 let still_moving = |physics: ScrollPhysics, v: f32| {
916 let sim = physics.ballistic(metrics, v).expect("a fling");
917 sim.dx(5.0).abs() > 0.0
918 };
919 // Both within the finger cap, so the two speeds differ by nothing but
920 // themselves — the cap is one of the things the profiles disagree about.
921 for velocity in [300.0, 7000.0] {
922 assert!(
923 still_moving(ScrollPhysics::BOUNCING, velocity),
924 "a finger coasts, at {velocity} as at any other speed"
925 );
926 assert!(
927 !still_moving(
928 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast),
929 velocity
930 ),
931 "and a trackpad stops, at {velocity} as at any other speed"
932 );
933 }
934 }
935
936 /// And the stop is not merely eventual: the fast profile comes to rest **short
937 /// of** where coasting would have carried it, which is the whole reason a
938 /// desktop list does not drift on after the hand has left the trackpad.
939 #[test]
940 fn a_trackpad_fling_stops_short_of_where_a_finger_would_coast() {
941 let metrics = ScrollMetrics::new(200.0, 40_000.0, 600.0);
942 let travel = |physics: ScrollPhysics| {
943 let sim = physics.ballistic(metrics, 3000.0).expect("a fling");
944 sim.x(30.0) - 200.0
945 };
946 let finger = travel(ScrollPhysics::BOUNCING);
947 let trackpad = travel(ScrollPhysics::bouncing(ScrollDecelerationRate::Fast));
948 assert!(finger > 0.0 && trackpad > 0.0, "both travel forwards");
949 assert!(
950 trackpad < finger,
951 "the trackpad stops short: {trackpad} vs {finger}"
952 );
953 }
954
955 /// **The fast band is twice as stiff.** A trackpad reports a great deal of
956 /// motion for a small gesture, and a band as slack as a finger's would let one
957 /// flick throw the content a long way out of its content.
958 #[test]
959 fn the_fast_band_resists_twice_as_hard() {
960 let metrics = ScrollMetrics::new(-10.0, 400.0, 600.0);
961 let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
962 let trackpad =
963 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, -20.0);
964 assert!(finger < 0.0 && trackpad < 0.0, "both pull further out");
965 assert!(
966 (trackpad / finger - 0.5).abs() < 0.05,
967 "half as much movement for the same gesture: {trackpad} vs {finger}"
968 );
969 }
970
971 /// **Easing back out of an overscroll is free on a trackpad**, and this is the
972 /// one difference that is a behaviour rather than a number.
973 ///
974 /// A finger that pulled a list past its end is still holding the band, and
975 /// letting it back gradually is what tension feels like. A trackpad is holding
976 /// nothing; the same stickiness reads as the content refusing to come back.
977 #[test]
978 fn easing_out_of_an_overscroll_is_unresisted_on_a_trackpad() {
979 let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
980 // Past the start, a positive delta walks back towards the content.
981 let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
982 let trackpad =
983 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, 20.0);
984 assert_eq!(trackpad, 20.0, "every pixel of the gesture comes back");
985 assert!(
986 finger < 20.0,
987 "where a finger still meets some resistance: {finger}"
988 );
989 // And tensioning is still resisted on both — it is the direction that
990 // differs, not the family.
991 assert!(
992 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast)
993 .apply_user_offset(metrics, -20.0)
994 .abs()
995 < 20.0,
996 "pulling further out is still resisted"
997 );
998 }
999
1000 /// A pointing device reports speeds no fingertip reaches, so the fast profile
1001 /// lifts the ceiling rather than clipping what the hardware actually said.
1002 #[test]
1003 fn a_trackpad_may_be_flung_eight_times_as_hard() {
1004 assert_eq!(
1005 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).max_fling_velocity(),
1006 ScrollPhysics::BOUNCING.max_fling_velocity() * 8.0
1007 );
1008 assert_eq!(
1009 ScrollPhysics::CLAMPING.max_fling_velocity(),
1010 ScrollPhysics::BOUNCING.max_fling_velocity(),
1011 "and the clamping family is untouched"
1012 );
1013 }
1014
1015 /// **The finger profile is exactly what it always was.** Everything above adds
1016 /// a second answer; none of it may change the first, which is what every
1017 /// hand-held build of this framework runs.
1018 #[test]
1019 fn the_finger_profile_is_unchanged() {
1020 assert_eq!(friction_factor(ScrollDecelerationRate::Normal, 0.0), 0.52);
1021 assert_eq!(
1022 ScrollPhysics::BOUNCING.max_fling_velocity(),
1023 MAX_FLING_VELOCITY
1024 );
1025 // Its fling carries no constant deceleration, so it coasts — the property
1026 // the closed-form friction has always had.
1027 let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
1028 let sim = ScrollPhysics::BOUNCING
1029 .ballistic(metrics, 3000.0)
1030 .expect("a fling");
1031 assert!(
1032 sim.dx(5.0).abs() > 0.0,
1033 "still creeping at five seconds, as before"
1034 );
1035 }
1036
1037 /// **Never and always lie over the platform's physics** (milestone 649), as the
1038 /// reference's `applyTo` does: what a physics settles it keeps, what it leaves open the
1039 /// platform decides.
1040 #[test]
1041 fn never_and_always_lie_over_the_platform() {
1042 let ios = ScrollPhysics::BOUNCING;
1043 let android = ScrollPhysics::CLAMPING;
1044 assert_eq!(ScrollPhysics::NEVER.user(), UserScroll::Never);
1045 assert_eq!(ScrollPhysics::ALWAYS.user(), UserScroll::Always);
1046 assert_eq!(ios.user(), UserScroll::WhenOverflowing, "the default rule");
1047
1048 let never = ScrollPhysics::NEVER.apply_to(ios);
1049 assert_eq!(never.user(), UserScroll::Never);
1050 assert_eq!(
1051 never.feel(),
1052 ScrollFeel::Bouncing(ScrollDecelerationRate::Normal)
1053 );
1054 assert!(
1055 never.allows_overscroll(),
1056 "a programmatic overshoot still bounces"
1057 );
1058 let always = ScrollPhysics::ALWAYS.apply_to(android);
1059 assert_eq!(
1060 (always.user(), always.feel()),
1061 (UserScroll::Always, ScrollFeel::Clamping)
1062 );
1063
1064 // A feel given keeps itself, and takes the platform's rule.
1065 let given = ScrollPhysics::BOUNCING.apply_to(android.always_scrollable());
1066 assert_eq!(
1067 given.feel(),
1068 ScrollFeel::Bouncing(ScrollDecelerationRate::Normal)
1069 );
1070 assert_eq!(given.user(), UserScroll::Always);
1071 // A rule given keeps itself over the platform's.
1072 let kept = ScrollPhysics::CLAMPING
1073 .never_scrollable()
1074 .apply_to(ios.always_scrollable());
1075 assert_eq!(
1076 (kept.user(), kept.feel()),
1077 (UserScroll::Never, ScrollFeel::Clamping)
1078 );
1079 // Nothing settled at all is clamping, and the default rule.
1080 let open = ScrollPhysics::NEVER;
1081 assert_eq!(open.feel(), ScrollFeel::Clamping);
1082 assert_eq!(
1083 ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).deceleration_rate(),
1084 Some(ScrollDecelerationRate::Fast)
1085 );
1086 }
1087}