Skip to content

feat(framework): kinetic scroller with iOS-feel physics#163

Draft
doodlewind wants to merge 1 commit into
feat/gesture-layerfrom
feat/kinetic-scroller
Draft

feat(framework): kinetic scroller with iOS-feel physics#163
doodlewind wants to merge 1 commit into
feat/gesture-layerfrom
feat/kinetic-scroller

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

Part 2/6 of the touch-input series (stacked on #161).

What

framework/src/kinetics.tscreateScroller(): a single-axis, fully deterministic scroll physics state machine, offset published as a Solid signal (translateY: -offset() = one paint-only setProp per moving frame, no relayout). States:

state integration notes
tracking per drag delta rubber-band past edges: (1 − 1/((x·0.55/d)+1))·d, capped (default 48 px), invertible for mid-bounce catches
fling per tick v *= 0.9672 (≡ iOS 0.998/ms); projectFling for snap functions
spring per tick K=170/C=26 (engine Spring preset), carries incoming velocity — a fling that crosses an edge keeps momentum into the bounce
chase per frame byte-for-byte apps/im: pos += d·0.3, snap < 0.6 px (d-pad / stick-to-bottom)
tween per frame cubic ease-out scrollTo, snap-points at release

Settle checks run inside the tick loop, so the stop tick is hz-invariant: the 30 Hz trajectory is exactly the 60 Hz one subsampled (asserted). Settled offsets round to 1/64 px for stable framebuffer hashes. No Math.pow/transcendentals anywhere.

Also bindDpadScroll() (im d-pad/analog semantics over any Scroller), rebase() (prepend-without-jump), isAtEnd() (target-judged, the im at-bottom rule).

Why JS-side, not engine springs

Windowing, near-edge fetch, and stick-to-bottom all need the live offset every frame, and there is no layout/anim readback op — the integrator must live guest-side. Cost is identical (one setProp per moving frame); contracts/spec/spec.ts stays untouched.

Tests

21 cases in tests/kinetics.test.ts: literal trajectory determinism, 30≡60 Hz subsampling incl. rest position, rubber curve values (-24.9745 at 50 px past edge), overscroll cap/hard-clamp, mid-bounce catch continuity, exact chase arithmetic (30, 51, …), tween frame-count and landings, snap projection, rebase, settle callbacks, dpad binding with analog + gating.

🤖 Generated with Claude Code

framework/src/kinetics.ts (@pocketjs/framework/kinetics): one axis, one
deterministic state machine — tracking (finger-follow with the classic
rubber-band curve, slope 0.55, capped overscroll), fling (per-tick decay
0.9672 ≡ UIScrollView 0.998/ms), spring (edge bounce with the engine's
K=170/C=26 preset, CARRYING the incoming velocity — the one place
spring-with-initial-velocity is needed, kept JS-side), chase (byte-for-byte
the apps/im pump: 0.3 of remaining distance, snap under 0.6 px), and tween
(cubic ease-out scrollTo, optional snap-points at release).

Fling and spring integrate per core tick with settle checks INSIDE the tick
loop, so the stop tick is hz-invariant and a 30 Hz trajectory is exactly the
60 Hz one subsampled (asserted in tests). Every formula is IEEE + − * / with
literal constants — no transcendentals in the sim path. Settled offsets
round to 1/64 px so resting framebuffers hash identically.

bindDpadScroll() wraps the im d-pad/analog semantics (6 px per held frame,
10 px nub) over any Scroller, gated by an `active` accessor. rebase() shifts
the offset and every in-flight anchor for prepend-without-jump; isAtEnd()
judges intent (the chase/tween target) per the im at-bottom rule.

21 unit tests: literal trajectories, subsampling theorem, rubber curve
values, mid-bounce catch, exact chase/snap/tween arithmetic, dpad binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant