Skip to content

Fix two-finger pinch-to-zoom in reader modes (#256)#378

Open
aaronbamblett wants to merge 1 commit into
Suwayomi:mainfrom
aaronbamblett:feat/pinch-zoom-fix
Open

Fix two-finger pinch-to-zoom in reader modes (#256)#378
aaronbamblett wants to merge 1 commit into
Suwayomi:mainfrom
aaronbamblett:feat/pinch-zoom-fix

Conversation

@aaronbamblett
Copy link
Copy Markdown

@aaronbamblett aaronbamblett commented May 12, 2026

Closes #256.

The bug

Reported in #256: two-finger pinch is "almost unusable" on Android — you have to "find the right angle" before it triggers. In practice it works once in ten attempts, never reliably.

Root cause

Gesture-arena race. The reader wraps content in DirectionalSwipeGestureHandler, whose HorizontalDragGestureRecognizer / VerticalDragGestureRecognizer accept the gesture as soon as either finger drifts past drag-slop (~18px). Meanwhile InteractiveViewer's scale recognizer is waiting for an actual scale delta to claim. The drag recognizer almost always wins, eating the pinch. Fast pinches occasionally race through because the scale delta accumulates faster.

Verified on a Pixel 9 Pro with an in-app diagnostic counter overlay (since stripped): scale callbacks fired zero times during slow pinches, while the outer drag recognizer's end fired every time.

The fix

Two pieces:

  1. Replace InteractiveViewer with zoom_view. Suggested by @DattatreyaReddy in the issue thread itself. It's a Flutter package designed for the "zoom inside a scrollable" case — handles gesture-arena coordination between scale and scroll via a shared controller.

  2. Small HorizontalDragGestureRecognizer / VerticalDragGestureRecognizer subclasses in DirectionalSwipeGestureHandler that reject multi-touch. When a second finger lands, the recognizer rejects its in-flight gesture via resolve(GestureDisposition.rejected) so the scale recognizer can claim. Single-finger swipes (the swipe-at-chapter-boundary feature) are unchanged. Tap and long-press handlers stay on a nested standard GestureDetector since they don't compete with multi-touch.

Configured zoom_view's forceHoldOnPointerDown: true flag (recommended by the package for use inside ScrollablePositionedList) and doubleTapDrag: true for the standard double-tap-to-zoom affordance.

Dependency situation (please read)

ScrollablePositionedList doesn't expose a standard ScrollControllerzoom_view needs one to coordinate scroll position with zoom. There's an open upstream PR at google/flutter.widgets#535 (by yakagami, the same author as zoom_view) that adds the needed ScrollPosition getter on ScrollOffsetController — 2-line change. It's been open since June 2024.

Pending that merge, this PR pins scrollable_positioned_list to yakagami's fork via git URL in pubspec.yaml. There's a comment in pubspec linking to PR #535 noting the situation; once Google's PR lands, the line can be reverted to a pub.dev version.

If you'd rather not introduce a git dep, the alternative is to vendor the 2-line change into a local copy of the package. Happy to take that path instead — just say the word.

Tests

Three widget tests under test/src/widgets/zoom/ cover the recognizer behavior:

  • Single-finger drag still triggers the end callback (didn't break the swipe-at-boundary feature).
  • Two-finger drag does NOT trigger the end callback (the actual fix).
  • After a multi-touch gesture, a fresh single-finger drag still works (the recognizer isn't left in a broken state).

One important note: the widget-test gesture arena is more permissive than the real-device arena. The test fixture deliberately includes a no-op ScaleGestureRecognizer so the arena has competition — without it, the lone drag recognizer eager-accepts and masks the bug. There's a documentation-only test calling this limitation out explicitly.

Two-finger pinch was almost unusable in both reader modes: the
horizontal/vertical drag recognizers in DirectionalSwipeGestureHandler
accept the gesture as soon as either finger drifts past drag slop,
beating InteractiveViewer's scale recognizer to the arena. Fast
pinches occasionally race through; normal pinches don't.

Replace InteractiveViewer with zoom_view, which coordinates scroll
position with zoom via a shared ScrollController and exposes a
forceHoldOnPointerDown flag specifically for the
ScrollablePositionedList case (recommended by the package author in
the linked Flutter framework issue).

ScrollablePositionedList doesn't expose a standard ScrollController,
so pin scrollable_positioned_list to yakagami's fork via git URL.
The fork adds a 2-line ScrollPosition getter on
ScrollOffsetController; the same change is sitting in
google/flutter.widgets#535 awaiting upstream merge.

Replace DirectionalSwipeGestureHandler's plain
HorizontalDragGestureRecognizer / VerticalDragGestureRecognizer with
subclasses that track the pointers they personally see and reject
their in-flight gesture (via resolve(GestureDisposition.rejected))
when a second pointer arrives. Single-finger swipes still drive the
existing swipe-at-chapter-boundary navigation; two-finger gestures
fall through to ZoomView. Tap and long-press handlers stay on a
nested GestureDetector since they don't compete with multi-touch.

Self-tracked pointer state rather than a Listener-maintained
counter — pointer-down events reach the gesture recognizer BEFORE
they reach outer Listeners in Flutter's dispatch order, so a
Listener-maintained count is stale by one event.

Tests under test/src/widgets/zoom/. The fixture deliberately
includes a no-op ScaleGestureRecognizer so the gesture arena has
competition; without it the lone drag recognizer eager-accepts and
masks the bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two-finger zoom is almost unusable on Android app

1 participant