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 {