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