Skip to content

Commit 03c45ff

Browse files
committed
fix: queue rapid page-turn taps so clicks feel responsive
Taps during the turn animation were dropped for ~500ms (anim + cooldown). Queue up to two intents while busy, drain after settle, shorten cooldown and default animation so consecutive side-taps keep up.
1 parent 94ad5b3 commit 03c45ff

2 files changed

Lines changed: 137 additions & 20 deletions

File tree

lib/view/book/page_content_reader.dart

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
5151

5252
animationController = AnimationControllerWithListenerNumber(
5353
vsync: this,
54-
duration: const Duration(milliseconds: 280),
54+
duration: const Duration(milliseconds: 220),
5555
);
5656

5757
pageManager = ReaderPageManager()..onTurnSettled = _onTurnSettled;
@@ -78,8 +78,13 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
7878

7979
void _onTurnSettled() {
8080
if (!mounted) return;
81+
// Keep an in-flight finger (settle-tap candidate) so its UP can still queue
82+
// the next page — do not wipe _activePointer mid-gesture.
83+
if (_activePointer != null) {
84+
_log('settled (pointer held) → keep finger for queue-tap');
85+
return;
86+
}
8187
_phase = _PointerPhase.idle;
82-
_activePointer = null;
8388
_log('settled → idle');
8489
}
8590

@@ -133,8 +138,14 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
133138
_log('DOWN ignored (menu)');
134139
return;
135140
}
141+
// While a turn animation is running we still accept the down so that a
142+
// quick tap-up can queue the next page (跟手). Drag tracking stays blocked.
136143
if (_animating || _phase == _PointerPhase.settling) {
137-
_log('DOWN ignored (animating)');
144+
_activePointer = e.pointer;
145+
_downPos = e.localPosition;
146+
_lastPos = e.localPosition;
147+
_phase = _PointerPhase.settling; // only queue on UP, no drag paint
148+
_log('DOWN during anim → settle-tap candidate');
138149
return;
139150
}
140151

@@ -147,6 +158,11 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
147158

148159
void _onPointerMove(PointerMoveEvent e) {
149160
if (e.pointer != _activePointer) return;
161+
// Taps queued during settle never become drags.
162+
if (_phase == _PointerPhase.settling) {
163+
_lastPos = e.localPosition;
164+
return;
165+
}
150166
if (_phase != _PointerPhase.down && _phase != _PointerPhase.dragging) {
151167
return;
152168
}
@@ -185,13 +201,13 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
185201
_log('UP ignored (menu)');
186202
return;
187203
}
188-
if (_animating) {
189-
_log('UP ignored (animating)');
190-
return;
191-
}
192204

193205
switch (phase) {
194206
case _PointerPhase.dragging:
207+
if (_animating) {
208+
_log('UP swipe ignored (animating)');
209+
return;
210+
}
195211
_log('UP as SWIPE end=${e.localPosition}');
196212
final started = pageManager?.finishSwipe(e.localPosition) ?? false;
197213
if (started) {
@@ -211,6 +227,21 @@ class _PageContentReaderState extends ConsumerState<PageContentReader>
211227
break;
212228

213229
case _PointerPhase.settling:
230+
// Tap that began during an in-flight turn — queue next page.
231+
// Only treat as tap if finger barely moved (not a mid-anim drag).
232+
final dist = (_lastPos - _downPos).distance;
233+
if (dist <= _dragSlop) {
234+
_log('UP settle-tap → queue pos=${e.localPosition}');
235+
final size = _canvasSize(context);
236+
final started = vm.tapPageAt(e.localPosition, size);
237+
if (started || pageManager?.isAnimating == true) {
238+
_phase = _PointerPhase.settling;
239+
}
240+
} else {
241+
_log('UP settle ignored (moved ${dist.toStringAsFixed(1)})');
242+
}
243+
break;
244+
214245
case _PointerPhase.idle:
215246
_log('UP ignored (phase=$phase)');
216247
break;

lib/view/page_turn/reader_page_manager.dart

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@ import 'package:book/model/read_model.dart';
66
import 'package:book/view/page_turn/touch_event.dart';
77
import 'package:flutter/foundation.dart';
88
import 'package:flutter/material.dart';
9+
import 'package:flutter/scheduler.dart';
910

1011
/// Page-turn orchestrator.
1112
///
1213
/// **Single commit rule:** the only place that advances the page is
1314
/// [_commitTurn][ReadModel.commitPageTurn]. Cover / Simulation / Static
1415
/// paint only; they never call commitPageTurn themselves.
16+
///
17+
/// Rapid taps while animating are queued (capped) and drained as soon as the
18+
/// current turn settles so consecutive clicks feel responsive.
1519
class ReaderPageManager {
1620
static const int TYPE_ANIMATION_NONE = 0;
1721
static const int TYPE_ANIMATION_SIMULATION_TURN = 1;
1822
static const int TYPE_ANIMATION_COVER_TURN = 2;
1923
static const int TYPE_ANIMATION_SLIDE_TURN = 3;
2024

21-
/// Short anti double-submit window only (not a hard UI lock).
22-
static const Duration _cooldown = Duration(milliseconds: 220);
25+
/// Anti double-submit only — short enough that queued taps drain quickly.
26+
static const Duration _cooldown = Duration(milliseconds: 70);
27+
28+
/// Max queued tap directions (prevents runaway multi-page jump).
29+
static const int _maxQueued = 2;
2330

2431
late BaseAnimationPage currentAnimationPage;
2532
TouchEvent currentTouchData = TouchEvent(TouchEvent.ACTION_UP, Offset.zero);
@@ -41,6 +48,10 @@ class ReaderPageManager {
4148
int _turnEpoch = 0;
4249
DateTime? _lastCommitAt;
4350

51+
/// Directions accepted while busy; drained after settle.
52+
final List<int> _queuedDirs = <int>[];
53+
bool _drainScheduled = false;
54+
4455
/// True only while confirm/cancel animation is running.
4556
bool get isAnimating => currentState == PageTurnState.animating;
4657

@@ -53,13 +64,23 @@ class ReaderPageManager {
5364
return DateTime.now().difference(t) < _cooldown;
5465
}
5566

67+
Duration get _cooldownRemaining {
68+
final t = _lastCommitAt;
69+
if (t == null) return Duration.zero;
70+
final left = _cooldown - DateTime.now().difference(t);
71+
return left.isNegative ? Duration.zero : left;
72+
}
73+
5674
bool get _needsController =>
5775
currentAnimationType == TYPE_ANIMATION_COVER_TURN ||
5876
currentAnimationType == TYPE_ANIMATION_SIMULATION_TURN;
5977

6078
void _log(String msg) {
6179
if (kDebugMode) {
62-
debugPrint('[PageTurn] $msg state=$currentState epoch=$_turnEpoch');
80+
debugPrint(
81+
'[PageTurn] $msg state=$currentState epoch=$_turnEpoch '
82+
'q=$_queuedDirs',
83+
);
6384
}
6485
}
6586

@@ -131,26 +152,53 @@ class ReaderPageManager {
131152
_markPaint();
132153
}
133154

155+
/// Start (or queue) a tap-driven turn. Returns true if accepted or queued.
134156
bool triggerTapTurn(int direction) {
157+
if (direction == 0) return false;
158+
final dir = direction > 0 ? 1 : -1;
159+
160+
// While a turn is in flight / cooling down, keep the latest intents so
161+
// rapid clicks still advance instead of being dropped.
135162
if (isBusy) {
136-
_log('triggerTapTurn ignored (busy animating=$isAnimating cd=$_inCooldown)');
137-
return false;
163+
return _enqueueTap(dir);
164+
}
165+
166+
return _startTapTurn(dir);
167+
}
168+
169+
bool _enqueueTap(int dir) {
170+
if (_queuedDirs.length >= _maxQueued) {
171+
// Replace tail with latest intent (keeps 跟手 without multi-page jump).
172+
_queuedDirs[_queuedDirs.length - 1] = dir;
173+
_log('triggerTapTurn queue full → replace tail dir=$dir');
174+
_scheduleDrain();
175+
return true;
138176
}
177+
// Collapse consecutive same-direction spam into one extra step max is
178+
// already capped by _maxQueued; still allow stacking up to the cap.
179+
_queuedDirs.add(dir);
180+
_log('triggerTapTurn queued dir=$dir');
181+
_scheduleDrain();
182+
return true;
183+
}
139184

140-
final goNext = direction > 0;
185+
bool _startTapTurn(int dir) {
186+
final goNext = dir > 0;
141187
if (goNext && !currentAnimationPage.canTurnNext()) {
142188
_log('triggerTapTurn blocked: cannot go next');
189+
_queuedDirs.clear();
143190
return false;
144191
}
145192
if (!goNext && !currentAnimationPage.canTurnPrevious()) {
146193
_log('triggerTapTurn blocked: cannot go pre');
194+
_queuedDirs.clear();
147195
return false;
148196
}
149197

150198
if (currentAnimationType == TYPE_ANIMATION_NONE ||
151199
animationController == null) {
152-
_log('triggerTapTurn static dir=${goNext ? 1 : -1}');
153-
_commitTurn(goNext ? 1 : -1);
200+
_log('triggerTapTurn static dir=$dir');
201+
_commitTurn(dir);
154202
return true;
155203
}
156204

@@ -167,7 +215,7 @@ class ReaderPageManager {
167215
final h2 = currentAnimationPage.currentSize.height;
168216
if (w2 <= 0 || h2 <= 0) {
169217
_log('triggerTapTurn fallback static (no size)');
170-
_commitTurn(goNext ? 1 : -1);
218+
_commitTurn(dir);
171219
return true;
172220
}
173221

@@ -190,17 +238,49 @@ class ReaderPageManager {
190238
currentAnimationPage.onTouchEvent(TouchEvent(TouchEvent.ACTION_MOVE, end));
191239
currentTouchData = TouchEvent(TouchEvent.ACTION_MOVE, end);
192240

193-
_log('triggerTapTurn anim dir=${goNext ? 1 : -1} size=${w2}x$h2');
194-
final ok = startConfirmAnimation(goNext ? 1 : -1);
241+
_log('triggerTapTurn anim dir=$dir size=${w2}x$h2');
242+
final ok = startConfirmAnimation(dir);
195243
if (!ok) {
196244
// Animation failed to start — still advance page so taps never "die".
197245
_log('triggerTapTurn anim failed → instant commit');
198-
_commitTurn(goNext ? 1 : -1);
246+
_commitTurn(dir);
199247
return true;
200248
}
201249
return true;
202250
}
203251

252+
void _scheduleDrain() {
253+
if (_drainScheduled || _queuedDirs.isEmpty) return;
254+
_drainScheduled = true;
255+
final wait = _cooldownRemaining;
256+
void run() {
257+
_drainScheduled = false;
258+
_drainQueue();
259+
}
260+
261+
if (wait > Duration.zero) {
262+
Future<void>.delayed(wait, run);
263+
} else {
264+
// Defer out of animation status / commit re-entrancy.
265+
SchedulerBinding.instance.addPostFrameCallback((_) => run());
266+
}
267+
}
268+
269+
void _drainQueue() {
270+
if (_queuedDirs.isEmpty) return;
271+
if (isAnimating) {
272+
// Will retry after current turn settles.
273+
return;
274+
}
275+
if (_inCooldown) {
276+
_scheduleDrain();
277+
return;
278+
}
279+
final dir = _queuedDirs.removeAt(0);
280+
_log('drain queue dir=$dir remaining=$_queuedDirs');
281+
_startTapTurn(dir);
282+
}
283+
204284
void setPageSize(Size size) {
205285
currentAnimationPage.setSize(size);
206286
}
@@ -215,6 +295,7 @@ class ReaderPageManager {
215295

216296
void setCurrentAnimation(int animationType) {
217297
_abortAnimation();
298+
_queuedDirs.clear();
218299
currentAnimationType = animationType;
219300
switch (animationType) {
220301
case TYPE_ANIMATION_SIMULATION_TURN:
@@ -360,6 +441,8 @@ class ReaderPageManager {
360441
);
361442
_markPaint();
362443
onTurnSettled?.call();
444+
// Kick off any taps that arrived during this animation.
445+
_scheduleDrain();
363446
};
364447

365448
animation.addListener(_tickListener!);
@@ -377,6 +460,7 @@ class ReaderPageManager {
377460
// Instant (static) turns settle immediately.
378461
if (currentState != PageTurnState.animating) {
379462
onTurnSettled?.call();
463+
_scheduleDrain();
380464
}
381465
}
382466

@@ -386,6 +470,7 @@ class ReaderPageManager {
386470

387471
void interruptCancelAnimation() {
388472
_abortAnimation();
473+
_queuedDirs.clear();
389474
_markPaint();
390475
onTurnSettled?.call();
391476
}
@@ -397,7 +482,8 @@ class ReaderPageManager {
397482
}
398483

399484
void setAnimationController(AnimationController controller) {
400-
controller.duration ??= const Duration(milliseconds: 280);
485+
// Slightly snappier default so queued taps feel closer to 跟手.
486+
controller.duration ??= const Duration(milliseconds: 220);
401487
animationController = controller;
402488
if (_needsController) {
403489
currentAnimationPage.setAnimationController(controller);

0 commit comments

Comments
 (0)