289 lines
12 KiB
Python
289 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
|
|
|
|
Same reason `fling_spline_reference.py` exists: the numbers checked into
|
|
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
|
|
estimator -- total motion over the sample span, an average -- passed every test
|
|
it had, because every one of those tests asserted the average's own definition
|
|
back at it. An average cannot tell an accelerating flick from a steady drag, and
|
|
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
|
|
actually works but is slower than Compose's immediately after releasing the
|
|
flick".
|
|
|
|
Transcribed by hand from, and only from, the `-sources.jar` of
|
|
**androidx.compose.ui:ui-android:1.12.0** and
|
|
**androidx.compose.foundation:foundation-android:1.12.0**
|
|
(dl.google.com/dl/android/maven2), read 2026-09-07:
|
|
|
|
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
|
|
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
|
|
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
|
|
`HistorySize = 20`, `HorizonMilliseconds = 100`,
|
|
`AssumePointerMoveStoppedMilliseconds = 40`.
|
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
|
|
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
|
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
|
|
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
|
|
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
|
|
default, which is `false`.
|
|
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
|
|
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
|
|
feeds the tracker and where the maximum-velocity clamp is applied.
|
|
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
|
|
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
|
|
* `androidx/compose/foundation/gestures/Scrollable.kt` --
|
|
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
|
|
|
|
**Which strategy a touch fling actually uses, since this was the surprise.**
|
|
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
|
|
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
|
|
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
|
|
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
|
|
least-squares fit over **absolute positions**, whose velocity is the fitted
|
|
polynomial's derivative at the newest sample. Impulse is reached only through
|
|
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
|
|
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
|
|
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
|
|
printed points, because ruling it out by reading is cheaper than ruling it out
|
|
again next time somebody remembers "Compose uses impulse".
|
|
|
|
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
|
|
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
|
|
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
|
|
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
|
|
and all the UP branch does is reset the tracker when more than 40ms have passed
|
|
since the last MOVE (b/238654963). So a finger that stops before lifting reads
|
|
as a stop, not as a decelerating tail. Positions are the raw event positions,
|
|
so the touch slop is inside the motion the tracker sees even though the list
|
|
never scrolled by it.
|
|
|
|
Two of Compose's samples iris does *not* reproduce, both noted rather than
|
|
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
|
|
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
|
|
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
|
|
one, since it is a real measured position and dropping it would be copying a
|
|
quirk of where Compose happens to split its state machine.
|
|
|
|
**The clamps.** Maximum: `sendDragStopped` passes
|
|
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
|
|
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
|
|
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
|
|
exists in Compose's `ViewConfiguration` interface but its only use in either
|
|
artifact is `NestedScrollInteropConnection`, for View interop.
|
|
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
|
|
and says why in its own comment: "we need it since spline curve gives us
|
|
NaNs". 1 px/s, not 50 dp/s.
|
|
|
|
Run it with no arguments; it prints the sample sets and the velocities the
|
|
Rust tests assert on.
|
|
"""
|
|
|
|
import math
|
|
|
|
HISTORY_SIZE = 20
|
|
HORIZON_MILLISECONDS = 100.0
|
|
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
|
MIN_SAMPLE_SIZE_LSQ2 = 3
|
|
|
|
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
|
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
|
# positions fed to the tracker -- pixels per second here.
|
|
FLING_MINIMUM_PX_S = 1.0
|
|
|
|
|
|
def poly_fit_least_squares(x, y, sample_count, degree):
|
|
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
|
|
if degree < 1:
|
|
raise ValueError("The degree must be at positive integer")
|
|
if sample_count == 0:
|
|
raise ValueError("At least one point must be provided")
|
|
|
|
truncated_degree = sample_count - 1 if degree >= sample_count else degree
|
|
m = sample_count
|
|
n = truncated_degree + 1
|
|
|
|
a = [[0.0] * m for _ in range(n)]
|
|
for h in range(m):
|
|
a[0][h] = 1.0
|
|
for i in range(1, n):
|
|
a[i][h] = a[i - 1][h] * x[h]
|
|
|
|
q = [[0.0] * m for _ in range(n)]
|
|
r = [[0.0] * n for _ in range(n)]
|
|
for j in range(n):
|
|
w = q[j]
|
|
w[:] = a[j][:m]
|
|
for i in range(j):
|
|
z = q[i]
|
|
dot = sum(w[h] * z[h] for h in range(m))
|
|
for h in range(m):
|
|
w[h] -= dot * z[h]
|
|
norm = math.sqrt(sum(v * v for v in w))
|
|
inverse_norm = 1.0 / max(norm, 1e-6)
|
|
for h in range(m):
|
|
w[h] *= inverse_norm
|
|
for i in range(n):
|
|
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
|
|
|
|
coefficients = [0.0] * n
|
|
for i in range(n - 1, -1, -1):
|
|
c = sum(q[i][h] * y[h] for h in range(m))
|
|
for j in range(n - 1, i, -1):
|
|
c -= r[i][j] * coefficients[j]
|
|
coefficients[i] = c / r[i][i]
|
|
return coefficients
|
|
|
|
|
|
def kinetic_energy_to_velocity(kinetic_energy):
|
|
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
|
|
return sign * math.sqrt(2 * abs(kinetic_energy))
|
|
|
|
|
|
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
|
|
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
|
|
work = 0.0
|
|
start = sample_count - 1
|
|
next_time = time[start]
|
|
for i in range(start, 0, -1):
|
|
current_time = next_time
|
|
next_time = time[i - 1]
|
|
if current_time == next_time:
|
|
continue
|
|
if is_data_differential:
|
|
delta = -data_points[i - 1]
|
|
else:
|
|
delta = data_points[i] - data_points[i - 1]
|
|
v_curr = delta / (current_time - next_time)
|
|
v_prev = kinetic_energy_to_velocity(work)
|
|
work += (v_curr - v_prev) * abs(v_curr)
|
|
if i == start:
|
|
work = work * 0.5
|
|
return kinetic_energy_to_velocity(work)
|
|
|
|
|
|
def calculate_velocity(samples):
|
|
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
|
|
|
|
`samples` is `(time_millis, position)` oldest first, at most the last
|
|
`HISTORY_SIZE` of which the circular buffer would still be holding.
|
|
Returns units per second.
|
|
"""
|
|
held = samples[-HISTORY_SIZE:]
|
|
if not held:
|
|
return 0.0
|
|
|
|
data_points = []
|
|
time = []
|
|
newest_time, _ = held[-1]
|
|
previous_time = newest_time
|
|
for sample_time, sample_position in reversed(held):
|
|
age = float(newest_time - sample_time)
|
|
delta = abs(float(sample_time - previous_time))
|
|
# Lsq2 walks back sample to sample; only the non-differential
|
|
# Impulse branch compares every sample against the newest one.
|
|
previous_time = sample_time
|
|
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
|
|
break
|
|
data_points.append(sample_position)
|
|
time.append(-age)
|
|
if len(data_points) == HISTORY_SIZE:
|
|
break
|
|
|
|
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
|
|
return 0.0
|
|
try:
|
|
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
|
|
except ValueError:
|
|
return 0.0
|
|
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
|
|
# which is the newest sample's timestamp. units/ms -> units/s.
|
|
return coefficients[1] * 1000.0
|
|
|
|
|
|
def clamped(velocity, maximum):
|
|
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
|
|
if velocity == 0.0 or math.isnan(velocity):
|
|
return 0.0
|
|
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
|
|
|
|
|
|
def average(samples):
|
|
"""The estimator being replaced: total motion over the span."""
|
|
if len(samples) < 2:
|
|
return 0.0
|
|
span = (samples[-1][0] - samples[0][0]) / 1000.0
|
|
if span <= 0.0:
|
|
return 0.0
|
|
return (samples[-1][1] - samples[0][1]) / span
|
|
|
|
|
|
|
|
# 1. `app/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
|
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
|
# sample (see the module doc), which is why the finger sitting still for its
|
|
# last 4ms does not drag the estimate down. y only; the flick is vertical.
|
|
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
|
|
|
|
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
|
|
# average must agree here -- this is the case that cannot tell the two
|
|
# estimators apart, which is why it is not the only one.
|
|
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
|
|
|
|
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
|
|
# This is the case the average gets wrong, and the negative control for
|
|
# the port -- reverting to the average must fail this test and only this
|
|
# kind of test.
|
|
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
|
|
|
|
# 4. The two edges of the sample walk, checked here so the Rust asserts
|
|
# Compose's answer rather than iris's own reading of the rule.
|
|
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
|
# drag: the burst must not leak into the estimate.
|
|
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
|
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
|
|
|
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
|
# ... and one move frame, which Compose cannot fit either.
|
|
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
|
|
|
PHONE_DENSITY = 2.55
|
|
|
|
|
|
def report(name, samples):
|
|
v = calculate_velocity(samples)
|
|
print(f"{name}:")
|
|
print(f" samples (t_ms, position): {samples}")
|
|
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
|
|
print(f" average (the old estimator): {average(samples):.4f} px/s")
|
|
print(f" impulse (non-touch, for ref): ", end="")
|
|
held = list(reversed(samples[-HISTORY_SIZE:]))
|
|
newest = held[0][0]
|
|
print(
|
|
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
|
|
)
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
|
|
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
|
|
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
|
|
report("flick-120hz.touch", FLICK_120HZ)
|
|
report("steady drag (5px/10ms)", STEADY_DRAG)
|
|
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
|
|
|
|
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
|
|
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
|
|
report("press and two move frames", TWO_MOVE_FRAMES)
|
|
report("press and one move frame", ONE_MOVE_FRAME)
|
|
|
|
print("Clamps:")
|
|
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
|
|
print(
|
|
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
|
|
)
|
|
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
|
|
print()
|
|
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
|
|
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
|