From 0e8381d2d192069ca9ad72b06b7246d43bc639fb Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 29 Apr 2026 00:09:57 +0200 Subject: [PATCH] fix: ignore tap navigation while user is mid-swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #746 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/NativeCoverPageView_iOS.swift | 20 ++++++++++++++++++- .../Views/NativeCoverPageView_macOS.swift | 20 ++++++++++++++++++- .../Reader/Views/ScrollPageView_iOS.swift | 10 ++++++++++ .../Reader/Views/ScrollPageView_macOS.swift | 10 ++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/KMReader/Features/Reader/Views/NativeCoverPageView_iOS.swift b/KMReader/Features/Reader/Views/NativeCoverPageView_iOS.swift index 9b6d8cc1..0a66499c 100644 --- a/KMReader/Features/Reader/Views/NativeCoverPageView_iOS.swift +++ b/KMReader/Features/Reader/Views/NativeCoverPageView_iOS.swift @@ -111,7 +111,15 @@ } else if !isAnimatingTransition { syncCurrentItemFromViewModel() if let navigationTarget = parent.viewModel.navigationTarget { - handleNavigationTarget(navigationTarget) + // While the user is actively panning, discard tap-initiated navigation. + // `isAnimatingTransition` only covers the post-pan commit/cancel animation; + // during the pan itself it is false, so an unguarded tap would call + // `commitTransition` mid-drag and override the user's gesture. + if isUserPanning { + parent.viewModel.clearNavigationTarget() + } else { + handleNavigationTarget(navigationTarget) + } } else { syncSlotContent() updateSlotLayout() @@ -223,6 +231,16 @@ deckState.currentItem } + private var isUserPanning: Bool { + guard let panRecognizer else { return false } + switch panRecognizer.state { + case .began, .changed: + return true + default: + return false + } + } + private var pendingTargetItem: ReaderViewItem? { guard let transitionDirection else { return nil } return transitionDirection == 1 ? deckState.nextItem : deckState.previousItem diff --git a/KMReader/Features/Reader/Views/NativeCoverPageView_macOS.swift b/KMReader/Features/Reader/Views/NativeCoverPageView_macOS.swift index 40282b84..fc8a296c 100644 --- a/KMReader/Features/Reader/Views/NativeCoverPageView_macOS.swift +++ b/KMReader/Features/Reader/Views/NativeCoverPageView_macOS.swift @@ -112,7 +112,15 @@ } else if !isAnimatingTransition { syncCurrentItemFromViewModel() if let navigationTarget = parent.viewModel.navigationTarget { - handleNavigationTarget(navigationTarget) + // While the user is actively panning, discard tap-initiated navigation. + // `isAnimatingTransition` only covers the post-pan commit/cancel animation; + // during the pan itself it is false, so an unguarded tap would call + // `commitTransition` mid-drag and override the user's gesture. + if isUserPanning { + parent.viewModel.clearNavigationTarget() + } else { + handleNavigationTarget(navigationTarget) + } } else { syncSlotContent() updateSlotLayout() @@ -216,6 +224,16 @@ deckState.currentItem } + private var isUserPanning: Bool { + guard let panRecognizer else { return false } + switch panRecognizer.state { + case .began, .changed: + return true + default: + return false + } + } + private var pendingTargetItem: ReaderViewItem? { guard let transitionDirection else { return nil } return transitionDirection == 1 ? deckState.nextItem : deckState.previousItem diff --git a/KMReader/Features/Reader/Views/ScrollPageView_iOS.swift b/KMReader/Features/Reader/Views/ScrollPageView_iOS.swift index ebf05816..99d9b368 100644 --- a/KMReader/Features/Reader/Views/ScrollPageView_iOS.swift +++ b/KMReader/Features/Reader/Views/ScrollPageView_iOS.swift @@ -354,6 +354,16 @@ _ navigationTarget: ReaderViewItem, in collectionView: UICollectionView ) { + // While the user is in the middle of a swipe (drag or its deceleration), ignore + // tap-initiated navigation. The drag's intent dominates; layering a programmatic + // scroll over natural deceleration produces a double page advance. Mirrors the + // existing guard in `scrollViewWillBeginDragging` that lets a drag override an + // in-flight programmatic scroll. + if engine.isUserInteracting { + parent.viewModel.clearNavigationTarget() + return + } + guard let resolvedTarget = parent.viewModel.resolvedViewItem(for: navigationTarget), let targetItem = engine.resolveItem(resolvedTarget) else { diff --git a/KMReader/Features/Reader/Views/ScrollPageView_macOS.swift b/KMReader/Features/Reader/Views/ScrollPageView_macOS.swift index 0eb62f20..ea2b3ab7 100644 --- a/KMReader/Features/Reader/Views/ScrollPageView_macOS.swift +++ b/KMReader/Features/Reader/Views/ScrollPageView_macOS.swift @@ -421,6 +421,16 @@ in scrollView: NSScrollView, collectionView: NSCollectionView ) { + // While the user is in the middle of a swipe (drag or its deceleration), ignore + // tap-initiated navigation. The drag's intent dominates; layering a programmatic + // scroll over natural deceleration produces a double page advance. Mirrors the + // existing guard in `scrollViewWillBeginDragging` that lets a drag override an + // in-flight programmatic scroll. + if engine.isUserInteracting { + parent.viewModel.clearNavigationTarget() + return + } + guard let resolvedTarget = parent.viewModel.resolvedViewItem(for: navigationTarget), let targetItem = engine.resolveItem(resolvedTarget) else {