Kinetic Scrolling
Velocity tracking, fling physics and the rubber band, implemented once in
teksilo-core::kinetic.
1. The two physics, and when each applies
Two families ship. They are genuinely different conventions, not tunings of one
curve, and the choice is made once per scrollable by its
OverscrollStyle:
ClampingSimulation | BouncingSimulation | |
|---|---|---|
| Upstream | Android OverScroller | Flutter BouncingScrollSimulation (iOS UIScrollView) |
| Selected by | OverscrollStyle::Clamp | OverscrollStyle::RubberBand |
| In-range motion | friction spline | exponential decay |
| At the boundary | stops dead | overshoots, spring pulls it back |
| Feels like | Windows, GTK, Android | macOS, iOS |
ScrollPhysics::Platform, the default, resolves to Bouncing on macOS and
iOS and to Clamping everywhere else, through
resolve_platform_physics. An
app that wants one feel on every OS states Clamping or Bouncing outright.
The style picks both the drag behaviour and the fling simulation, because they are one decision. A surface that clamped under the finger but bounced on release would read as two different scrollables sharing a rectangle.
The third piece: the rubber band
rubber_band belongs to
neither simulation. It is the drag-time companion to the bouncing feel, the
falling-gain curve that makes content resist a finger past the edge. The fling
never calls it: past the edge, the spring is the return.
Flutter states the rule as a gain, frictionFactor(f) = 0.52·(1 − f)², where f
is how far past the edge the content already is as a fraction of the viewport.
iOS is documented with a closed form, (1 − 1/(x·c/d + 1))·d. These are the same
curve: integrating Flutter's gain over the finger's travel yields iOS's closed
form exactly, with c = 0.52 in place of Apple's 0.55. The derivation is written
out at the function, and a unit test measures the gain at five points along the
curve and checks it against 0.52·(1 − f)², so the claim that the two upstream
statements agree is asserted, not just asserted-in-prose.
rubber_band_inverse recovers the finger travel from a damped displacement. A
drag that resumes from an overscrolled position (a fling left the content past
the edge, then a finger grabbed it) has to continue from the right point on the
curve rather than restart at its origin.
2. The constants
Every value is traceable to upstream and is cited at its definition together with the arithmetic that produces it. Scroll feel is muscle memory; a curve that is merely plausible reads as broken, and "plausible" cannot be reviewed.
Velocity tracker
Declared in
kinetic/velocity.rs.
| Constant | Value | Source |
|---|---|---|
HISTORY_SIZE | 20 samples | Flutter VelocityTracker._historySize |
HORIZON | 100 ms | Flutter _horizonMilliseconds |
MIN_SAMPLE_SIZE | 3 samples | Flutter _minSampleSize |
STOP_GAP | 40 ms | Flutter _assumePointerMoveStoppedMilliseconds |
| fit degree | 2 | Flutter LeastSquaresSolver(…).solve(2) |
Flutter took all five from Android's LeastSquaresVelocityTrackerStrategy.
They are load-bearing, not decorative. The 100 ms horizon is what makes a long slow drag ending in a flick report the flick rather than the drag. The 40 ms gap is what makes a pointer that stopped, waited, then lifted report zero rather than the velocity it had before the pause. The degree-2 fit is what lets a decelerating finger report the velocity it actually had at release rather than its average over the window, the reason a two-point difference is not good enough.
Fewer than three usable samples yields None, not a guess.
Clamping physics
Declared in
ScrollPhysicsTokens, consumed in
kinetic/simulation.rs.
| Constant | Value | Source |
|---|---|---|
clamping_deceleration_rate | ln(0.78)/ln(0.9) ≈ 2.358202 | Android OverScroller.DECELERATION_RATE |
clamping_inflexion | 0.35 | Android OverScroller.INFLEXION |
clamping_friction | 0.015 | Android ViewConfiguration.getScrollFriction() |
START_TENSION | 0.5 | Android OverScroller.START_TENSION |
END_TENSION | 1.0 | Android OverScroller.END_TENSION |
NB_SAMPLES | 100 | Android OverScroller.NB_SAMPLES |
| gravity | 9.80665 m/s² | Android SensorManager.GRAVITY_EARTH |
| inches per metre | 39.37 | Android mPhysicalCoeff |
| pixels per inch | 160 | Android density 1.0, see below |
| tuning factor | 0.84 | Android mPhysicalCoeff |
mPhysicalCoeff = 9.80665 × 39.37 × 160 × 0.84 = 51890.2017.
Why 160 ppi. Android computes ppi = displayMetrics.density × 160. Teksilo's
logical pixel is the density-1 Android pixel, that is what "dp" means, so the
substitution is density 1.0. Flutter makes the same substitution when it
hardcodes 61774.04968 (= 9.80665 × 39.37 × 160).
Android's SPLINE_POSITION table is rebuilt by the same bisection rather
than approximated by a cubic fit, so a reader can put the code next to
OverScroller.java and check it line by line. Android's companion SPLINE_TIME
table is not reproduced: it serves startScroll's viscous-fluid interpolator,
not the fling.
Bouncing physics
| Constant | Value | Source |
|---|---|---|
bouncing_decay_per_second | 0.135 | Flutter BouncingScrollSimulation, UIScrollView.decelerationRate.normal (0.998) to the 1000th power |
spring_mass | 0.5 | Flutter SpringDescription |
spring_stiffness | 100 | Flutter SpringDescription |
spring_damping_ratio | 1.1 | just overdamped, the return never oscillates back past the edge |
maxSpringTransferVelocity | 5000 dp/s | Flutter BouncingScrollSimulation.maxSpringTransferVelocity |
rubber_band_factor | 0.52 | Flutter BouncingScrollPhysics.frictionFactor (iOS uses 0.55) |
The 1.1 damping ratio makes the spring overdamped (cmk = damping² − 4·m·k = 242 − 200 = 42 > 0), which is deliberate: a scroll edge that springs back and
overshoots inward reads as a bug. A unit test asserts the return is monotone.
Settle tolerances
| Constant | Value | Source |
|---|---|---|
SETTLE_DISTANCE_TOLERANCE | 0.1 dp | Flutter ScrollPhysics.tolerance 1/(devicePixelRatio × 10) at dpr 1 |
SETTLE_VELOCITY_TOLERANCE | 20 dp/s | Flutter ScrollPhysics.tolerance 1/(0.050 × devicePixelRatio) at dpr 1 |
FLING_FRAME_INTERVAL | 16 667 µs | 60 Hz, matching FrameTickScheduler and the animation schedulers |
Flutter's generic Tolerance.defaultTolerance (1e-3) is deliberately not
used: at scroll scale it keeps a simulation nominally alive for seconds after
the last visible movement.
Fling velocity gates
Read from the active
GestureProfile, not from the physics
tokens, because they are properties of the input device rather than of the
surface:
| Constant | Value | Source |
|---|---|---|
min_fling_velocity | 50 dp/s | Android ViewConfiguration.MINIMUM_FLING_VELOCITY |
max_fling_velocity | 8000 dp/s | Android ViewConfiguration.MAXIMUM_FLING_VELOCITY |
A release below the floor returns false and starts nothing at all, that is
the point of the floor, so a slow release settles where the finger left it
rather than drifting. A release above the ceiling is scaled down to it,
direction preserved: clamped, never refused.
The three deliberate divergences from upstream
Each is documented at its site in the source, with its reason:
f64least-squares arithmetic. A degree-2 Vandermonde matrix built from millisecond-scale abscissae is ill-conditioned enough that single precision visibly moves the answer. Flutter usesdoublefor the same reason; Teksilo's public surface staysf32.- The underdamped spring's decay rate. Flutter spells it
-(damping / 2.0 * mass), that is−(c/2)·m, where the damped-oscillator solution calls for−c/(2m). The two agree only atmass == 1, which is Flutter's default and why the divergence has survived there. Teksilo's spring hasmass == 0.5, so the textbook form is used. Not reachable with the shipped tokens (ratio 1.1 is overdamped). - The signed spring hand-off velocity. Flutter caps the hand-off with a
bare
math.minon a quantity it has already made positive; Teksilo caps the magnitude of the signed velocity, so the leading and trailing hand-offs are one expression and the spring is always launched in the direction the content was actually travelling.
Plus one pin: SPLINE_POSITION[0] is set to exactly 0. Android pins only the far
end ([NB_SAMPLES] = 1.0), leaving the near end at the bisection's ~2.3e-5
residue, invisible there because Android starts a fling from the current scroll
position, but a ScrollSimulation is asked for position(0) directly, and
2.3e-5 of a 2157 dp fling is a 0.05 dp jump at the instant the finger lifts.
3. Reduced motion
prefers-reduced-motion is not advisory here. With
KineticScroller::set_reduced_motion(true):
- A fling does not coast. It collapses to a settle that completes on the very
next
tick, so the content arrives at a legal offset in one step rather than sliding. Turning the preference on mid-coast collapses the running coast too, rather than letting it play out. - The rubber band is hard-clamped. The content stops at the boundary instead of following the finger past it and springing back. The reported overscroll is always zero.
FlingDriver::startis a no-op, and a running coast is dropped. A re-dispatched fling has no settle to collapse to, because the target already has the content where the finger left it.
That is the scrolling half of the accessibility rule, and the only half this module owns. The magnifier's appearance and follow, the selection toolbar's entrance, the long-press tooltip fade, the scrollbar reveal fade, the overlay reposition on a soft-keyboard inset, and the density-switch relayout each belong to the package that owns that surface.
4. The macOS momentum rule
macOS, and a precision trackpad on any host that forwards its phases,
simulates the coast itself and delivers it as a stream of
ScrollPhase::Momentum deltas. Starting a Teksilo fling on top of that is the
classic double-momentum bug: two independent simulations moving the same
content, so the content speeds up when the finger lifts.
The guard is a phase-aware entry point, so that no call site has to remember the rule, the phase is already in hand at every release:
#![allow(unused)] fn main() { scroller.set_os_momentum(caps.reports_os_momentum); // once, from the backend // …then, at every release: scroller.fling_for_phase(sample.phase, velocity, profile); }
fling_for_phase starts a coast only when should_fling_for_phase allows it:
ScrollPhase | Flings? | Why |
|---|---|---|
Ended | only if the host does not report its own momentum | the fingers lifted and nobody else is animating |
Fling | yes | the host reported a release velocity and handed the coast to us |
Momentum | never | the host is already animating, this is the bug |
MomentumEnded | never | same |
Discrete, Began, Changed, Cancelled | never | not a release |
should_fling_for_phase is public, for a consumer that must ask before it has a
velocity. Calling plain fling bypasses the guard; do that only where the phase
is already known to be a release Teksilo owns.
5. The two driver types
KineticScroller: one surface's physics
Owned by a scrollable. Holds the offset, the range, a VelocityTracker, and at
most one running simulation.
#![allow(unused)] fn main() { let mut s = KineticScroller::new(OverscrollStyle::RubberBand); s.set_range_y(0.0, content_height - viewport_height); s.set_viewport(viewport_size); // only the rubber band reads this s.set_reduced_motion(ctx.prefers_reduced_motion()); // While the finger is down: let step = s.pan(time, pointer_position, offset_delta); // step.offset, clamped into the range // step.overscroll, how far past it, after the rubber band // step.absorbed, per axis: did this axis take any of the movement? // On release: if s.fling_for_phase(phase, velocity, profile) { /* schedule ticks */ } // Each frame while animating (`None` once the coast has finished): if let Some(step) = s.tick(now) { … } }
ScrollStep::absorbed is the boundary hand-off signal. An axis that absorbed
nothing has nothing left to give, so the event belongs to an ancestor
scrollable. It answers one question, did the content actually move by more than
SCROLL_MOVE_EPSILON?, which is why an axis asked for nothing and an axis asked
for 50 dp that could only give a rounding error both report false. Both must
chain.
Sign conventions. pan's delta is a change in scroll offset (positive =
the offset grows); position is the pointer, recorded only for the velocity
estimate. pointer_velocity() therefore follows the finger, and a surface that
drags its content opposite to the finger negates before passing it to fling,
which takes its velocity in offset space.
next_deadline() is one frame after the last movement, or None when nothing is
animating.
FlingDriver: the tree's fling pump
The other fling shape: a coast re-dispatched as scroll deltas rather than applied to one scroller's own offset.
The distinction is chaining. A scroller's fling belongs to that scroller and stops at its bounds. A driver's fling belongs to the tree: each tick it hands out a delta for the target widget, and if the target declines the delta because it has reached its end, the caller chains it to an ancestor exactly as it would chain a wheel event. That is what makes a flick that runs out of inner list scroll the outer one.
The driver holds simulations only, bounds live with the widgets that receive the deltas, so its simulations are unbounded coasts.
It integrates on the existing per-frame path rather than running a cadence of
its own: FlingDriver::with_scheduler(tree_scheduler.clone()) takes one
FrameTickSubscription per coasting target and releases it when the coast ends.
FrameTickScheduler is an Rc handle, so the clone shares the tree's table
rather than creating a second one.
The tree builds one at construction and drives it from tick_flings_with_ops,
which rides the pass a host already runs each wake (tick_gestures_with_ops),
a coast is an input deadline like a long press, and giving it a second call site
would mean every host had to learn one. WidgetTree::advance_time ticks it too,
so a headless test moves a fling with the same call that moves an animation.
6. The hand-off rule
A coast, and the pan that launches it, are delivered along the claimant
chain: the frozen pan_candidates list, from the container that took the claim
outward, and nothing else. teksilo-core::widget_tree::pan_arbiter owns the
walk. Three rules govern the boundary, and all three exist to keep an existing
contract rather than to invent one.
Whole events, never remainders
Per event, delivery is all-or-nothing, exactly as the wheel already is:
scroll_response answers Handled if the axis absorbed anything and Ignored
at a hard boundary
(common/scroll.rs). On
Ignored the router re-delivers the same whole event to the next container
outward.
There is no fractional residual and no back-channel. Both are tempting, and both are wrong here:
EventResponseis binary. It cannot carry "I took 30 of your 50 pixels", so a residual would mean a new return type on every scroll handler in the workspace, a delta-path change in every scrollable at once.- What it would buy is invisible. The difference between handing the ancestor the whole 50 and handing it the leftover 20 is one frame of one gesture, at a boundary the user is already crossing.
So the chain hands over events. A container that declines one is offered the next one first again, because the claim never left it.
Nothing is told a lie
The claimant the chain moves past receives no PointerCancel and no
pan-ended. It did not lose the gesture; it declined one event. Cancelling it
would tear down its own gesture state in the middle of a drag it is still
winning, and a terminal phase would make it release a rubber band it is still
holding.
OverscrollBehavior::Contain, declared on the node via
.overscroll_behavior(..), read by the chain, stops the walk even when the
claimant absorbed nothing, so a self-contained panel never lets a boundary pan
escape into the page behind it.
Claimants only
The chain visits the PanClaim holders and never the generic bubble. This is
not an optimisation. The bubble path from a nested list to the window root passes
through nodes that handle on_scroll without being scroll containers at all: a
SpinBox increments its value on wheel, a TabBar remaps wheel to horizontal
tab scrolling. A boundary pan that reached either would change a number or switch
a tab, silently, because the user ran out of list. PanClaim is the declaration
that tells them apart, and the chain reads nothing else.
Wheel and trackpad keep ScrollDelivery::Bubble, the route they have always
had. Only ScrollSource::TouchPan chains along claimants, and a mouse never
forms a pan claim, so mouse wheel chaining is unchanged.
A fling chains identically, because it is dispatched through the same door
(dispatch_scroll, with ScrollPhase::Fling) and walks the same frozen list. A
coast the whole chain declines is stopped: it has nothing left to move.
7. One pinch ingress
WidgetTree::dispatch_os_gesture(gesture, at, ops) is the only way a
GestureEvent::Pinch* reaches a widget. Two producers arrive there:
- the OS trackpad stream (
PinchGesture/RotationGesture), which winit reports without a position,atisNoneand the route falls back to the hover owner's last position, then the hovered widget, then the focused one, then the root; TouchPinchRecognizer(gesture/pinch.rs), which derives the same three phases from two contacts under aPINCH_ZOOM-permitting subtree and supplies the contact midpoint asat.
One ingress is the point. SceneView::on_pinch used to be reachable on macOS
and nowhere else, and a second, touch-only event family would have doubled the
handler surface and guaranteed the same drift again. A test asserts the two
streams are identical, not merely similar.
One ingress also means one payload contract, and it is stated on
GestureEvent::PinchChanged: scale and rotation are per-sample deltas
against the previous sample of the same gesture, and rotation is in
radians. A consumer folds each sample in (zoom *= scale,
rotation += rotation) rather than assigning it. Deltas rather than values
cumulative since the start, because the trackpad arm cannot produce a cumulative
value, winit reports a change per event and hands over no gesture-start baseline
to divide by; the recognizer, which has one, keeps it under
TouchPinchRecognizer::cumulative_scale / cumulative_rotation so the two
readings never share a name. Degrees are converted where the unit is known, in
teksilo_platform::event_translation's rotation_gesture, and never in a
consumer.
Third and later contacts are ignored: rotation through three moving points has no unique rigid transform, so the recognizer takes the two earliest contacts and says so rather than averaging something. A contact leaving mid-pinch ends the gesture, promoting a spare into its slot would teleport the centre and the span.
8. Testing
Unit tests assert hand-derived closed-form reference values, not goldens this implementation produced, so a reader can re-derive them from upstream without running anything. The two anchor cases:
- The exact point.
getSplineDecelerationis zero exactly whenINFLEXION·v == friction·mPhysicalCoeff, i.e. atv = 2223.8658dp/s. There the duration is exactly 1 second and the distance is exactlyfriction·mPhysicalCoeff = 778.3530dp, no tolerance argument needed. v = 4000dp/s.l = ln(1.7986697) = 0.5870473, duration= exp(0.4322239) = 1.54068s, distance= 778.3530 × exp(1.0192697) = 2156.95dp. The identitydistance == friction·mPhysicalCoeff · duration^DECELfalls out of the two formulas and is asserted alongside, so a future edit cannot break one without breaking the other.
Flutter's side is anchored the same way: x(1 s) = 431.964, dx(1 s) = 135,
finalX = 499.381 for the friction coast at 1000 dp/s; and
x(0.1 s) = end + 30.437 for the overdamped spring released 50 dp out at rest.
Property tests live in
crates/teksilo-core/tests/prop_kinetic.rs
and follow property-testing.md, including its safe run
protocol (--no-run, then the binary under ulimit -v) and its rule that
dependent generator inputs must be coupled with prop_flat_map, so an extent of
1e6 can never pair with a step of 1e-6.
Adopting ScrollableBehavior
common::scrollable is the
widget-side half. A surface adopts it in four steps, and ScrollArea
(scroll_area.rs) is the
reference to copy.
1. Own a scroller. One Rc<RefCell<KineticScroller>> field, built in the
widget's constructor rather than in build, so the physics survives a rebuild:
#![allow(unused)] fn main() { scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))), }
2. Publish the viewport from layout. The rubber-band curve is a fraction of
the viewport, and the layout pass is the only place that number exists. The cell
is a RefCell precisely so a &self place_children can write it:
#![allow(unused)] fn main() { self.scroller .borrow_mut() .set_viewport(Vec2::new(viewport_width, viewport_height)); }
3. Install the behaviour in build. Both halves at once, the handler and
the pan claim:
#![allow(unused)] fn main() { let axes = ScrollableAxes { x, y, max_x, max_y, overscroll }; let behavior = ScrollableBehavior::new(axes) .with_scroller(self.scroller.clone()) .axes(PanAxes::BOTH) .overscroll(self.overscroll_behavior) .smooth(self.smooth_scrolling) .line_height(self.line_height) .reduced_motion(ctx.prefers_reduced_motion()) .physics(ctx.theme().input.scroll_physics); ctx.apply_self_handlers(behavior.install(HandlerSet::new())); }
4. Keep your own scroll-adjacent arms in .before(..). It runs first for
every event and answers an Option, which is the difference between owning an
event and merely watching one. Return None to observe and fall through, the
shared handler then runs on that same event, which is how a surface does
bookkeeping ahead of a delta it still wants applied. Return Some(response) to
claim the event: the shared handler does not run at all, and response is the
surface's answer. Some(Handled) is the usual claim (a ScrollIntoView
reveal); Some(Ignored) is for an arm that consumed the event and could not
move, the tables' Shift+wheel remap at its horizontal end, where the whole
original event must chain outward and must not be applied a second time on
the way past.
The four rules that are easy to get wrong
An offset must be Signal::new_animated on a surface with smooth on and
a range on that axis: animate_to on a plain Signal<f32> panics. Both paths
write an axis only when its clamp actually moved, so an axis whose range is
permanently zero is never written and may stay plain, which is what lets a
vertical-only view pin its horizontal axis, and what lets the three text
surfaces keep the plain signals they have always had.
The claim is not optional. install attaches it, but a surface that hand-
rolls the handler and forgets .scroll_container(..) / .pan_claim(..) scrolls
on a wheel and ignores a finger entirely, because pan_candidates is a list of
claimants and it would not be on it.
The rubber band is off by default, and should stay off on anything nested. A band absorbs the movement, so a surface that follows the finger past its own end can never hand the gesture to the container around it. The band belongs to the outermost surface of a scroll chain.
The end of a gesture is not movement. ScrollPhase::Ended carries a zero
delta and is answered Ignored even under Contain, so the claimant walk
carries it to every container outward and each releases its own band. A claimant
that answered Handled there would leave the ones around it holding a band
nobody told them to let go of.
What the surface does not do
It does not run the coast. A release hands its velocity to the tree's
FlingDriver (§5), whose deltas come back as ScrollPhase::Fling samples on the
same chain. The surface applies them with a hard clamp, the driver's
simulation is unbounded, so stopping at the boundary and declining, which is what
chains the coast outward, is the surface's job.
It also does not read a clock, subscribe to frame ticks, or animate. Everything it does happens inside an event.
See also
- Porting a widget to the pointer model , clause 1 on declaring a pan claim, which is what routes a finger's drag here.
- Events & gestures §4.3, the arbitration a pan claim competes in, and the claimant chain a won claim is delivered along.
- Touch & pen, velocity samples, the one clock, and the coalesced history the tracker is fed from.
- Density & targets, where
ScrollPhysicsTokensand the fling velocity gates live on the theme.
Engineering notes retain the implementation history and measurements.