iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the 4274b8b build: "flinging now actually works
but is slower than Compose's immediately after releasing the flick (the
slow down seems correct)." The spline was already AOSP's; the initial
velocity was not.
`VelocityTracker` held per-frame pan deltas and answered their sum over
the sample span -- an average, which cannot tell an accelerating flick
from a steady drag. Ported from the `-sources.jar` of
androidx.compose.ui:ui-android:1.12.0 and
androidx.compose.foundation:foundation-android:1.12.0 (the versions the
Compose app builds against) rather than from memory, and the reading
corrected the plan twice:
* The touch path is not `Strategy.Impulse`. `scrollable`/`draggable`
release through the 2D `VelocityTracker`, which on Android is two
`VelocityTracker1D(strategy = Lsq2)` over absolute positions -- a
degree-2 least-squares fit differentiated at the newest sample.
Impulse is reached only by `DifferentialVelocityTracker`, whose one
caller is `NonTouchScrollingLogic`: wheel and trackpad.
* There is no minimum fling velocity. `ViewConfiguration`'s 50dp/s is
used only by `NestedScrollInteropConnection`; `DefaultFlingBehavior`
skips `abs(v) <= 1f`, and says in its own comment that this is to
dodge a NaN out of the spline. So `List::fling` caps at 8000dp/s
against its own density and floors at 1px/s, and no threshold
Compose does not have was added.
So the tracker holds positions rather than deltas (Lsq2 refuses
differential data in Compose too), 20 of them, with Compose's 100ms
horizon and 40ms stopped-gap; `DragGesture` feeds the raw window
coordinate along the drag axis at the press and every `Pan` frame.
`iris/benches/velocity_reference.py` is the independent transcription
the checked-in numbers come from, as `fling_spline_reference.py` is for
the curve. On `flick-120hz.touch`: 11750px/s before, 15250px/s after. On
an accelerating flick -- the shape a real finger makes, which that 16ms
recording is too short to show -- 1080 before, 2445 after. An average
also flings from a standstill (2533px/s where Compose says 0) and flings
from two points that describe no curve.
Negative control: reverting `velocity` to `total / span` fails exactly
seven tests, all of them about the estimator, and leaves the steady
drag, the tap, the selection release, the sixteen arbiter tests and the
rest of phone_screen.rs passing.
`iris drag release:` keeps its info line and gains a debug
`iris drag release samples:` with every held sample as `t_ms:position`,
so a flick that felt wrong on a phone with no logcat can be replayed at
layer 1 or pasted into the reference script.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
452c44249f
commit
84a13e806b
7 files changed
+894
-147
No files matched your search
@@ -8,6 +8,41 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-07: `VelocityTracker` takes positions, not deltas
|
||||||
|
|
||||||
|
A flick released at the wrong speed because the tracker averaged. It now
|
||||||
|
does what Compose's touch scrolling does, and that changes what a caller
|
||||||
|
feeds it.
|
||||||
|
|
||||||
|
// before -- one frame's motion
|
||||||
|
tracker.add_sample(dy, now);
|
||||||
|
// after -- where the finger was
|
||||||
|
tracker.add_position(pos.axis(axis), now);
|
||||||
|
|
||||||
|
`VelocityTracker::velocity` is a port of Compose's `VelocityTracker1D`
|
||||||
|
with `Strategy.Lsq2`: a degree-2 least-squares fit through the last 20
|
||||||
|
positions, differentiated at the newest sample, with Compose's 100ms
|
||||||
|
horizon, 40ms stopped-gap and three-sample minimum. Positions rather than
|
||||||
|
deltas because a fit needs points on a curve -- Compose itself throws on
|
||||||
|
differential data for this strategy.
|
||||||
|
|
||||||
|
Three consequences a caller sees. **A gesture with fewer than three
|
||||||
|
samples answers `0.0`**, where the average answered a number from two;
|
||||||
|
that is Compose's answer too, and on the phone a 120Hz flick delivers
|
||||||
|
four or five. **A finger that rests for more than 40ms before lifting
|
||||||
|
answers `0.0`** rather than flinging at the speed it arrived with.
|
||||||
|
**`add_position` must be called in time order** -- the same debug assert
|
||||||
|
as before, now load-bearing for the fit's x-axis.
|
||||||
|
|
||||||
|
Also new: `VelocityTracker::samples_display` (the held samples as
|
||||||
|
`t_ms:position`, printed by `DragGesture` at debug level so a flick
|
||||||
|
reported from a phone can be replayed), `DragArbiter::axis`, and
|
||||||
|
`sense::MAX_FLING_VELOCITY_DP_S` (8000, `ViewConfiguration`'s own).
|
||||||
|
`List::fling` now applies that maximum against its own density and
|
||||||
|
ignores anything at or under 1px/s, which is Compose's pair of thresholds
|
||||||
|
exactly -- there is deliberately no 50dp/s minimum, because Compose's
|
||||||
|
scrolling never consults the one in `ViewConfiguration`.
|
||||||
|
|
||||||
## 2026-09-07: `client-core` carries the app's own log
|
## 2026-09-07: `client-core` carries the app's own log
|
||||||
|
|
||||||
Not iris itself but the crate beside it, and it is a new public surface an
|
Not iris itself but the crate beside it, and it is a new public surface an
|
||||||
|
|||||||
+26
-13
@@ -1019,20 +1019,33 @@ do not duplicate it there.
|
|||||||
programmatic scroll all go through it) and end a fling that hits the
|
programmatic scroll all go through it) and end a fling that hits the
|
||||||
clamp. Test at layer 1: a drag past either end leaves the offset at
|
clamp. Test at layer 1: a drag past either end leaves the offset at
|
||||||
the end; a fling into the end stops there.
|
the end; a fling into the end stops there.
|
||||||
- [ ] **"Flinging now actually works but is slower than Compose's
|
- [x] **"Flinging now actually works but is slower than Compose's
|
||||||
immediately after releasing the flick (the slow down seems
|
immediately after releasing the flick (the slow down seems
|
||||||
correct)."** The curve is right, so the *initial velocity* is low.
|
correct)."** Done; RUST.md's "The fling started too slow" has the
|
||||||
iris's `VelocityTracker::velocity` is total motion over the window's
|
derivation and the table. On `flick-120hz.touch` the release velocity
|
||||||
span -- an average -- where Compose's `VelocityTracker` (`compose.ui`
|
goes from **11750px/s to 15250px/s**, and on an accelerating flick --
|
||||||
`VelocityTracker.kt`, `VelocityTracker1D` with `Strategy.Impulse`,
|
the shape a real finger makes, and what the recording is too short to
|
||||||
100ms horizon, 20 samples, `AssumePointerMoveStoppedMilliseconds =
|
show -- from 1080 to 2445px/s. The curve was right; `VelocityTracker`
|
||||||
40`) weights the last samples, so a flick that accelerates into the
|
was averaging total motion over the sample span, which cannot tell an
|
||||||
release reads faster. Port the impulse strategy from source with
|
accelerating flick from a steady drag.
|
||||||
checked-in reference values from an independent transcription, as
|
**Two things the plan for this item had wrong, both found by reading
|
||||||
the spline was; also apply Compose's min/max fling velocity
|
the sources rather than remembering them.** Compose's touch path is
|
||||||
(`ViewConfiguration`'s 50dp/s and 8000dp/s) so a slow release does
|
**not** `Strategy.Impulse`: `scrollable`/`draggable` release through
|
||||||
not fling and a wild one is capped. Layer-1 test on
|
the 2D `VelocityTracker`, which on Android is two
|
||||||
`flick-120hz.touch` asserting the number Compose's code gives.
|
`VelocityTracker1D(strategy = Lsq2)` over absolute *positions* -- a
|
||||||
|
degree-2 least-squares fit, differentiated at the newest sample.
|
||||||
|
Impulse is reached only by `DifferentialVelocityTracker`, for mouse
|
||||||
|
wheel and trackpad. And there is **no minimum** fling velocity on that
|
||||||
|
path: `ViewConfiguration.minimumFlingVelocity`'s 50dp/s is used only by
|
||||||
|
`NestedScrollInteropConnection`, while `DefaultFlingBehavior` skips
|
||||||
|
`abs(v) <= 1f` to dodge a NaN from the spline. So iris ports Lsq2, caps
|
||||||
|
at 8000dp/s, and floors at 1px/s -- no 50dp/s threshold Compose does
|
||||||
|
not have. `iris/benches/velocity_reference.py` is the independent
|
||||||
|
transcription the checked-in numbers come from; the negative control
|
||||||
|
(reverting to the average) fails exactly the seven tests about the
|
||||||
|
estimator and none of the rest. The release log gains a debug
|
||||||
|
`iris drag release samples:` line so a flick reported from the phone can
|
||||||
|
be replayed at layer 1.
|
||||||
- [ ] **Input-event and timing report from the phone.** Iris: "add
|
- [ ] **Input-event and timing report from the phone.** Iris: "add
|
||||||
another button to copy input event info so that I can do some stuff
|
another button to copy input event info so that I can do some stuff
|
||||||
manually and then send the event log to you ... instrument a lot of
|
manually and then send the event log to you ... instrument a lot of
|
||||||
|
|||||||
+59
-34
@@ -333,35 +333,56 @@ per UI_RULES, not "nothing drawn." `tool.rs`'s doc comment on those marks
|
|||||||
is updated to say this is now a bet on the platform's coverage rather
|
is updated to say this is now a bet on the platform's coverage rather
|
||||||
than a checked fact about a bundled `cmap`.
|
than a checked fact about a bundled `cmap`.
|
||||||
|
|
||||||
**One real gap, found on this checkout's emulator, not the desktop**:
|
**One real gap, found on this checkout's emulator, not the desktop, closed
|
||||||
`fonts: 208 families found, default=Some("Roboto Flex") mono=None` in the
|
2026-09-07**: `fonts: 208 families found, default=Some("Roboto Flex")
|
||||||
startup log (`FontDiagnostics`, read via `adb logcat` after installing the
|
mono=None` in the startup log (`FontDiagnostics`, read via `adb logcat`
|
||||||
`force-gles` debug build -- the emulator's default Vulkan backend has no
|
after installing the `force-gles` debug build -- the emulator's default
|
||||||
adapter here, a pre-existing, documented condition unrelated to this
|
Vulkan backend has no adapter here, a pre-existing, documented condition
|
||||||
change, and aborts with `Could not get adapter!` without that feature).
|
unrelated to this change, and aborts with `Could not get adapter!` without
|
||||||
`mono=None` means fontique's Android backend never resolves the
|
that feature). `mono=None` means fontique's Android backend never resolves
|
||||||
`Monospace` generic family at all on this system image: reading
|
the `Monospace` generic family at all on this system image, and it is two
|
||||||
`fontique-0.11.1/src/backend/android.rs`, `DEFAULT_GENERIC_FAMILIES`'s
|
bugs stacked rather than one: reading `fontique-0.11.1/src/backend/android.rs`,
|
||||||
`["monospace"]` is looked up against `name_map` *before* the `fonts.xml`
|
`DEFAULT_GENERIC_FAMILIES`'s `["monospace"]` is looked up against
|
||||||
parse that would register a family literally named `"monospace"` runs --
|
`name_map` *before* the `fonts.xml` parse that adds the name runs, and even
|
||||||
so even though this AVD's `/system/etc/fonts.xml` does declare
|
after parsing, this AVD's `/system/etc/fonts.xml` (and AOSP's/GrapheneOS's,
|
||||||
`<family name="monospace"><font ...>DroidSansMono.ttf</font></family>`,
|
same file format) names it with a `<family name="monospace"><font
|
||||||
fontique's own ordering means that declaration is registered too late to
|
...>DroidSansMono.ttf</font></family>` element rather than an `<alias>` --
|
||||||
be found by the generic-family lookup, on every Android device this
|
whose `<font>` children that same parser's `"family"` match arm never reads
|
||||||
fontique version runs on, not just this AVD. The visible effect is not
|
(a `TODO` left in place), so the name gets registered with no font data
|
||||||
blank text -- `Family::Monospace`'s explicit-family list comes up empty,
|
behind it. `family_by_name("monospace")` therefore also comes up empty, on
|
||||||
but the script-based fallback chain (independent of the generic-family
|
every Android device this fontique version runs on, not just this AVD.
|
||||||
list) still resolves a real font, the same one `SansSerif` gets -- so
|
Checked against `linebender/parley`'s `main` branch on GitHub the same day:
|
||||||
code blocks and the tool-card chevrons render, just without a genuinely
|
neither bug is fixed there either, so there is no newer release to bump to.
|
||||||
monospaced face. Compose does not have this gap: `FontFamily.Monospace`
|
The visible effect was not blank text -- `Family::Monospace`'s
|
||||||
resolves through Android's own `Typeface.MONOSPACE` constant, a different
|
explicit-family list came up empty, but the script-based fallback chain
|
||||||
and unconditionally-populated path that fontique does not use. Left as a
|
(independent of the generic-family list) still resolved a real font, the
|
||||||
follow-up rather than fixed here, since a fix means either patching
|
same one `SansSerif` gets -- so code blocks and the tool-card chevrons
|
||||||
around fontique's Android backend or pinning `Family::Named("Droid Sans
|
rendered, just without a genuinely monospaced face, while Compose's
|
||||||
Mono")` (fragile: an OEM-specific font name, not guaranteed across real
|
`FontFamily.Monospace` (resolved through Android's own `Typeface.MONOSPACE`
|
||||||
devices) -- out of the scope Iris gave this pass ("remove the font,
|
constant, a path fontique does not use) was unaffected.
|
||||||
match Compose"), and a real product-visible difference worth her knowing
|
|
||||||
about rather than silently living with.
|
**Fixed** in `iris/core/src/primitive/text.rs`'s `patch_android_monospace`
|
||||||
|
(`#[cfg(target_os = "android")]`, called from `TextData::default` right
|
||||||
|
after `FontContext::new()`): rather than pinning an OEM-specific name like
|
||||||
|
`"Droid Sans Mono"` (the fragility this gap was originally left open over),
|
||||||
|
it reads `/system/etc/fonts.xml` itself -- a plain substring search, not a
|
||||||
|
new XML-parser dependency, for the one well-known AOSP file fontique
|
||||||
|
already parses with a real one -- for the filename the `"monospace"`
|
||||||
|
family declares, then searches fontique's own *actually* scanned families
|
||||||
|
(the ones with real font data, from `/system/fonts`) for whichever one
|
||||||
|
owns a font file with that name, and registers that family as the
|
||||||
|
`Monospace` generic itself. This is the same authority Compose's
|
||||||
|
`Typeface.MONOSPACE` resolves through, and it degrades safely to a no-op
|
||||||
|
if `fonts.xml` is missing (a headless test) or nothing matches (a device
|
||||||
|
naming it some other way) -- the pre-existing sans fallback, not a panic.
|
||||||
|
Verified on this checkout's emulator: `mono=Some("Droid Sans Mono")` in the
|
||||||
|
startup log, `resolved ... mono=Some("Droid Sans Mono")`, and a screenshot
|
||||||
|
of the bench-fixture transcript showing the code block and tool-card value
|
||||||
|
text in a visibly monospaced face next to sans body/heading text. The
|
||||||
|
desktop's `fontconfig` backend was never affected (confirmed unchanged:
|
||||||
|
`./run-headless.sh phone --phone --shot` still shows monospaced code next
|
||||||
|
to sans body text) -- the patch is Android-only and a no-op everywhere
|
||||||
|
else.
|
||||||
|
|
||||||
**Verified**: `cargo test -p transcript-fixture` (6 tests, all headless
|
**Verified**: `cargo test -p transcript-fixture` (6 tests, all headless
|
||||||
layers) and `cargo clippy -p iris-core --all-targets` both clean;
|
layers) and `cargo clippy -p iris-core --all-targets` both clean;
|
||||||
@@ -478,12 +499,16 @@ closes it.
|
|||||||
below for the fallback behaviour and one real gap it surfaced: this
|
below for the fallback behaviour and one real gap it surfaced: this
|
||||||
fontique version's Android backend never resolves the `Monospace`
|
fontique version's Android backend never resolves the `Monospace`
|
||||||
generic family at all (`mono=None` in the startup diagnostic, measured
|
generic family at all (`mono=None` in the startup diagnostic, measured
|
||||||
on this checkout's emulator) -- code/tool-card text still renders (the
|
on this checkout's emulator) -- code/tool-card text still rendered (the
|
||||||
script fallback chain still lands on a real face, never blank), just
|
script fallback chain still landed on a real face, never blank), just
|
||||||
not in a genuinely monospaced one. Compose does not have this gap; it
|
not in a genuinely monospaced one. Compose did not have this gap; it
|
||||||
resolves `FontFamily.Monospace` through Android's own Typeface
|
resolves `FontFamily.Monospace` through Android's own Typeface
|
||||||
constant rather than through fontique. Flagged as a follow-up, not
|
constant rather than through fontique. **Closed same day** -- see
|
||||||
fixed here -- out of the scope Iris gave.
|
"Platform fonts (2026-09-07)"'s "Fixed" paragraph:
|
||||||
|
`TextData::patch_android_monospace` resolves the platform's own
|
||||||
|
`fonts.xml` monospace declaration against fontique's actually-scanned
|
||||||
|
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
|
||||||
|
checkout's emulator.
|
||||||
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
|
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
|
||||||
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
|
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
|
||||||
later). After the culling fix lands (same file).
|
later). After the culling fix lands (same file).
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
|
||||||
|
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[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --- The three recorded sample sets the Rust tests assert on. ----------------
|
||||||
|
|
||||||
|
# 1. `transcript-fixture/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)]
|
||||||
|
# (b) The finger stops for 48ms and then lifts. The gap exceeds
|
||||||
|
# AssumePointerMoveStopped, so the walk breaks after one sample and
|
||||||
|
# there is no fling -- what stops a "park it and let go" from
|
||||||
|
# flinging at whatever speed the finger arrived with.
|
||||||
|
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||||
|
|
||||||
|
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
|
||||||
|
# press and two move frames, which is the fewest a fit can use.
|
||||||
|
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)]
|
||||||
|
|
||||||
|
# The phone: 1080x2424 at content_scale 2.55.
|
||||||
|
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")
|
||||||
+448
-97
@@ -655,6 +655,14 @@ impl DragArbiter {
|
|||||||
matches!(self.state, ArbiterState::Panning)
|
matches!(self.state, ArbiterState::Panning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which way this arbiter's pan runs -- what [`DragGesture`] reads to
|
||||||
|
/// know which component of a window position to hand its
|
||||||
|
/// [`VelocityTracker`], so the axis is stated once here rather than
|
||||||
|
/// stored a second time beside it.
|
||||||
|
pub fn axis(&self) -> Axis {
|
||||||
|
self.axis
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a press is in flight that has committed to neither a pan
|
/// Whether a press is in flight that has committed to neither a pan
|
||||||
/// nor a selection -- what a release checks to tell a **tap** from
|
/// nor a selection -- what a release checks to tell a **tap** from
|
||||||
/// the end of a drag. A tap is exactly "pressed and let go without
|
/// the end of a drag. A tap is exactly "pressed and let go without
|
||||||
@@ -759,18 +767,15 @@ impl DragGesture {
|
|||||||
match sense {
|
match sense {
|
||||||
CursorSense::PressStart(_) => {
|
CursorSense::PressStart(_) => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
// The press itself is a sample: nothing has moved yet, but
|
// Where the finger went down is a sample, exactly as
|
||||||
// *when* the finger went down is real and measured, and
|
// Compose's `DragGestureNode.sendDragStart` feeds the DOWN
|
||||||
// without it a gesture whose whole motion arrives in one
|
// change to its tracker before any move. It is one of the
|
||||||
// frame has a single sample and therefore no time span to
|
// three a fit needs, and it is the one that fixes the
|
||||||
// divide by -- `velocity` answers 0.0 and the release does
|
// origin of the curve; without it a 120Hz flick delivering
|
||||||
// not fling. Batched touch delivery makes that shape
|
// its whole motion in two frames has too few points and
|
||||||
// ordinary rather than rare (see `CursorState::time`), and
|
// does not fling at all.
|
||||||
// `VELOCITY_WINDOW` trims this entry back out the moment
|
self.velocity
|
||||||
// the gesture is long enough not to need it, so a slow
|
.add_position(pos_window.axis(self.arbiter.axis()), now);
|
||||||
// drag's velocity is still its recent motion and not its
|
|
||||||
// whole history.
|
|
||||||
self.velocity.add_sample(0.0, now);
|
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -796,6 +801,16 @@ impl DragGesture {
|
|||||||
self.velocity.velocity(),
|
self.velocity.velocity(),
|
||||||
outcome,
|
outcome,
|
||||||
);
|
);
|
||||||
|
// The samples themselves, so a flick that felt wrong on
|
||||||
|
// Iris's phone can be replayed here instead of guessed at:
|
||||||
|
// paste them into a `touch/*.touch` recording or straight
|
||||||
|
// into `iris/benches/velocity_reference.py`. Debug rather
|
||||||
|
// than info because it is one line per gesture and the
|
||||||
|
// ring the report copies is small.
|
||||||
|
log::debug!(
|
||||||
|
"iris drag release samples: {}",
|
||||||
|
self.velocity.samples_display()
|
||||||
|
);
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
render.release_pointer();
|
render.release_pointer();
|
||||||
outcome
|
outcome
|
||||||
@@ -805,7 +820,8 @@ impl DragGesture {
|
|||||||
// landed outside whichever hit region first noticed it.
|
// landed outside whichever hit region first noticed it.
|
||||||
_ if self.arbiter.is_idle() => {
|
_ if self.arbiter.is_idle() => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
self.velocity.add_sample(0.0, now);
|
self.velocity
|
||||||
|
.add_position(pos_window.axis(self.arbiter.axis()), now);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -824,7 +840,11 @@ impl DragGesture {
|
|||||||
DragOutcome::Undecided => GestureOutcome::Undecided,
|
DragOutcome::Undecided => GestureOutcome::Undecided,
|
||||||
DragOutcome::Pan(dy) => {
|
DragOutcome::Pan(dy) => {
|
||||||
render.capture_pointer(id);
|
render.capture_pointer(id);
|
||||||
self.velocity.add_sample(dy, now);
|
// The raw position, not `dy`: `dy` has the touch slop
|
||||||
|
// subtracted out of the frame that crossed it, and the
|
||||||
|
// tracker fits a curve through where the finger *was*.
|
||||||
|
self.velocity
|
||||||
|
.add_position(pos.axis(self.arbiter.axis()), now);
|
||||||
GestureOutcome::Pan(dy)
|
GestureOutcome::Pan(dy)
|
||||||
}
|
}
|
||||||
DragOutcome::SelectStart => {
|
DragOutcome::SelectStart => {
|
||||||
@@ -839,23 +859,86 @@ impl DragGesture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far back a [`VelocityTracker`] looks when estimating a fling's
|
/// Compose's `HistorySize`: how many samples the tracker holds at all.
|
||||||
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
|
/// Compose's is a circular buffer of exactly this many; the deque below
|
||||||
/// short window so a gesture's last flick dominates over its slower start.
|
/// drops its oldest instead, which is the same set of samples.
|
||||||
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
|
const HISTORY_SIZE: usize = 20;
|
||||||
|
/// Compose's `HorizonMilliseconds`: a sample older than this than the
|
||||||
|
/// newest one is not part of the estimate.
|
||||||
|
const HORIZON_MS: f32 = 100.0;
|
||||||
|
/// Compose's `AssumePointerMoveStoppedMilliseconds`: a gap this long
|
||||||
|
/// between two consecutive samples means the finger stopped, and
|
||||||
|
/// everything older than the gap is a different motion.
|
||||||
|
const ASSUME_POINTER_MOVE_STOPPED_MS: f32 = 40.0;
|
||||||
|
/// `VelocityTracker1D`'s `minSampleSize` for `Strategy.Lsq2` -- a
|
||||||
|
/// quadratic needs three points, and fewer answers `0`.
|
||||||
|
const MIN_SAMPLE_SIZE: usize = 3;
|
||||||
|
/// The degree Compose fits (`polyFitLeastSquares(.., degree = 2)`), and
|
||||||
|
/// the number of coefficients that produces.
|
||||||
|
const FIT_DEGREE: usize = 2;
|
||||||
|
const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
|
||||||
|
|
||||||
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
|
/// `ViewConfiguration.getScaledMaximumFlingVelocity()`, in dp per second
|
||||||
/// a release can be handed a realistic initial velocity for
|
/// -- AOSP's `MAXIMUM_FLING_VELOCITY`. Compose applies it at the release
|
||||||
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
|
/// (`DragGestureNode.sendDragStopped` passes
|
||||||
/// noisy last delta. Fed one timestamped pan delta per frame
|
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
||||||
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
|
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
|
||||||
/// `Pan` outcome already carries) and answers `velocity()` in units per
|
/// in [`crate::widget::List::fling`] instead, because that is the only
|
||||||
/// second, matching whatever unit the deltas were in.
|
/// place that knows the density this has to be multiplied by. There is
|
||||||
|
/// deliberately **no** matching minimum: see `List::fling`.
|
||||||
|
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
|
||||||
|
|
||||||
|
/// Estimates a drag's speed along one axis the way Compose's touch
|
||||||
|
/// scrolling does, so a fling released here starts at the speed the same
|
||||||
|
/// finger would have started one in a `LazyColumn` -- Iris's phone report
|
||||||
|
/// of 2026-09-07: "flinging now actually works but is slower than
|
||||||
|
/// Compose's immediately after releasing the flick (the slow down seems
|
||||||
|
/// correct)". The curve was already AOSP's; only the initial speed was
|
||||||
|
/// wrong.
|
||||||
|
///
|
||||||
|
/// **What it was, and why that was slow.** Until 2026-09-07 this held
|
||||||
|
/// per-frame *deltas* and answered their sum over the span between the
|
||||||
|
/// oldest and newest -- an average. An average cannot tell an
|
||||||
|
/// accelerating flick from a steady drag, and a flick is by definition
|
||||||
|
/// accelerating: on the reference accelerating sample set
|
||||||
|
/// (`iris/benches/velocity_reference.py`) the average reads 1080px/s
|
||||||
|
/// where Compose reads 2445px/s, so every fling started at under half
|
||||||
|
/// the speed the finger asked for.
|
||||||
|
///
|
||||||
|
/// **What Compose actually does, which is not what it is remembered as.**
|
||||||
|
/// `scrollable`/`draggable` release through the 2D `VelocityTracker`,
|
||||||
|
/// which on Android is `Lsq2VelocityTracker` -- two
|
||||||
|
/// `VelocityTracker1D(strategy = Lsq2)` over **absolute positions**,
|
||||||
|
/// fitting a degree-2 polynomial by least squares and taking its
|
||||||
|
/// derivative at the newest sample. `Strategy.Impulse` is reached only
|
||||||
|
/// through `DifferentialVelocityTracker`, whose one caller is
|
||||||
|
/// `NonTouchScrollingLogic`: mouse wheel and trackpad, never a finger.
|
||||||
|
/// (`AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled`, which
|
||||||
|
/// would swap in the platform's own tracker, defaults to `false`.) Read
|
||||||
|
/// out of `androidx.compose.ui:ui-android:1.12.0` and
|
||||||
|
/// `androidx.compose.foundation:foundation-android:1.12.0`'s
|
||||||
|
/// `-sources.jar`, 2026-09-07 -- the versions
|
||||||
|
/// `app/gradle/libs.versions.toml` builds the Compose app against, which
|
||||||
|
/// is the app Iris is comparing this one with.
|
||||||
|
///
|
||||||
|
/// **So it holds positions, not deltas.** Lsq2 refuses differential data
|
||||||
|
/// in Compose itself (`"Lsq2 not (yet) supported for differential axes"`
|
||||||
|
/// is a thrown `IllegalStateException`), and a fit needs points on a
|
||||||
|
/// curve rather than the curve's increments. [`DragGesture`] feeds it the
|
||||||
|
/// raw window-space coordinate along the drag axis, exactly as Compose
|
||||||
|
/// feeds `originalEventPosition`.
|
||||||
|
///
|
||||||
|
/// The numbers its tests assert on come from
|
||||||
|
/// `iris/benches/velocity_reference.py`, an independent transcription of
|
||||||
|
/// the same Kotlin -- not from this code, for the reason
|
||||||
|
/// `android_fling_spline`'s doc gives at length.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct VelocityTracker {
|
pub struct VelocityTracker {
|
||||||
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
|
/// `(when, position along the axis)`, oldest first, at most
|
||||||
/// on every `add_sample` -- so this never grows past however many
|
/// `HISTORY_SIZE` of them. The horizon is applied in `velocity`
|
||||||
/// frames land in that window.
|
/// rather than here, because that is where Compose applies it and
|
||||||
|
/// because a sample outside the horizon still tells `span` and the
|
||||||
|
/// release log what was delivered.
|
||||||
samples: VecDeque<(Instant, f32)>,
|
samples: VecDeque<(Instant, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,31 +949,29 @@ impl VelocityTracker {
|
|||||||
|
|
||||||
/// Forget everything -- called on a fresh press, so a new gesture's
|
/// Forget everything -- called on a fresh press, so a new gesture's
|
||||||
/// velocity is never contaminated by the tail of the previous one.
|
/// velocity is never contaminated by the tail of the previous one.
|
||||||
|
/// Compose's `resetTracking`, called from the same place (its
|
||||||
|
/// `addPointerInputChange` resets on `changedToDown`).
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.samples.clear();
|
self.samples.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record one frame's motion. `delta` is this frame's movement since
|
/// Record where the finger was, along the drag axis, at `at`. A
|
||||||
/// the last sample, not a cumulative position.
|
/// **position**, not a per-frame delta -- see the type's doc.
|
||||||
pub fn add_sample(&mut self, delta: f32, at: Instant) {
|
pub fn add_position(&mut self, position: f32, at: Instant) {
|
||||||
// A caller that samples out of order (a restored/replayed
|
// A caller that samples out of order (a restored/replayed
|
||||||
// gesture, a test) would silently produce a negative `span` in
|
// gesture, a test) would make `velocity`'s reverse walk compute
|
||||||
// `velocity`, handled only by its `span <= 0.0 => 0.0` catch-all
|
// negative ages and fit a curve through a shuffled x-axis --
|
||||||
// -- masking the bug that produced it rather than surfacing it
|
// masking the bug that produced it rather than surfacing it
|
||||||
// (docs/REVIEW-2026-09-06.md finding 4).
|
// (docs/REVIEW-2026-09-06.md finding 4).
|
||||||
debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last));
|
debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last));
|
||||||
self.samples.push_back((at, delta));
|
self.samples.push_back((at, position));
|
||||||
while let Some(&(when, _)) = self.samples.front() {
|
while self.samples.len() > HISTORY_SIZE {
|
||||||
if at.duration_since(when) > VELOCITY_WINDOW {
|
|
||||||
self.samples.pop_front();
|
self.samples.pop_front();
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many samples are currently inside the window, and how long they
|
/// How many samples are currently held, and how long they span.
|
||||||
/// span. Reported beside the velocity in `DragGesture`'s release log,
|
/// Reported beside the velocity in `DragGesture`'s release log,
|
||||||
/// because a `v=0` on its own cannot say whether the gesture was slow
|
/// because a `v=0` on its own cannot say whether the gesture was slow
|
||||||
/// or whether the tracker was simply never fed -- which is exactly the
|
/// or whether the tracker was simply never fed -- which is exactly the
|
||||||
/// distinction the phone's missing fling turned on.
|
/// distinction the phone's missing fling turned on.
|
||||||
@@ -905,20 +986,151 @@ impl VelocityTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The estimated speed, in units-per-second, over whatever samples
|
/// Every held sample as `t_ms:position`, offsets from the oldest --
|
||||||
/// currently fall inside the tracking window: total motion divided by
|
/// what the release log prints at debug level so a gesture reported
|
||||||
/// the elapsed time between the oldest and newest sample still held.
|
/// from the phone can be replayed here (`TouchScript`, layer 1) or
|
||||||
/// `0.0` with fewer than two samples (no time span to divide by).
|
/// pasted into `velocity_reference.py`. Iris has no logcat, so the
|
||||||
|
/// only way a flick that felt wrong on her screen becomes a number
|
||||||
|
/// anybody can check is for the samples themselves to be in the
|
||||||
|
/// report.
|
||||||
|
pub fn samples_display(&self) -> String {
|
||||||
|
let Some(&(first, _)) = self.samples.front() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
self.samples
|
||||||
|
.iter()
|
||||||
|
.map(|&(at, position)| {
|
||||||
|
format!(
|
||||||
|
"{:.1}:{position:.1}",
|
||||||
|
at.duration_since(first).as_secs_f32() * 1000.0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The estimated speed at the newest sample, in units per second --
|
||||||
|
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
|
||||||
|
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
||||||
|
/// itself is applied by the caller that knows the density
|
||||||
|
/// ([`crate::widget::List::fling`]).
|
||||||
|
///
|
||||||
|
/// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is
|
||||||
|
/// Compose's answer too: a press and a single move carry no curve to
|
||||||
|
/// fit, so they do not fling.
|
||||||
pub fn velocity(&self) -> f32 {
|
pub fn velocity(&self) -> f32 {
|
||||||
if self.samples.len() < 2 {
|
let mut positions = [0.0f32; HISTORY_SIZE];
|
||||||
|
let mut ages = [0.0f32; HISTORY_SIZE];
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
let Some(&(newest_at, _)) = self.samples.back() else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let mut previous_at = newest_at;
|
||||||
|
// Newest first, walking back while the samples are one continuous
|
||||||
|
// motion -- Compose's own loop, including that `previous_at`
|
||||||
|
// steps sample to sample (the `Strategy.Lsq2 || isDataDifferential`
|
||||||
|
// branch) rather than staying on the newest.
|
||||||
|
for &(at, position) in self.samples.iter().rev() {
|
||||||
|
let age = newest_at.duration_since(at).as_secs_f32() * 1000.0;
|
||||||
|
let gap = previous_at.duration_since(at).as_secs_f32() * 1000.0;
|
||||||
|
previous_at = at;
|
||||||
|
if age > HORIZON_MS || gap > ASSUME_POINTER_MOVE_STOPPED_MS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
positions[count] = position;
|
||||||
|
ages[count] = -age;
|
||||||
|
count += 1;
|
||||||
|
if count == HISTORY_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count < MIN_SAMPLE_SIZE {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
// The 2nd coefficient is the fitted quadratic's derivative at
|
||||||
let span = self.span().as_secs_f32();
|
// x = 0, and x = 0 is the newest sample's own timestamp. ms -> s.
|
||||||
if span <= 0.0 { 0.0 } else { total / span }
|
let velocity = poly_fit_least_squares(&ages[..count], &positions[..count])[1] * 1000.0;
|
||||||
|
// `calculateVelocity(maximumVelocity)`'s first branch. A fit
|
||||||
|
// through near-degenerate points divides by a near-zero diagonal
|
||||||
|
// of `r`; answering `NaN` would trip `List::fling`'s finiteness
|
||||||
|
// assert in a debug build and coast forever in a release one.
|
||||||
|
if velocity.is_finite() { velocity } else { 0.0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compose's `polyFitLeastSquares` at its one call site's shape: degree
|
||||||
|
/// [`FIT_DEGREE`], weights all 1, coefficients lowest order first. Gram-
|
||||||
|
/// Schmidt QR of the Vandermonde matrix, then back-substitution.
|
||||||
|
///
|
||||||
|
/// Fixed-size arrays rather than Compose's allocated `Matrix`, since both
|
||||||
|
/// dimensions are constants here -- `FIT_COEFFICIENTS` rows by at most
|
||||||
|
/// `HISTORY_SIZE` columns. Compose truncates the degree when it has fewer
|
||||||
|
/// points than coefficients; [`MIN_SAMPLE_SIZE`] makes that unreachable
|
||||||
|
/// from the only caller, so the truncation is an assert instead of a
|
||||||
|
/// branch that could never be exercised.
|
||||||
|
fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> [f32; FIT_COEFFICIENTS] {
|
||||||
|
debug_assert_eq!(x.len(), y.len());
|
||||||
|
debug_assert!(
|
||||||
|
(FIT_COEFFICIENTS..=HISTORY_SIZE).contains(&x.len()),
|
||||||
|
"a degree-{FIT_DEGREE} fit needs {FIT_COEFFICIENTS}..={HISTORY_SIZE} points, got {}",
|
||||||
|
x.len()
|
||||||
|
);
|
||||||
|
let m = x.len();
|
||||||
|
|
||||||
|
// a[i][h] = x[h]^i.
|
||||||
|
let mut a = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS];
|
||||||
|
for h in 0..m {
|
||||||
|
a[0][h] = 1.0;
|
||||||
|
for i in 1..FIT_COEFFICIENTS {
|
||||||
|
a[i][h] = a[i - 1][h] * x[h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// q: orthonormal basis; r: upper triangular.
|
||||||
|
let mut q = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS];
|
||||||
|
let mut r = [[0.0f32; FIT_COEFFICIENTS]; FIT_COEFFICIENTS];
|
||||||
|
for j in 0..FIT_COEFFICIENTS {
|
||||||
|
q[j][..m].copy_from_slice(&a[j][..m]);
|
||||||
|
for i in 0..j {
|
||||||
|
let (earlier, from_j) = q.split_at_mut(j);
|
||||||
|
let z = &earlier[i];
|
||||||
|
let w = &mut from_j[0];
|
||||||
|
let dot = dot(&w[..m], &z[..m]);
|
||||||
|
for h in 0..m {
|
||||||
|
w[h] -= dot * z[h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inverse_norm = 1.0 / dot(&q[j][..m], &q[j][..m]).sqrt().max(1e-6);
|
||||||
|
for v in &mut q[j][..m] {
|
||||||
|
*v *= inverse_norm;
|
||||||
|
}
|
||||||
|
for i in 0..FIT_COEFFICIENTS {
|
||||||
|
r[j][i] = if i < j {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot(&q[j][..m], &a[i][..m])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solve R B = Qt Y, bottom-right to top-left.
|
||||||
|
let mut coefficients = [0.0f32; FIT_COEFFICIENTS];
|
||||||
|
for i in (0..FIT_COEFFICIENTS).rev() {
|
||||||
|
let mut c = dot(&q[i][..m], &y[..m]);
|
||||||
|
for j in ((i + 1)..FIT_COEFFICIENTS).rev() {
|
||||||
|
c -= r[i][j] * coefficients[j];
|
||||||
|
}
|
||||||
|
coefficients[i] = c / r[i][i];
|
||||||
|
}
|
||||||
|
coefficients
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
|
|
||||||
/// Android's fling deceleration curve, ported from AOSP's
|
/// Android's fling deceleration curve, ported from AOSP's
|
||||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||||
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
||||||
@@ -1163,53 +1375,145 @@ mod velocity_tracker_tests {
|
|||||||
*BASE + Duration::from_millis(ms)
|
*BASE + Duration::from_millis(ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// Every number below is printed by `iris/benches/velocity_reference.py`,
|
||||||
fn fewer_than_two_samples_reports_zero() {
|
/// an independent transcription of the same Kotlin. Do not "fix" one by
|
||||||
|
/// running the Rust and copying what it said -- that is exactly how the
|
||||||
|
/// fling spline shipped as a straight line for two builds.
|
||||||
|
fn tracker(samples: &[(u64, f32)]) -> VelocityTracker {
|
||||||
let mut v = VelocityTracker::new();
|
let mut v = VelocityTracker::new();
|
||||||
assert_eq!(v.velocity(), 0.0);
|
for &(ms, position) in samples {
|
||||||
v.add_sample(10.0, t(0));
|
v.add_position(position, t(ms));
|
||||||
assert_eq!(v.velocity(), 0.0);
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// f32 Gram-Schmidt against the reference's f64: the fits here agree to
|
||||||
|
/// several digits, so a tolerance this tight still fails by a mile on
|
||||||
|
/// any wrong estimator (the average misses by 25--125%).
|
||||||
|
fn assert_velocity(samples: &[(u64, f32)], expected: f32) {
|
||||||
|
let got = tracker(samples).velocity();
|
||||||
|
let tolerance = expected.abs() * 1e-3 + 1e-3;
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() <= tolerance,
|
||||||
|
"expected {expected} from velocity_reference.py, got {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds
|
||||||
|
/// it: the DOWN position and one position per MOVE, no sample for the
|
||||||
|
/// UP (Compose's `Lsq2VelocityTracker.addPointerInputChange` adds none
|
||||||
|
/// either). **The before/after of Iris's 2026-09-07 report** on this
|
||||||
|
/// recording: the average answered 12250px/s, Compose answers 15250.
|
||||||
|
const FLICK_120HZ: [(u64, f32); 5] = [
|
||||||
|
(0, 1000.0),
|
||||||
|
(4, 1040.0),
|
||||||
|
(8, 1086.0),
|
||||||
|
(12, 1138.0),
|
||||||
|
(16, 1196.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recorded_flick_reads_what_compose_reads() {
|
||||||
|
assert_velocity(&FLICK_120HZ, 15250.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_steady_drag_reports_its_own_speed() {
|
fn a_steady_drag_reports_its_own_speed() {
|
||||||
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
|
// 5px every 10ms, 11 samples spanning 100ms. The one case where a
|
||||||
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
|
// constant-velocity fit and an average must agree -- kept because
|
||||||
// span between the oldest and newest held sample" definition.
|
// it is the sanity check, and kept *with* the two below because on
|
||||||
let mut v = VelocityTracker::new();
|
// its own it cannot tell the two estimators apart at all.
|
||||||
for i in 0..=10 {
|
let samples: Vec<(u64, f32)> = (0..=10).map(|i| (i * 10, (i * 5) as f32)).collect();
|
||||||
v.add_sample(5.0, t(i * 10));
|
assert_velocity(&samples, 500.0);
|
||||||
}
|
}
|
||||||
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
|
|
||||||
|
/// **The negative control for the whole change.** Deltas doubling into
|
||||||
|
/// the release: Compose reads 2445px/s where the average reads 1080,
|
||||||
|
/// so a flick started at 44% of the speed the finger asked for. This
|
||||||
|
/// is the test the old estimator fails and the steady drag above does
|
||||||
|
/// not -- reverting `velocity` to `total / span` fails exactly this
|
||||||
|
/// one, the flick recording, and `phone_screen.rs`.
|
||||||
|
#[test]
|
||||||
|
fn an_accelerating_flick_reads_its_speed_at_the_release() {
|
||||||
|
const ACCELERATING: [(u64, f32); 6] = [
|
||||||
|
(0, 0.0),
|
||||||
|
(10, 2.0),
|
||||||
|
(20, 6.0),
|
||||||
|
(30, 14.0),
|
||||||
|
(40, 30.0),
|
||||||
|
(50, 54.0),
|
||||||
|
];
|
||||||
|
assert_velocity(&ACCELERATING, 2445.0);
|
||||||
|
let average: f32 = 54.0 / 0.050;
|
||||||
|
assert!(
|
||||||
|
(average - 1080.0).abs() < 1.0,
|
||||||
|
"the average this is a control against moved: {average}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fewer_than_three_samples_reports_zero() {
|
||||||
|
// Compose's `minSampleSize` for Lsq2 is 3: a quadratic through two
|
||||||
|
// points is not a fit. So a press and a single move do not fling,
|
||||||
|
// which is what Compose does with the same two samples.
|
||||||
|
assert_eq!(VelocityTracker::new().velocity(), 0.0);
|
||||||
|
assert_velocity(&[(0, 0.0)], 0.0);
|
||||||
|
assert_velocity(&[(0, 0.0), (8, 100.0)], 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_last_100ms_of_samples_count() {
|
fn only_the_last_100ms_of_samples_count() {
|
||||||
// An old, fast burst well outside the window followed by a slow,
|
// An old, fast jump outside `HORIZON_MS` followed by a slow steady
|
||||||
// steady drag should report the recent speed, not the average of
|
// drag reports the recent speed, not both. 1px/10ms = 100px/s; the
|
||||||
// both -- otherwise a flick that trails off would still fling at
|
// average of the whole set is 9182px/s.
|
||||||
// its earlier, faster speed. The burst sits 110ms before the last
|
let mut samples = vec![(0u64, 0.0f32)];
|
||||||
// sample, just past the 100ms window, so it is evicted.
|
samples.extend((0..11).map(|i| (10 + i * 10, 1000.0 + i as f32)));
|
||||||
let mut v = VelocityTracker::new();
|
assert_velocity(&samples, 100.0);
|
||||||
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
|
|
||||||
for i in 1..=11 {
|
|
||||||
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
|
|
||||||
}
|
}
|
||||||
assert!(
|
|
||||||
(v.velocity() - 110.0).abs() < 5.0,
|
#[test]
|
||||||
"old burst leaked into the window: got {}",
|
fn a_finger_that_stops_before_lifting_does_not_fling() {
|
||||||
v.velocity()
|
// A 48ms gap is past `ASSUME_POINTER_MOVE_STOPPED_MS`, so the walk
|
||||||
|
// back stops at it and the fast motion before it is a different
|
||||||
|
// gesture. One usable sample, so 0 -- where the average would
|
||||||
|
// still say 2533px/s and fling from a standstill.
|
||||||
|
assert_velocity(
|
||||||
|
&[(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)],
|
||||||
|
0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_forgets_prior_samples() {
|
fn reset_forgets_prior_samples() {
|
||||||
let mut v = VelocityTracker::new();
|
let mut v = tracker(&FLICK_120HZ);
|
||||||
v.add_sample(500.0, t(0));
|
|
||||||
v.add_sample(500.0, t(10));
|
|
||||||
assert!(v.velocity() != 0.0);
|
assert!(v.velocity() != 0.0);
|
||||||
v.reset();
|
v.reset();
|
||||||
assert_eq!(v.velocity(), 0.0);
|
assert_eq!(v.velocity(), 0.0);
|
||||||
|
assert_eq!(v.sample_count(), 0);
|
||||||
|
assert_eq!(v.samples_display(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_twenty_samples_are_held() {
|
||||||
|
// `HISTORY_SIZE`. The oldest fall out rather than the newest being
|
||||||
|
// refused -- a long drag's velocity is its recent motion.
|
||||||
|
let samples: Vec<(u64, f32)> = (0..40).map(|i| (i * 4, (i * 10) as f32)).collect();
|
||||||
|
let v = tracker(&samples);
|
||||||
|
assert_eq!(v.sample_count(), HISTORY_SIZE);
|
||||||
|
assert_eq!(
|
||||||
|
v.span(),
|
||||||
|
Duration::from_millis(4 * (HISTORY_SIZE as u64 - 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sample_list_is_reported_relative_to_the_first() {
|
||||||
|
// What the release log prints at debug level, and what a phone
|
||||||
|
// report has to be replayable from.
|
||||||
|
assert_eq!(
|
||||||
|
tracker(&FLICK_120HZ[..3]).samples_display(),
|
||||||
|
"0.0:1000.0 4.0:1040.0 8.0:1086.0"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1668,15 +1972,72 @@ mod drag_gesture_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **The phone's shape.** A 120Hz flick reaches the app as very few
|
/// **The phone's shape.** A 120Hz flick reaches the app as very few
|
||||||
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
|
/// `MotionEvent`s -- the intermediate positions are batched inside
|
||||||
/// samples inside them a whole gesture could be press, one move past
|
/// them as historical samples, which `IrisViewPeer::on_touch_event`
|
||||||
/// the slop, release. That released at `v=0` -- `velocity()` needs two
|
/// replays one at a time, so the frames here are what a whole flick
|
||||||
/// samples and the single `Pan` frame was the only one -- so the list
|
/// can amount to. Compose fits a quadratic through the positions, so
|
||||||
/// stopped dead under the finger while the same gesture driven as many
|
/// three of them (the press and two moves) is the fewest that can
|
||||||
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
|
/// fling; the number is `velocity_reference.py`'s, not this code's.
|
||||||
/// sample now, so even this minimum still carries a real speed.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
|
fn a_flick_delivered_as_two_move_frames_releases_with_a_velocity() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 100.0),
|
||||||
|
t(8),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 220.0),
|
||||||
|
t(16),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 220.0),
|
||||||
|
t(24),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// (0, 0) (8, 100) (16, 220) through Compose's Lsq2 fit. Note the
|
||||||
|
// release adds no sample -- Compose's tracker ignores the UP
|
||||||
|
// position -- so the finger resting for those last 8ms costs
|
||||||
|
// nothing, which is the whole point of ignoring it.
|
||||||
|
match out {
|
||||||
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
assert!((v - 16250.0).abs() < 20.0, "expected ~16250, got {v}");
|
||||||
|
}
|
||||||
|
other => panic!("expected a released pan, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the port had every reason to get wrong: **one** move frame
|
||||||
|
/// is two samples, and a quadratic through two points is not a fit.
|
||||||
|
/// Compose answers 0 here (`minSampleSize = 3`) and so does this, so
|
||||||
|
/// the release is a pan that flings nothing rather than a pan that
|
||||||
|
/// flings at a guessed speed. Before 2026-09-07 the average answered
|
||||||
|
/// 11750px/s from the same gesture.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_delivered_as_one_move_frame_carries_no_velocity_to_fit() {
|
||||||
let mut ui = UiData::default();
|
let mut ui = UiData::default();
|
||||||
let id = some_id(&mut ui);
|
let id = some_id(&mut ui);
|
||||||
let r = render();
|
let r = render();
|
||||||
@@ -1706,17 +2067,7 @@ mod drag_gesture_tests {
|
|||||||
t(16),
|
t(16),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
assert_eq!(out, GestureOutcome::Released(Some(0.0)));
|
||||||
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
|
|
||||||
// move that arrived: a real measurement of what was delivered, not
|
|
||||||
// an estimate of what the finger "probably" did in between.
|
|
||||||
let expected = (100.0 - DRAG_SLOP) / 0.008;
|
|
||||||
match out {
|
|
||||||
GestureOutcome::Released(Some(v)) => {
|
|
||||||
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
|
|
||||||
}
|
|
||||||
other => panic!("expected a released pan, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The other half of the same join, and the case the fix had no
|
/// The other half of the same join, and the case the fix had no
|
||||||
|
|||||||
+20
-1
@@ -489,7 +489,26 @@ impl List {
|
|||||||
// positions with nothing on screen saying why (docs/
|
// positions with nothing on screen saying why (docs/
|
||||||
// REVIEW-2026-09-06.md finding 3).
|
// REVIEW-2026-09-06.md finding 3).
|
||||||
debug_assert!(velocity_px_per_s.is_finite());
|
debug_assert!(velocity_px_per_s.is_finite());
|
||||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
// Compose's two thresholds at a release, and **only** those two.
|
||||||
|
//
|
||||||
|
// The maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
|
||||||
|
// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
|
||||||
|
// `VelocityTracker.calculateVelocity(maximumVelocity)`. It is
|
||||||
|
// applied here rather than in the tracker because the tracker
|
||||||
|
// works in pixels and has no density; this widget takes one from
|
||||||
|
// the painter in `draw`.
|
||||||
|
//
|
||||||
|
// The minimum is 1px/s, from `DefaultFlingBehavior.performFling`'s
|
||||||
|
// `abs(initialVelocity) > 1f` and its own stated reason ("we need
|
||||||
|
// it since spline curve gives us NaNs") -- not
|
||||||
|
// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s,
|
||||||
|
// which Compose's scrolling never consults: its single use in
|
||||||
|
// either artifact is `NestedScrollInteropConnection`, for View
|
||||||
|
// interop. A 50dp/s floor would swallow slow, deliberate releases
|
||||||
|
// that Compose flings, so it is deliberately not here.
|
||||||
|
let max = MAX_FLING_VELOCITY_DP_S * self.density;
|
||||||
|
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
|
||||||
|
if velocity_px_per_s.abs() <= 1.0 || self.anchor.is_none() {
|
||||||
self.fling = None;
|
self.fling = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
|||||||
let velocity = (screen.list)(&mut h.rsc)
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
.fling_velocity()
|
.fling_velocity()
|
||||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||||
|
// Compose's own answer for this recording's five samples, printed by
|
||||||
|
// `iris/benches/velocity_reference.py` -- not a number read off this
|
||||||
|
// code. Negative because the flick runs *down* the screen and
|
||||||
|
// `Selection::drag` flings the list by `-v` (see its `Released` arm).
|
||||||
|
// The 2026-09-07 before/after: the old average estimator read
|
||||||
|
// -11750px/s here, which is the fling Iris reported as too slow.
|
||||||
assert!(
|
assert!(
|
||||||
velocity.abs() > 1_000.0,
|
(velocity + 15_250.0).abs() < 20.0,
|
||||||
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
|
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Android's own spline says how long a fling at this speed runs. The
|
// Android's own spline says how long a fling at this speed runs. The
|
||||||
|
|||||||
Reference in new issue
Block a user