Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ClampingSimulationBouncingSimulation
UpstreamAndroid OverScrollerFlutter BouncingScrollSimulation (iOS UIScrollView)
Selected byOverscrollStyle::ClampOverscrollStyle::RubberBand
In-range motionfriction splineexponential decay
At the boundarystops deadovershoots, spring pulls it back
Feels likeWindows, GTK, AndroidmacOS, 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.

ConstantValueSource
HISTORY_SIZE20 samplesFlutter VelocityTracker._historySize
HORIZON100 msFlutter _horizonMilliseconds
MIN_SAMPLE_SIZE3 samplesFlutter _minSampleSize
STOP_GAP40 msFlutter _assumePointerMoveStoppedMilliseconds
fit degree2Flutter 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.

ConstantValueSource
clamping_deceleration_rateln(0.78)/ln(0.9) ≈ 2.358202Android OverScroller.DECELERATION_RATE
clamping_inflexion0.35Android OverScroller.INFLEXION
clamping_friction0.015Android ViewConfiguration.getScrollFriction()
START_TENSION0.5Android OverScroller.START_TENSION
END_TENSION1.0Android OverScroller.END_TENSION
NB_SAMPLES100Android OverScroller.NB_SAMPLES
gravity9.80665 m/s²Android SensorManager.GRAVITY_EARTH
inches per metre39.37Android mPhysicalCoeff
pixels per inch160Android density 1.0, see below
tuning factor0.84Android 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

ConstantValueSource
bouncing_decay_per_second0.135Flutter BouncingScrollSimulation, UIScrollView.decelerationRate.normal (0.998) to the 1000th power
spring_mass0.5Flutter SpringDescription
spring_stiffness100Flutter SpringDescription
spring_damping_ratio1.1just overdamped, the return never oscillates back past the edge
maxSpringTransferVelocity5000 dp/sFlutter BouncingScrollSimulation.maxSpringTransferVelocity
rubber_band_factor0.52Flutter 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

ConstantValueSource
SETTLE_DISTANCE_TOLERANCE0.1 dpFlutter ScrollPhysics.tolerance 1/(devicePixelRatio × 10) at dpr 1
SETTLE_VELOCITY_TOLERANCE20 dp/sFlutter ScrollPhysics.tolerance 1/(0.050 × devicePixelRatio) at dpr 1
FLING_FRAME_INTERVAL16 667 µs60 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:

ConstantValueSource
min_fling_velocity50 dp/sAndroid ViewConfiguration.MINIMUM_FLING_VELOCITY
max_fling_velocity8000 dp/sAndroid 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:

  1. f64 least-squares arithmetic. A degree-2 Vandermonde matrix built from millisecond-scale abscissae is ill-conditioned enough that single precision visibly moves the answer. Flutter uses double for the same reason; Teksilo's public surface stays f32.
  2. 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 at mass == 1, which is Flutter's default and why the divergence has survived there. Teksilo's spring has mass == 0.5, so the textbook form is used. Not reachable with the shipped tokens (ratio 1.1 is overdamped).
  3. The signed spring hand-off velocity. Flutter caps the hand-off with a bare math.min on 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::start is 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:

ScrollPhaseFlings?Why
Endedonly if the host does not report its own momentumthe fingers lifted and nobody else is animating
Flingyesthe host reported a release velocity and handed the coast to us
Momentumneverthe host is already animating, this is the bug
MomentumEndedneversame
Discrete, Began, Changed, Cancellednevernot 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:

  • EventResponse is 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, at is None and 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 a PINCH_ZOOM-permitting subtree and supplies the contact midpoint as at.

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. getSplineDeceleration is zero exactly when INFLEXION·v == friction·mPhysicalCoeff, i.e. at v = 2223.8658 dp/s. There the duration is exactly 1 second and the distance is exactly friction·mPhysicalCoeff = 778.3530 dp, no tolerance argument needed.
  • v = 4000 dp/s. l = ln(1.7986697) = 0.5870473, duration = exp(0.4322239) = 1.54068 s, distance = 778.3530 × exp(1.0192697) = 2156.95 dp. The identity distance == friction·mPhysicalCoeff · duration^DECEL falls 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

Engineering notes retain the implementation history and measurements.