Skip to content

fix: ignore tap navigation while user is mid-swipe#747

Merged
everpcpc merged 1 commit intoeverpcpc:mainfrom
spawvn:fix/ignore-tap-during-active-swipe
Apr 29, 2026
Merged

fix: ignore tap navigation while user is mid-swipe#747
everpcpc merged 1 commit intoeverpcpc:mainfrom
spawvn:fix/ignore-tap-during-active-swipe

Conversation

@spawvn
Copy link
Copy Markdown
Contributor

@spawvn spawvn commented Apr 28, 2026

Closes #746

Summary

When a tap on the page edge lands while a finger drag (or its deceleration / commit-cancel animation) is still in flight, the tap fires a second navigation on top of the in-flight transition — causing a double page advance, or in the two-finger case the tap overriding the drag entirely.

This affects Scroll and Cover transition styles. Page Curl is unaffected (UIPageViewController already rejects competing transitions during an interactive transition).

Fix: in both modes, gate tap-initiated navigation on the in-flight user-interaction state and discard the tap (clear navigationTarget) when blocked, so the tap doesn't get queued for after the drag finishes.

Root cause

Scroll (ScrollPageView_iOS.swift / ScrollPageView_macOS.swift)

handleNavigationChange already guards against !hasSyncedInitialPosition and isProgrammaticScrolling && isPendingProgrammaticCommit(target), but not engine.isUserInteracting. The engine sets that flag at scrollViewWillBeginDragging and clears it at scrollViewDidEndDecelerating — i.e., it spans the full drag-plus-deceleration window. A tap arriving in that window propagated to handleNavigationChange, which called scrollToItem(animated: true)setContentOffset(target, animated: true), interrupting the natural deceleration with a new programmatic scroll.

There is already a symmetric guard going the other direction (scrollViewWillBeginDraggingcancelProgrammaticNavigationIfNeeded) that lets a fresh drag override an in-flight programmatic scroll. The reverse — in-flight drag overrides a new programmatic scroll — is what was missing.

Cover (NativeCoverPageView_iOS.swift / NativeCoverPageView_macOS.swift)

update(...) guarded handleNavigationTarget with !isAnimatingTransition, which only covers the post-pan commit/cancel animation. During the active pan itself (UIPanGestureRecognizer.state == .changed), isAnimatingTransition is false. A tap during the active pan therefore passed the guard and fired handleNavigationTargetcommitTransition(to:, animation: .tapNavigation), interrupting the user's drag with a programmatic transition.

Fix

Scroll — early return in handleNavigationChange:

if engine.isUserInteracting {
  parent.viewModel.clearNavigationTarget()
  return
}

Cover — derive isUserPanning from the recognizer state and gate the navigation branch:

private var isUserPanning: Bool {
  guard let panRecognizer else { return false }
  switch panRecognizer.state {
  case .began, .changed: return true
  default: return false
  }
}

// In update(...):
if let navigationTarget = parent.viewModel.navigationTarget {
  if isUserPanning {
    parent.viewModel.clearNavigationTarget()
  } else {
    handleNavigationTarget(navigationTarget)
  }
}

clearNavigationTarget() ensures the tap is discarded, not queued. Without it, the tap would sit as a pending navigation target and fire as soon as the drag completed — producing a delayed page jump that's the same bug felt slightly differently.

Why this is structurally correct

Both fixes are mirrors of guards already present in the same files for the in-flight programmatic state. The pattern in each file is "do not start a new navigation while another navigation is in flight." Each file only checked half of that — the programmatic half. These changes close the symmetric gap by also checking the user-interaction half.

Scope

File Change
ScrollPageView_iOS.swift +10 lines: early return in handleNavigationChange
ScrollPageView_macOS.swift +10 lines: same
NativeCoverPageView_iOS.swift +20 lines: isUserPanning property + guard in update
NativeCoverPageView_macOS.swift +20 lines: same

PageCurl untouched; reporter confirmed it's already protected by UIKit.

Testing

Not tested. I do not have an Xcode build environment for this project, so the change has not been built or run. The reasoning is purely static:

  • For scroll: engine.isUserInteracting is already a known-good signal — cancelProgrammaticNavigationIfNeeded (line 506 iOS) reads similar state and is invoked from the same lifecycle (drag start). The guard simply uses it in the opposite direction.
  • For cover: panRecognizer.state is the standard UIKit/AppKit mechanism for querying recognizer state at any time; .began and .changed are exactly the active-touch states. applyPanRecognizerState (line 208 iOS / equivalent macOS) already inspects previousState == .began || previousState == .changed, so this pattern is already used in the same file.
  • clearNavigationTarget is the same call already used at line 305 / 314 / 321 / 326 / 342 of NativeCoverPageView_iOS for unresolved or no-op targets.

A maintainer with the build environment should verify:

  1. Scroll mode, single-finger sequence: drag → lift → tap during deceleration → page advances by exactly one (the deceleration's target), tap is ignored.
  2. Scroll mode, two-finger sequence: drag with one finger held mid-drag → tap edge with second finger → tap is ignored, drag completes normally.
  3. Cover mode, two-finger sequence: same as above.
  4. Cover mode, post-pan animation: tap during commit/cancel animation still ignored (the existing isAnimatingTransition guard, unchanged).
  5. Plain tap navigation when no drag is happening: works as before in both modes.
  6. PageCurl: no behavioral change.
  7. RTL reading direction: behavior unchanged in both modes.

🤖 Generated with Claude Code

When a tap on the page edge arrived during an in-flight finger drag (or
its deceleration / commit-cancel animation), the tap fired a second
navigation that interrupted or layered onto the user's gesture. The net
result was a double page advance — or, in the two-finger case, the tap
overriding the drag entirely.

Scroll mode: `handleNavigationChange` already guarded against
`isProgrammaticScrolling` but not `isUserInteracting`. The engine's
`isUserInteracting` flag spans drag start through deceleration end, so
add an early return for that state and clear `navigationTarget` so the
tap is fully discarded rather than queued for after deceleration.

Cover mode: `update(...)` guarded `handleNavigationTarget` with
`!isAnimatingTransition`, which only covers the post-pan commit/cancel
animation — during the active pan itself the flag is false. Derive
`isUserPanning` from `panRecognizer.state` (`.began` or `.changed`) and
discard tap-initiated navigation while panning, mirroring the scroll
behavior.

Page Curl is unaffected; UIPageViewController already rejects competing
transitions during an interactive transition.

Both fixes are mirrors of guards already present in the same files for
the in-flight programmatic state — they just close the symmetric gap
where in-flight user interaction needed the same protection.

Closes everpcpc#746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@everpcpc everpcpc merged commit d7b9695 into everpcpc:main Apr 29, 2026
3 checks passed
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.

Tap during in-progress swipe triggers extra page change in scroll and cover modes

2 participants