fix(tvos): stop a single Siri Remote flick moving focus two steps
Touch travel banked during the swipe repeat cooldown was released as a second focus step by the first post-cooldown move frame, even when the finger had stopped or was lifting. Re-anchor the swipe delta on every frame inside the cooldown so a discrete flick emits exactly one step while a sustained drag keeps repeating. close #1756
This commit is contained in:
@@ -172,12 +172,19 @@ class AppleTvRemoteTouchService {
|
|||||||
|
|
||||||
final deltaX = _anchorX - x;
|
final deltaX = _anchorX - x;
|
||||||
final deltaY = _anchorY - y;
|
final deltaY = _anchorY - y;
|
||||||
final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY);
|
|
||||||
if (axis == null) return;
|
|
||||||
|
|
||||||
final now = _now();
|
final now = _now();
|
||||||
final lastSwipeAt = _lastSwipeAt;
|
final lastSwipeAt = _lastSwipeAt;
|
||||||
if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) {
|
if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) {
|
||||||
|
// Travel during the repeat cooldown never counts toward the next step:
|
||||||
|
// re-anchor on every frame so a fast flick's deceleration tail is
|
||||||
|
// discarded instead of banked. Without this, the first post-cooldown
|
||||||
|
// move frame — even a stationary or lift-drift one — released the
|
||||||
|
// banked delta as a second focus step for a single intentional swipe.
|
||||||
|
// A deliberate continuous drag still repeats because it covers a fresh
|
||||||
|
// swipe threshold after each cooldown expires.
|
||||||
|
_anchorX = x;
|
||||||
|
_anchorY = y;
|
||||||
final age = now.difference(lastSwipeAt).inMilliseconds;
|
final age = now.difference(lastSwipeAt).inMilliseconds;
|
||||||
_log(
|
_log(
|
||||||
'suppress swipe reason=repeat-cooldown age=${age}ms dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}',
|
'suppress swipe reason=repeat-cooldown age=${age}ms dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}',
|
||||||
@@ -185,6 +192,9 @@ class AppleTvRemoteTouchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY);
|
||||||
|
if (axis == null) return;
|
||||||
|
|
||||||
final logicalKey = axis == _SwipeAxis.horizontal
|
final logicalKey = axis == _SwipeAxis.horizontal
|
||||||
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
|
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
|
||||||
: (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
|
: (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('AppleTvRemoteTouchService', () {
|
group('AppleTvRemoteTouchService', () {
|
||||||
test('emits repeated horizontal swipes only after the repeat interval', () async {
|
test('a single fast flick emits exactly one swipe', () async {
|
||||||
final harness = _Harness();
|
final harness = _Harness();
|
||||||
|
|
||||||
await harness.send('started', x: 500, y: 500);
|
await harness.send('started', x: 500, y: 500);
|
||||||
@@ -15,8 +15,44 @@ void main() {
|
|||||||
|
|
||||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||||
|
|
||||||
|
// The flick's tail travel landed inside the repeat cooldown and must be
|
||||||
|
// discarded: a near-stationary frame after the cooldown expires must not
|
||||||
|
// release it as a second focus step.
|
||||||
harness.advance(const Duration(milliseconds: 141));
|
harness.advance(const Duration(milliseconds: 141));
|
||||||
await harness.send('move', x: 260, y: 490);
|
await harness.send('move', x: 259, y: 490);
|
||||||
|
|
||||||
|
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub-threshold cooldown travel plus lift drift does not fire a second swipe', () async {
|
||||||
|
final harness = _Harness();
|
||||||
|
|
||||||
|
await harness.send('started', x: 500, y: 500);
|
||||||
|
await harness.send('move', x: 390, y: 500);
|
||||||
|
// 80pt tail inside the cooldown: below threshold, but banked it would
|
||||||
|
// combine with the 70pt lift drift below to cross the 100pt threshold.
|
||||||
|
await harness.send('move', x: 310, y: 500);
|
||||||
|
|
||||||
|
harness.advance(const Duration(milliseconds: 141));
|
||||||
|
await harness.send('move', x: 240, y: 500);
|
||||||
|
|
||||||
|
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sustained drag keeps repeating after each repeat interval', () async {
|
||||||
|
final harness = _Harness();
|
||||||
|
|
||||||
|
await harness.send('started', x: 900, y: 500);
|
||||||
|
await harness.send('move', x: 780, y: 500);
|
||||||
|
|
||||||
|
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||||
|
|
||||||
|
harness.advance(const Duration(milliseconds: 70));
|
||||||
|
await harness.send('move', x: 700, y: 500);
|
||||||
|
|
||||||
|
// A full fresh threshold is covered after the cooldown expires.
|
||||||
|
harness.advance(const Duration(milliseconds: 71));
|
||||||
|
await harness.send('move', x: 580, y: 500);
|
||||||
|
|
||||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
|
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user