From bbed1a2551d625dfc1eef66a4293b31f5d37c473 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 12 Mar 2026 02:55:56 -0700 Subject: [PATCH] scroll: use iOS-style weighted velocity averaging for fling (#37659) * scroll: use iOS-style weighted velocity averaging for fling Weight older velocity samples more heavily on finger release to produce more consistent fling velocities. The last touch samples before lift are noisy (finger decelerating, rotating, jittering), so we trust the earlier steadier samples more: 60% oldest, 35% middle, 5% newest. Reverse-engineered from iOS UIScrollView by the Flutter team. Co-Authored-By: Claude Opus 4.6 * Update system/ui/lib/application.py * Apply suggestions from code review --------- Co-authored-by: Claude Opus 4.6 --- system/ui/lib/application.py | 4 ++++ system/ui/lib/scroll_panel2.py | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 4fbfbc0de..5d40abdc1 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -169,6 +169,10 @@ class MouseState: self._rk.keep_time() def _handle_mouse_event(self): + # TODO: read touch events from evdev directly to get real kernel timestamps. + # Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll + # velocity oscillate (alternating high/low). Real timestamps would also let us + # detect swipe-stop-lift via event gaps instead of the fragile decel heuristic. for slot in range(MAX_TOUCH_SLOTS): mouse_pos = rl.get_touch_position(slot) x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index e2a548ba2..18fd8a9a6 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -20,6 +20,21 @@ MAX_SPEED = 10000.0 # px/s DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1" +# Weights older (steadier) velocity samples more heavily on release. +# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity. +# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team: +# https://github.com/flutter/flutter/pull/60501 +# 3 samples ≈ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes. +def weighted_velocity(buffer: deque) -> float: + if len(buffer) >= 3: + return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05 + elif len(buffer) == 2: + return buffer[-2] * 0.7 + buffer[-1] * 0.3 + elif len(buffer) == 1: + return buffer[-1] + return 0.0 + + # from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration class ScrollState(Enum): STEADY = 0 @@ -151,7 +166,13 @@ class GuiScrollPanel2: # Touch rejection: when releasing finger after swiping and stopping, panel # reports a few erroneous touch events with high velocity, try to ignore. - # If velocity decelerates very quickly, assume user doesn't intend to auto scroll + # If velocity decelerates very quickly, assume user doesn't intend to auto scroll. + # Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer) + # 2) dirty finger lift where finger rotates/slides producing spurious velocity spike. + # TODO: this heuristic false-positives on fast swipes because 140Hz touch polling + # jitter causes velocity to oscillate (not real deceleration). Better approaches: + # - Use evdev kernel timestamps to eliminate velocity oscillation at the source + # - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift high_decel = False if len(self._velocity_buffer) > 2: # We limit max to first half since final few velocities can surpass first few @@ -166,6 +187,8 @@ class GuiScrollPanel2: print('deceleration too high, going to STEADY') high_decel = True + self._velocity = weighted_velocity(self._velocity_buffer) + # If final velocity is below some threshold, switch to steady state too low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin