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