From 69e0cc2f1b6a870e001f4cbf98125804cce9cc60 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 14 May 2026 16:50:54 +0900 Subject: [PATCH 1/5] fix(navigation): handle horizontal scroll swipe transitions --- .../NavigationStackController.swift | 395 ++++++++++++- .../NavigationViewGestureController.swift | 69 ++- .../NavigationStackControllerTests.swift | 521 ++++++++++++++++++ Tools/MiniApp/MiniApp/ContentView.swift | 348 +++++++++++- 4 files changed, 1283 insertions(+), 50 deletions(-) diff --git a/Sources/NavigationStackController/NavigationStackController.swift b/Sources/NavigationStackController/NavigationStackController.swift index 2ce6c24..8bd9d33 100644 --- a/Sources/NavigationStackController/NavigationStackController.swift +++ b/Sources/NavigationStackController/NavigationStackController.swift @@ -15,7 +15,7 @@ public enum NavigationStackOperation: Equatable, Sendable { case forward } -enum NavigationStackDirection { +enum NavigationStackDirection: Equatable { case push case back case forward @@ -135,8 +135,21 @@ public final class NavigationStackController: NSViewController { private var forwardViewControllerStack: [NSViewController] = [] private var activeTransition: Transition? private var isNotifyingWillShow = false + private struct EventMonitorToken: @unchecked Sendable { + let value: Any + } + + private struct EventBox: @unchecked Sendable { + let value: NSEvent + } + + private var gestureEventMonitor: EventMonitorToken? private lazy var viewGestureController = NavigationViewGestureController(navigationController: self) + var isGestureEventMonitorInstalled: Bool { + gestureEventMonitor != nil + } + private var canStartNavigation: Bool { activeTransition == nil && !isNotifyingWillShow } @@ -157,6 +170,12 @@ public final class NavigationStackController: NSViewController { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } + deinit { + if let gestureEventMonitor { + NSEvent.removeMonitor(gestureEventMonitor.value) + } + } + /// Creates a navigation stack controller from an archive. public required init?(coder: NSCoder) { super.init(coder: coder) @@ -181,6 +200,22 @@ public final class NavigationStackController: NSViewController { layoutContentViews() } + public override func wantsScrollEventsForSwipeTracking(on axis: NSEvent.GestureAxis) -> Bool { + let wants = shouldForwardScrollEventsForSwipeTracking(on: axis) + return wants + } + + public override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool { + let wants = shouldForwardScrollEventsForSwipeTracking(on: axis) + return wants + } + + @objc(_tryToSwipeWithEvent:ignoringPinnedState:) + func _tryToSwipe(with event: NSEvent, ignoringPinnedState: Bool) -> Bool { + let handled = handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringPinnedState) + return handled + } + /// Replaces the current stack with a new set of view controllers. /// /// Calling this method clears forward history. The array must contain at least one view controller. @@ -375,14 +410,99 @@ public final class NavigationStackController: NSViewController { return incomingViewController } - fileprivate func handleScrollWheel(_ event: NSEvent) -> Bool { - viewGestureController.handleScrollWheel(event) + fileprivate func handleScrollWheel(_ event: NSEvent, ignoringHorizontalScrollViews: Bool = false) -> Bool { + let handled = viewGestureController.handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringHorizontalScrollViews) + updateGestureEventMonitorState() + return handled + } + + func finishActiveSwipeGesture(cancelled: Bool) -> Bool { + let handled = viewGestureController.finishActiveSwipeGesture(cancelled: cancelled) + updateGestureEventMonitorState() + return handled + } + + func shouldDeferSwipeTrackingToHorizontalScrollView(for event: NSEvent) -> Bool { + let localPoint = containerView.convert(event.locationInWindow, from: nil) + let hitView = containerView.hitTest(localPoint) + + let shouldDefer = NavigationHorizontalScrollConflictResolver.canScrollHorizontally( + from: hitView, + inside: containerView, + deltaX: event.scrollingDeltaX + ) + return shouldDefer } fileprivate func shouldForwardScrollEventsForSwipeTracking(on axis: NSEvent.GestureAxis) -> Bool { viewGestureController.wantsScrollEventsForSwipeTracking(on: axis) } + private func startGestureEventMonitorIfNeeded() { + guard gestureEventMonitor == nil else { + return + } + + guard let monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel, .endGesture], handler: { [weak self] event in + let eventBox = EventBox(value: event) + let consumed = MainActor.assumeIsolated { + guard let self else { + return false + } + + return self.handleLocalGestureMonitorEvent(eventBox.value) + } + return consumed ? nil : event + }) else { + return + } + + gestureEventMonitor = EventMonitorToken(value: monitor) + } + + private func updateGestureEventMonitorState() { + if viewGestureController.isTrackingSwipeGesture || activeTransition != nil { + startGestureEventMonitorIfNeeded() + } else { + stopGestureEventMonitor() + } + } + + func handleLocalGestureMonitorEvent(_ event: NSEvent, requiringMatchingWindow: Bool = true) -> Bool { + let currentWindow = unsafe view.window + let currentWindowNumber = currentWindow?.windowNumber + let matchesWindow = !requiringMatchingWindow + || (event.window != nil && event.window === currentWindow) + || (event.windowNumber != 0 && event.windowNumber == currentWindowNumber) + + switch event.type { + case .scrollWheel: + guard matchesWindow else { + return false + } + + let handled = handleScrollWheel(event, ignoringHorizontalScrollViews: true) + return handled + case .endGesture: + guard matchesWindow else { + return false + } + + return finishActiveSwipeGesture(cancelled: false) + default: + return false + } + } + + private func stopGestureEventMonitor() { + guard let gestureEventMonitor else { + return + } + + NSEvent.removeMonitor(gestureEventMonitor.value) + self.gestureEventMonitor = nil + } + fileprivate func layoutContentViews() { if let activeTransition { activeTransition.layout(in: containerView.bounds, parallaxFactor: parallaxFactor, layoutDirection: view.userInterfaceLayoutDirection) @@ -404,12 +524,24 @@ extension NavigationStackController { let toViewController: NSViewController let direction: NavigationStackDirection let operation: NavigationStackOperation + let backdropView = NavigationTransitionBackdropView() + let fromTransitionView = NavigationTransitionContentView() + let toTransitionView = NavigationTransitionContentView() var progress: CGFloat = 0 + var isAnimating = false var visibleViewController: NSViewController { progress >= 1 ? toViewController : fromViewController } + private var fromAnimatedView: NSView { + fromTransitionView.contentView == nil ? fromViewController.view : fromTransitionView + } + + private var toAnimatedView: NSView { + toTransitionView.contentView == nil ? toViewController.view : toTransitionView + } + init(from fromViewController: NSViewController, to toViewController: NSViewController, direction: NavigationStackDirection, operation: NavigationStackOperation) { self.fromViewController = fromViewController self.toViewController = toViewController @@ -418,16 +550,24 @@ extension NavigationStackController { } func layout(in bounds: NSRect, parallaxFactor: CGFloat, layoutDirection: NSUserInterfaceLayoutDirection) { + guard !isAnimating else { + return + } + apply(progress: progress, in: bounds, parallaxFactor: parallaxFactor, layoutDirection: layoutDirection, animated: false) } func apply(progress rawProgress: CGFloat, in bounds: NSRect, parallaxFactor: CGFloat, layoutDirection: NSUserInterfaceLayoutDirection, animated: Bool) { - progress = min(max(rawProgress, 0), 1) + let progress = min(max(rawProgress, 0), 1) + if !animated { + self.progress = progress + } let width = max(bounds.width, 1) let layoutSign: CGFloat = layoutDirection == .rightToLeft ? -1 : 1 - let fromView = fromViewController.view - let toView = toViewController.view + let fromView = fromAnimatedView + let toView = toAnimatedView + backdropView.frame = bounds var fromFrame = bounds var toFrame = bounds @@ -532,17 +672,49 @@ extension NavigationStackController { toView.autoresizingMask = [.width, .height] fromView.wantsLayer = true toView.wantsLayer = true + transition.fromTransitionView.installContentView(fromView) + transition.toTransitionView.installContentView(toView) + + let fromTransitionView = transition.fromTransitionView + let toTransitionView = transition.toTransitionView switch transition.direction { case .push, .forward: - containerView.addSubview(fromView) - containerView.addSubview(toView, positioned: .above, relativeTo: fromView) + containerView.addSubview(fromTransitionView) + containerView.addSubview(toTransitionView, positioned: .above, relativeTo: fromTransitionView) case .back: - containerView.addSubview(toView) - containerView.addSubview(fromView, positioned: .above, relativeTo: toView) + transition.backdropView.backgroundColor = transitionBackdropColor(matching: toView) + containerView.addSubview(transition.backdropView) + containerView.addSubview(toTransitionView) + containerView.addSubview(fromTransitionView, positioned: .above, relativeTo: toTransitionView) } transition.apply(progress: 0, in: containerView.bounds, parallaxFactor: parallaxFactor, layoutDirection: view.userInterfaceLayoutDirection, animated: false) + primeTransitionViewForDisplay(fromTransitionView) + primeTransitionViewForDisplay(toTransitionView) + } + + func primeTransitionViewForDisplay(_ view: NSView) { + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + } + + func transitionBackdropColor(matching view: NSView) -> NSColor { + if let scrollView = view as? NSScrollView { + if scrollView.drawsBackground { + return scrollView.backgroundColor + } + + if scrollView.contentView.drawsBackground { + return scrollView.contentView.backgroundColor + } + } + + if let backgroundColor = view.layer?.backgroundColor, let color = NSColor(cgColor: backgroundColor), color.alphaComponent > 0 { + return color + } + + return .windowBackgroundColor } func runTransition(from fromViewController: NSViewController, to toViewController: NSViewController, direction: NavigationStackDirection, operation: NavigationStackOperation, animated: Bool, commit: @escaping @MainActor @Sendable () -> Void) { @@ -559,13 +731,16 @@ extension NavigationStackController { NSAnimationContext.runAnimationGroup { context in context.duration = transitionDuration + context.allowsImplicitAnimation = true context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + transition.isAnimating = true transition.apply(progress: 1, in: containerView.bounds, parallaxFactor: parallaxFactor, layoutDirection: view.userInterfaceLayoutDirection, animated: true) } completionHandler: { [weak self] in MainActor.assumeIsolated { guard let self else { return } + transition.isAnimating = false self.finishTransition(transition, committed: true, commit: commit, animated: true) } } @@ -574,6 +749,7 @@ extension NavigationStackController { func finishTransition(_ transition: Transition, committed: Bool, commit: @MainActor @Sendable () -> Void, animated: Bool) { if activeTransition === transition { activeTransition = nil + stopGestureEventMonitor() } if committed { @@ -667,9 +843,9 @@ extension NavigationStackController { toViewController = forwardViewController operation = .forward } - let transition = Transition(from: fromViewController, to: toViewController, direction: direction, operation: operation) activeTransition = transition + startGestureEventMonitorIfNeeded() notifyWillShow(toViewController, operation: operation, animated: true) prepareTransition(transition) return true @@ -686,10 +862,17 @@ extension NavigationStackController { } let targetProgress: CGFloat = committed ? 1 : 0 + guard duration > 0 else { + transition.apply(progress: targetProgress, in: containerView.bounds, parallaxFactor: parallaxFactor, layoutDirection: view.userInterfaceLayoutDirection, animated: false) + completeSwipeTransitionIfCurrent(transition, committed: committed, animated: false, completion: completion) + return + } NSAnimationContext.runAnimationGroup { context in context.duration = duration + context.allowsImplicitAnimation = true context.timingFunction = CAMediaTimingFunction(name: committed ? .easeOut : .easeInEaseOut) + transition.isAnimating = true transition.apply(progress: targetProgress, in: containerView.bounds, parallaxFactor: parallaxFactor, layoutDirection: view.userInterfaceLayoutDirection, animated: true) } completionHandler: { [weak self] in MainActor.assumeIsolated { @@ -697,13 +880,77 @@ extension NavigationStackController { return } - self.finishTransition(transition, committed: committed, commit: { - self.commitStackMutation(for: transition) - }, animated: true) - completion() + transition.isAnimating = false + self.completeSwipeTransitionIfCurrent(transition, committed: committed, animated: true, completion: completion) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.05) { [weak self] in + MainActor.assumeIsolated { + transition.isAnimating = false + self?.completeSwipeTransitionIfCurrent(transition, committed: committed, animated: true, completion: completion) } } } + + private func completeSwipeTransitionIfCurrent(_ transition: Transition, committed: Bool, animated: Bool, completion: @escaping @MainActor () -> Void) { + guard activeTransition === transition else { + return + } + + finishTransition(transition, committed: committed, commit: { + commitStackMutation(for: transition) + }, animated: animated) + completion() + } + +} + +@MainActor +struct NavigationHorizontalScrollConflictResolver { + static func canScrollHorizontally(from hitView: NSView?, inside boundaryView: NSView, deltaX: CGFloat) -> Bool { + guard deltaX != 0 else { + return false + } + + var currentView = hitView + while let view = currentView { + if let scrollView = view as? NSScrollView { + let canScroll = canScrollHorizontally(scrollView, deltaX: deltaX) + if canScroll { + return true + } + } + + if view === boundaryView { + return false + } + + currentView = unsafe view.superview + } + + return false + } + + static func canScrollHorizontally(_ scrollView: NSScrollView, deltaX: CGFloat) -> Bool { + guard let documentView = scrollView.documentView else { + return false + } + + let visibleRect = scrollView.contentView.documentVisibleRect + let documentBounds = documentView.bounds + let minimumX = documentBounds.minX + let maximumX = max(documentBounds.maxX - visibleRect.width, minimumX) + let currentX = visibleRect.minX + let tolerance: CGFloat = 0.5 + + let canScroll = if deltaX > 0 { + currentX > minimumX + tolerance + } else { + currentX < maximumX - tolerance + } + return canScroll + } } private final class NavigationStackContainerView: NSView { @@ -713,6 +960,21 @@ private final class NavigationStackContainerView: NSView { true } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureTouchTracking() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureTouchTracking() + } + + private func configureTouchTracking() { + allowedTouchTypes = [.indirect] + wantsRestingTouches = true + } + override func layout() { super.layout() navigationController?.layoutContentViews() @@ -722,15 +984,112 @@ private final class NavigationStackContainerView: NSView { if navigationController?.handleScrollWheel(event) == true { return } - super.scrollWheel(with: event) } override func wantsScrollEventsForSwipeTracking(on axis: NSEvent.GestureAxis) -> Bool { - navigationController?.shouldForwardScrollEventsForSwipeTracking(on: axis) ?? false + let wants = navigationController?.shouldForwardScrollEventsForSwipeTracking(on: axis) ?? false + return wants } override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool { - navigationController?.shouldForwardScrollEventsForSwipeTracking(on: axis) ?? false + let wants = navigationController?.shouldForwardScrollEventsForSwipeTracking(on: axis) ?? false + return wants + } + + override func endGesture(with event: NSEvent) { + if navigationController?.finishActiveSwipeGesture(cancelled: false) == true { + return + } + + super.endGesture(with: event) + } + + override func touchesEnded(with event: NSEvent) { + if navigationController?.finishActiveSwipeGesture(cancelled: false) == true { + return + } + + super.touchesEnded(with: event) + } + + override func touchesCancelled(with event: NSEvent) { + if navigationController?.finishActiveSwipeGesture(cancelled: true) == true { + return + } + + super.touchesCancelled(with: event) + } + + @objc(_tryToSwipeWithEvent:ignoringPinnedState:) + func _tryToSwipe(with event: NSEvent, ignoringPinnedState: Bool) -> Bool { + let handled = navigationController?.handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringPinnedState) ?? false + return handled + } +} + +final class NavigationTransitionBackdropView: NSView { + var backgroundColor: NSColor = .windowBackgroundColor { + didSet { + layer?.backgroundColor = backgroundColor.cgColor + needsDisplay = true + } + } + + override var isOpaque: Bool { + true + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureLayer() + } + + private func configureLayer() { + wantsLayer = true + layer?.backgroundColor = backgroundColor.cgColor + } + + override func draw(_ dirtyRect: NSRect) { + backgroundColor.setFill() + dirtyRect.fill() + } +} + +final class NavigationTransitionContentView: NSView { + private(set) var contentView: NSView? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureLayer() + } + + func installContentView(_ view: NSView) { + contentView?.removeFromSuperview() + contentView = view + view.removeFromSuperview() + view.frame = bounds + view.autoresizingMask = [.width, .height] + addSubview(view) + } + + override func layout() { + super.layout() + contentView?.frame = bounds + } + + private func configureLayer() { + wantsLayer = true + layer?.masksToBounds = true } } diff --git a/Sources/NavigationStackController/NavigationViewGestureController.swift b/Sources/NavigationStackController/NavigationViewGestureController.swift index 212cb20..de6b4e1 100644 --- a/Sources/NavigationStackController/NavigationViewGestureController.swift +++ b/Sources/NavigationStackController/NavigationViewGestureController.swift @@ -9,7 +9,7 @@ final class NavigationViewGestureController { self.navigationController = navigationController } - func handleScrollWheel(_ event: NSEvent) -> Bool { + func handleScrollWheel(_ event: NSEvent, ignoringHorizontalScrollViews: Bool = false) -> Bool { guard let navigationController else { return false } @@ -26,6 +26,10 @@ final class NavigationViewGestureController { return false } + if !ignoringHorizontalScrollViews, navigationController.shouldDeferSwipeTrackingToHorizontalScrollView(for: event) { + return false + } + return swipeProgressTracker.handleScrollWheel(event) } @@ -34,10 +38,11 @@ final class NavigationViewGestureController { return false } - return axis == .horizontal + let wants = axis == .horizontal && navigationController.allowsBackForwardNavigationGestures && NSEvent.isSwipeTrackingFromScrollEventsEnabled && (navigationController.canGoBack || navigationController.canGoForward) + return wants } func progressSign(for direction: NavigationStackDirection) -> CGFloat { @@ -94,8 +99,13 @@ final class NavigationViewGestureController { navigationController?.maximumSwipeAnimationDuration ?? 0.4 } + var isTrackingSwipeGesture: Bool { + swipeProgressTracker.isActive + } + func beginSwipeGesture(direction: NavigationStackDirection) -> Bool { - navigationController?.beginSwipeGesture(direction: direction) ?? false + let didBegin = navigationController?.beginSwipeGesture(direction: direction) ?? false + return didBegin } func handleSwipeGesture(progress: CGFloat) { @@ -110,6 +120,10 @@ final class NavigationViewGestureController { navigationController.endSwipeGesture(committed: committed, duration: duration, completion: completion) } + + func finishActiveSwipeGesture(cancelled: Bool) -> Bool { + return swipeProgressTracker.finishActiveSwipeGesture(cancelled: cancelled) + } } struct NavigationSwipeStartClassifier { @@ -174,6 +188,21 @@ private final class NavigationSwipeProgressTracker { state != .none } + func finishActiveSwipeGesture(cancelled: Bool) -> Bool { + switch state { + case .none: + return false + case .pending: + reset() + return true + case .swiping: + finishSwipe(forcedCancelled: cancelled) + return true + case .animating: + return true + } + } + func handleScrollWheel(_ event: NSEvent) -> Bool { guard let viewGestureController else { return false @@ -183,7 +212,7 @@ private final class NavigationSwipeProgressTracker { return true } - if event.phase.contains(.mayBegin) || event.phase.contains(.began) { + if event.phase.contains(.mayBegin) || event.phase.contains(.began) || (state == .none && event.phase.contains(.changed) && event.momentumPhase.isEmpty) { reset() state = .pending } @@ -204,13 +233,21 @@ private final class NavigationSwipeProgressTracker { if state == .pending { switch classifier.decision(deltaX: cumulativeDeltaX, deltaY: cumulativeDeltaY) { case .pending: - return false + if !event.momentumPhase.isEmpty { + reset() + return false + } + return true case .cancel: reset() return false case .start: - guard let direction = viewGestureController.navigationDirection(forSwipeGestureAmount: cumulativeDeltaX), - viewGestureController.beginSwipeGesture(direction: direction) else { + guard let direction = viewGestureController.navigationDirection(forSwipeGestureAmount: cumulativeDeltaX) else { + reset() + return false + } + + guard viewGestureController.beginSwipeGesture(direction: direction) else { reset() return false } @@ -228,6 +265,11 @@ private final class NavigationSwipeProgressTracker { let progress = min(max(signedDistance / viewGestureController.totalSwipeDistance, 0), 1) updateProgress(progress) viewGestureController.handleSwipeGesture(progress: progress) + + if !event.momentumPhase.isEmpty { + finishSwipe(forcedCancelled: event.momentumPhase.contains(.cancelled)) + } + return true } @@ -254,24 +296,15 @@ private final class NavigationSwipeProgressTracker { return } - let cancelled = forceCancelled || shouldCancel() + let projectedProgress = progress + averageVelocity * viewGestureController.kineticProjectionDuration + let cancelled = forceCancelled || projectedProgress < viewGestureController.completionThreshold let duration = animationDuration(cancelled: cancelled) state = .animating - viewGestureController.endSwipeGesture(committed: !cancelled, duration: duration) { [weak self] in self?.reset() } } - private func shouldCancel() -> Bool { - guard let viewGestureController else { - return true - } - - let projectedProgress = progress + averageVelocity * viewGestureController.kineticProjectionDuration - return projectedProgress < viewGestureController.completionThreshold - } - private func animationDuration(cancelled: Bool) -> TimeInterval { guard let viewGestureController else { return 0.2 diff --git a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift index 097a687..1266db3 100644 --- a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift +++ b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift @@ -136,6 +136,51 @@ import Testing #expect(navigationController.topViewController === firstViewController) } +@MainActor +@Test func animatedBackKeepsOutgoingVisibleUntilAnimationCompletes() async throws { + let rootViewController = TestViewController() + let firstViewController = TestViewController() + let secondViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.transitionDuration = 0.05 + navigationController.pushViewController(firstViewController, animated: false) + navigationController.pushViewController(secondViewController, animated: false) + + #expect(navigationController.goBack(animated: true) === secondViewController) + #expect(navigationController.topViewController === firstViewController) + #expect(navigationController.visibleViewController === secondViewController) + + try await Task.sleep(nanoseconds: 150_000_000) + + #expect(navigationController.topViewController === firstViewController) + #expect(navigationController.visibleViewController === firstViewController) +} + +@MainActor +@Test func animatedPushKeepsOutgoingVisibleUntilAnimationCompletes() async throws { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.transitionDuration = 0.05 + + navigationController.pushViewController(pushedViewController, animated: true) + navigationController.view.layoutSubtreeIfNeeded() + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.visibleViewController === rootViewController) + + try await Task.sleep(nanoseconds: 150_000_000) + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.visibleViewController === pushedViewController) +} + @MainActor @Test func animatedForwardUpdatesHistoryBeforeWillShow() { let rootViewController = TestViewController() @@ -296,9 +341,93 @@ import Testing #expect(isApproximatelyEqual(toViewController.view.frame.origin.x, 37.5)) } +@MainActor +@Test func backTransitionDoesNotExposeContainerBackgroundNearCompletion() { + let fromViewController = TestViewController() + let toViewController = TestViewController() + let transition = NavigationStackController.Transition( + from: fromViewController, + to: toViewController, + direction: .back, + operation: .back + ) + + transition.apply( + progress: 0.95, + in: NSRect(x: 0, y: 0, width: 200, height: 100), + parallaxFactor: 0.25, + layoutDirection: .leftToRight, + animated: false + ) + + #expect(toViewController.view.frame.maxX >= fromViewController.view.frame.minX) +} + +@MainActor +@Test func backTransitionInstallsBackdropBehindParallaxedIncomingView() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 200, height: 100) + navigationController.pushViewController(pushedViewController, animated: false) + + #expect(navigationController.beginSwipeGesture(direction: .back)) + defer { + navigationController.endSwipeGesture(committed: false, duration: 0) { } + } + + let subviews = navigationController.view.subviews + #expect(subviews.count == 3) + #expect(subviews[0] is NavigationTransitionBackdropView) + let incomingTransitionView = subviews[1] as? NavigationTransitionContentView + let outgoingTransitionView = subviews[2] as? NavigationTransitionContentView + #expect(incomingTransitionView?.contentView === rootViewController.view) + #expect(outgoingTransitionView?.contentView === pushedViewController.view) + #expect(incomingTransitionView?.layer?.masksToBounds == true) + #expect(outgoingTransitionView?.layer?.masksToBounds == true) + #expect(isApproximatelyEqual(incomingTransitionView?.frame.origin.x ?? 0, -navigationController.parallaxFactor * 200)) + #expect(isApproximatelyEqual(rootViewController.view.frame.origin.x, 0)) +} + +@MainActor +@Test func backTransitionAnimatesHorizontalScrollViewThroughTransitionHost() { + let rootViewController = HorizontalScrollRootViewController() + let pushedViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 200, height: 100) + navigationController.pushViewController(pushedViewController, animated: false) + + #expect(navigationController.beginSwipeGesture(direction: .back)) + defer { + navigationController.endSwipeGesture(committed: false, duration: 0) { } + } + + navigationController.handleSwipeGesture(progress: 0.5) + + let subviews = navigationController.view.subviews + #expect(subviews.count == 3) + let incomingTransitionView = subviews[1] as? NavigationTransitionContentView + let outgoingTransitionView = subviews[2] as? NavigationTransitionContentView + #expect(incomingTransitionView?.contentView === rootViewController.view) + #expect(outgoingTransitionView?.contentView === pushedViewController.view) + #expect(rootViewController.view is NSScrollView) + #expect(pushedViewController.view is NSScrollView) + #expect(incomingTransitionView?.layer?.masksToBounds == true) + #expect(outgoingTransitionView?.layer?.masksToBounds == true) + #expect(isApproximatelyEqual(incomingTransitionView?.frame.origin.x ?? 0, -navigationController.parallaxFactor * 100)) + #expect(isApproximatelyEqual(outgoingTransitionView?.frame.origin.x ?? 0, 100)) + #expect(isApproximatelyEqual(rootViewController.view.frame.origin.x, 0)) + #expect(isApproximatelyEqual(pushedViewController.view.frame.origin.x, 0)) +} + @Test func swipeStartClassifierRequiresHorizontalThresholdAndHysteresis() { let classifier = NavigationSwipeStartClassifier(minimumHorizontalDistance: 10, verticalHysteresis: 1.2) + #expect(classifier.decision(deltaX: 4, deltaY: 2) == .pending) #expect(classifier.decision(deltaX: 9, deltaY: 0) == .pending) #expect(classifier.decision(deltaX: 12, deltaY: 11) == .pending) #expect(classifier.decision(deltaX: 12, deltaY: 4) == .start) @@ -319,12 +448,347 @@ import Testing #expect(isForward(viewGestureController.navigationDirection(forSwipeGestureAmount: -0.1))) } +@MainActor +@Test func horizontalScrollConflictResolverDefersOnlyWhenScrollViewCanMoveInSwipeDirection() { + let scrollView = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) + + #expect(!NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: 20)) + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: -20)) + + scrollView.contentView.scroll(to: NSPoint(x: 100, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: 20)) + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: -20)) + + scrollView.contentView.scroll(to: NSPoint(x: 200, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: 20)) + #expect(!NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: -20)) +} + +@MainActor +@Test func horizontalScrollConflictResolverFindsScrollableViewAtEventLocation() { + let containerView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let scrollView = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) + scrollView.frame.origin = NSPoint(x: 20, y: 10) + containerView.addSubview(scrollView) + + let hitView = containerView.hitTest(NSPoint(x: 50, y: 40)) + + #expect(!NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 20)) + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: -20)) +} + +@MainActor +@Test func rootScrollViewCanForwardSwipeTrackingThroughNavigationControllerResponder() { + let rootViewController = TestViewController() + let scrollRootViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.pushViewController(scrollRootViewController, animated: false) + + #expect(navigationController.wantsScrollEventsForSwipeTracking(on: .horizontal)) + #expect(navigationController.wantsForwardedScrollEvents(for: .horizontal)) + #expect(!navigationController.wantsScrollEventsForSwipeTracking(on: .vertical)) + #expect(!navigationController.wantsForwardedScrollEvents(for: .vertical)) + + let trackingResponder = firstSwipeTrackingResponder(startingAt: scrollRootViewController.view, axis: .horizontal) + let isNavigationTrackingResponder = trackingResponder === navigationController.view || trackingResponder === navigationController + #expect(isNavigationTrackingResponder) +} + +@MainActor +@Test func enclosingScrollViewCanAskNavigationContainerToTryWebKitStyleSwipe() { + let rootViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + let enclosingScrollView = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) + let tryToSwipeSelector = #selector(NavigationStackController._tryToSwipe(with:ignoringPinnedState:)) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + enclosingScrollView.documentView = navigationController.view + + #expect(navigationController.view.enclosingScrollView === enclosingScrollView) + #expect(navigationController.view.responds(to: tryToSwipeSelector)) + #expect(navigationController.responds(to: tryToSwipeSelector)) +} + +@MainActor +@Test func pendingSwipeConsumesSmallBeganEventAtHorizontalEdge() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.pushViewController(pushedViewController, animated: false) + + let didHandle = navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began), + ignoringPinnedState: false + ) + + #expect(didHandle) + #expect(navigationController.isGestureEventMonitorInstalled) + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.visibleViewController === pushedViewController) + + #expect(navigationController.finishActiveSwipeGesture(cancelled: false)) + #expect(!navigationController.isGestureEventMonitorInstalled) +} + +@MainActor +@Test func webKitStyleTryToSwipeCanCommitBackNavigationAfterSmallBeganEvent() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began), + ignoringPinnedState: false + )) + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 220, phase: .changed), + ignoringPinnedState: false + )) + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 0, momentumPhase: .began), + ignoringPinnedState: false + )) + + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + #expect(navigationController.topViewController === rootViewController) + #expect(navigationController.forwardViewControllers.last === pushedViewController) +} + +@MainActor +@Test func pendingSwipeLocalMonitorCapturesNestedScrollViewContinuation() { + let rootViewController = TestViewController() + let scrollRootViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(scrollRootViewController, animated: false) + + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began), + ignoringPinnedState: false + )) + #expect(navigationController.isGestureEventMonitorInstalled) + + #expect(navigationController.handleLocalGestureMonitorEvent( + makeScrollWheelEvent(deltaX: 220, phase: .changed), + requiringMatchingWindow: false + )) + #expect(navigationController.topViewController === scrollRootViewController) + + #expect(navigationController.handleLocalGestureMonitorEvent( + makeScrollWheelEvent(deltaX: 0, momentumPhase: .began), + requiringMatchingWindow: false + )) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + #expect(navigationController.topViewController === rootViewController) + #expect(navigationController.forwardViewControllers.last === scrollRootViewController) + #expect(!navigationController.isGestureEventMonitorInstalled) +} + +@MainActor +@Test func touchEndFinishesSwipeWhenScrollWheelTerminalEventIsMissing() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let nextViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began), + ignoringPinnedState: false + )) + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 118, phase: .changed), + ignoringPinnedState: false + )) + + #expect(navigationController.finishActiveSwipeGesture(cancelled: false)) + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.forwardViewControllers.isEmpty) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === nextViewController) +} + +@MainActor +@Test func activeSwipeLocalMonitorConsumesScrollWheelFromNestedHorizontalScrollView() { + let rootViewController = TestViewController() + let scrollRootViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(scrollRootViewController, animated: false) + + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began), + ignoringPinnedState: true + )) + #expect(navigationController._tryToSwipe( + with: makeScrollWheelEvent(deltaX: 118, phase: .changed), + ignoringPinnedState: true + )) + + let scrollView = scrollRootViewController.view as! NSScrollView + scrollView.contentView.scroll(to: NSPoint(x: 100, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(scrollView, deltaX: 20)) + #expect(navigationController.handleLocalGestureMonitorEvent( + makeScrollWheelEvent(deltaX: 20, phase: .changed), + requiringMatchingWindow: false + )) + #expect(navigationController.topViewController === scrollRootViewController) + #expect(navigationController.visibleViewController === scrollRootViewController) + + #expect(navigationController.finishActiveSwipeGesture(cancelled: true)) + #expect(navigationController.topViewController === scrollRootViewController) +} + +@MainActor +@Test func changedPhaseSwipeCanStartAndMomentumCanFinishBackNavigation() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let nextViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 220, phase: .changed)) + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 60, momentumPhase: .began)) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + #expect(navigationController.topViewController === rootViewController) + #expect(navigationController.forwardViewControllers.last === pushedViewController) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === nextViewController) +} + +@MainActor +@Test func cancelledChangedPhaseSwipeRestoresCurrentPageAndAllowsPush() { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let nextViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 120, phase: .changed)) + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 0, phase: .cancelled)) + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.forwardViewControllers.isEmpty) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === nextViewController) +} + +@MainActor +@Test func changedPhaseSwipeWaitsForTerminalEventBeforeCancellingAndAllowsPush() async throws { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let nextViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 120, phase: .changed)) + try await Task.sleep(nanoseconds: 350_000_000) + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.forwardViewControllers.isEmpty) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === pushedViewController) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 0, phase: .cancelled)) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === nextViewController) +} + +@MainActor +@Test func changedPhaseSwipeWaitsForMomentumBeforeCompletingAndAllowsPush() async throws { + let rootViewController = TestViewController() + let pushedViewController = TestViewController() + let nextViewController = TestViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(pushedViewController, animated: false) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 220, phase: .changed)) + try await Task.sleep(nanoseconds: 350_000_000) + + #expect(navigationController.topViewController === pushedViewController) + #expect(navigationController.forwardViewControllers.isEmpty) + + navigationController.view.scrollWheel(with: makeScrollWheelEvent(deltaX: 60, momentumPhase: .began)) + + #expect(navigationController.topViewController === rootViewController) + #expect(navigationController.forwardViewControllers.last === pushedViewController) + + navigationController.pushViewController(nextViewController, animated: false) + #expect(navigationController.topViewController === nextViewController) +} + private final class TestViewController: NSViewController { override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) } } +private final class HorizontalScrollRootViewController: NSViewController { + override func loadView() { + view = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) + } +} + private final class ReentrantNavigationDelegate: NavigationStackControllerDelegate { var onWillShow: ((NavigationStackController) -> Void)? @@ -337,6 +801,31 @@ private func identifiers(_ viewControllers: [NSViewController]) -> [ObjectIdenti viewControllers.map(ObjectIdentifier.init) } +@MainActor +private func firstSwipeTrackingResponder(startingAt responder: NSResponder, axis: NSEvent.GestureAxis) -> NSResponder? { + var currentResponder: NSResponder? = responder + + while let responder = currentResponder { + if responder.wantsScrollEventsForSwipeTracking(on: axis) { + return responder + } + + currentResponder = unsafe responder.nextResponder + } + + return nil +} + +@MainActor +private func makeHorizontalTestScrollView(visibleWidth: CGFloat, documentWidth: CGFloat) -> NSScrollView { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: visibleWidth, height: 80)) + scrollView.hasHorizontalScroller = true + scrollView.hasVerticalScroller = false + scrollView.documentView = NSView(frame: NSRect(x: 0, y: 0, width: documentWidth, height: 80)) + scrollView.layoutSubtreeIfNeeded() + return scrollView +} + private func isApproximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat, tolerance: CGFloat = 0.001) -> Bool { abs(lhs - rhs) <= tolerance } @@ -364,3 +853,35 @@ private func isForward(_ direction: NavigationStackDirection?) -> Bool { return false } + +private func makeScrollWheelEvent(deltaX: Int32, deltaY: Int32 = 0, phase: NSEvent.Phase = [], momentumPhase: NSEvent.Phase = []) -> NSEvent { + let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: deltaY, wheel2: deltaX, wheel3: 0)! + event.setIntegerValueField(.scrollWheelEventIsContinuous, value: 1) + event.setIntegerValueField(.scrollWheelEventScrollPhase, value: cgScrollPhaseValue(for: phase)) + event.setIntegerValueField(.scrollWheelEventMomentumPhase, value: cgScrollPhaseValue(for: momentumPhase)) + return NSEvent(cgEvent: event)! +} + +private func cgScrollPhaseValue(for phase: NSEvent.Phase) -> Int64 { + if phase.contains(.mayBegin) { + return 128 + } + + if phase.contains(.began) { + return 1 + } + + if phase.contains(.changed) { + return 2 + } + + if phase.contains(.ended) { + return 4 + } + + if phase.contains(.cancelled) { + return 8 + } + + return 0 +} diff --git a/Tools/MiniApp/MiniApp/ContentView.swift b/Tools/MiniApp/MiniApp/ContentView.swift index 530d28e..d1a3f88 100644 --- a/Tools/MiniApp/MiniApp/ContentView.swift +++ b/Tools/MiniApp/MiniApp/ContentView.swift @@ -9,16 +9,46 @@ import AppKit import SwiftUI import NavigationStackController +private enum DemoPageContentStyle: Int { + case centeredTitle + case horizontalScroll + + var segmentLabel: String { + switch self { + case .centeredTitle: + "Normal" + case .horizontalScroll: + "Scroll" + } + } +} + struct ContentView: View { + private let contentStyle: DemoPageContentStyle + private let initialPageCount: Int + + init() { + contentStyle = .centeredTitle + initialPageCount = 1 + } + + fileprivate init(contentStyle: DemoPageContentStyle, initialPageCount: Int = 1) { + self.contentStyle = contentStyle + self.initialPageCount = max(initialPageCount, 1) + } + var body: some View { - DemoSplitNavigationView() + DemoSplitNavigationView(contentStyle: contentStyle, initialPageCount: initialPageCount) .ignoresSafeArea() } } private struct DemoSplitNavigationView: NSViewControllerRepresentable { + let contentStyle: DemoPageContentStyle + let initialPageCount: Int + func makeNSViewController(context: Context) -> DemoSplitViewController { - DemoSplitViewController() + DemoSplitViewController(contentStyle: contentStyle, initialPageCount: initialPageCount) } func updateNSViewController(_ nsViewController: DemoSplitViewController, context: Context) { @@ -32,11 +62,25 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel static let splitTrackingSeparator = NSToolbarItem.Identifier("DemoToolbar.SplitTrackingSeparator") static let rightBack = NSToolbarItem.Identifier("DemoToolbar.RightBack") static let rightForward = NSToolbarItem.Identifier("DemoToolbar.RightForward") + static let contentStyle = NSToolbarItem.Identifier("DemoToolbar.ContentStyle") } private let navigationToolbar = NSToolbar(identifier: "DemoNavigationToolbar") - private let leftPane = DemoNavigationHostController() - private let rightPane = DemoNavigationHostController() + private let leftPane: DemoNavigationHostController + private let rightPane: DemoNavigationHostController + private var contentStyle: DemoPageContentStyle + private weak var contentStyleControl: NSSegmentedControl? + + init(contentStyle: DemoPageContentStyle = .centeredTitle, initialPageCount: Int = 1) { + self.contentStyle = contentStyle + leftPane = DemoNavigationHostController(contentStyle: contentStyle, initialPageCount: initialPageCount) + rightPane = DemoNavigationHostController(contentStyle: contentStyle, initialPageCount: initialPageCount) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + nil + } override func viewDidLoad() { super.viewDidLoad() @@ -81,6 +125,8 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel ToolbarItem.splitTrackingSeparator, ToolbarItem.rightBack, ToolbarItem.rightForward, + .flexibleSpace, + ToolbarItem.contentStyle, ] } @@ -91,6 +137,8 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel ToolbarItem.splitTrackingSeparator, ToolbarItem.rightBack, ToolbarItem.rightForward, + .flexibleSpace, + ToolbarItem.contentStyle, ] } @@ -106,6 +154,8 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel return makeToolbarItem(identifier: itemIdentifier, label: "Right Back", symbolName: "chevron.left", action: #selector(goBackInRightPane)) case ToolbarItem.rightForward: return makeToolbarItem(identifier: itemIdentifier, label: "Right Forward", symbolName: "chevron.right", action: #selector(goForwardInRightPane)) + case ToolbarItem.contentStyle: + return makeContentStyleToolbarItem(identifier: itemIdentifier) default: return nil } @@ -137,6 +187,24 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel return item } + private func makeContentStyleToolbarItem(identifier: NSToolbarItem.Identifier) -> NSToolbarItem { + let labels = [DemoPageContentStyle.centeredTitle.segmentLabel, DemoPageContentStyle.horizontalScroll.segmentLabel] + let control = NSSegmentedControl(labels: labels, trackingMode: .selectOne, target: self, action: #selector(contentStyleDidChange)) + control.controlSize = .small + control.selectedSegment = contentStyle.rawValue + control.setWidth(70, forSegment: DemoPageContentStyle.centeredTitle.rawValue) + control.setWidth(64, forSegment: DemoPageContentStyle.horizontalScroll.rawValue) + contentStyleControl = control + + let item = NSToolbarItem(itemIdentifier: identifier) + item.label = "Content Style" + item.paletteLabel = "Content Style" + item.toolTip = "Content Style" + item.view = control + item.visibilityPriority = .high + return item + } + @objc private func goBackInLeftPane() { leftPane.goBack() } @@ -152,13 +220,37 @@ private final class DemoSplitViewController: NSSplitViewController, NSToolbarDel @objc private func goForwardInRightPane() { rightPane.goForward() } + + @objc private func contentStyleDidChange(_ sender: NSSegmentedControl) { + guard let nextContentStyle = DemoPageContentStyle(rawValue: sender.selectedSegment), nextContentStyle != contentStyle else { + return + } + + contentStyle = nextContentStyle + leftPane.setContentStyle(nextContentStyle) + rightPane.setContentStyle(nextContentStyle) + contentStyleControl?.selectedSegment = nextContentStyle.rawValue + navigationToolbar.validateVisibleItems() + } } private final class DemoNavigationHostController: NSViewController, NavigationStackControllerDelegate { var onNavigationStateChanged: (() -> Void)? + private var contentStyle: DemoPageContentStyle + private let initialPageCount: Int private var navigationController: NavigationStackController! + init(contentStyle: DemoPageContentStyle = .centeredTitle, initialPageCount: Int = 1) { + self.contentStyle = contentStyle + self.initialPageCount = max(initialPageCount, 1) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + nil + } + var canGoBack: Bool { navigationController?.canGoBack ?? false } @@ -177,8 +269,9 @@ private final class DemoNavigationHostController: NSViewController, NavigationSt override func viewDidLoad() { super.viewDidLoad() - let rootViewController = makePageViewController(number: 1) - navigationController = NavigationStackController(rootViewController: rootViewController) + let initialViewControllers = (1...initialPageCount).map { makePageViewController(number: $0) } + navigationController = NavigationStackController() + navigationController.setViewControllers(initialViewControllers, animated: false) navigationController.delegate = self navigationController.transitionDuration = 0.22 navigationController.maximumSwipeCompletionThreshold = 0.42 @@ -202,14 +295,31 @@ private final class DemoNavigationHostController: NSViewController, NavigationSt } private func makePageViewController(number: Int) -> DemoPageViewController { - let viewController = DemoPageViewController(page: DemoPage(number: number)) + let viewController = DemoPageViewController(page: DemoPage(number: number), contentStyle: contentStyle) viewController.onPush = { [weak self] in self?.pushPage() } return viewController } - private func pushPage() { + func setContentStyle(_ contentStyle: DemoPageContentStyle) { + guard self.contentStyle != contentStyle else { + return + } + + self.contentStyle = contentStyle + + guard let navigationController else { + return + } + + let pageCount = max(navigationController.viewControllers.count, 1) + let viewControllers = (1...pageCount).map { makePageViewController(number: $0) } + navigationController.setViewControllers(viewControllers, animated: false) + onNavigationStateChanged?() + } + + func pushPage() { let pageNumber = navigationController.viewControllers.count + 1 navigationController.pushViewController(makePageViewController(number: pageNumber), animated: true) onNavigationStateChanged?() @@ -252,11 +362,12 @@ private struct DemoPage { private final class DemoPageViewController: NSViewController { var onPush: (() -> Void)? - private let page: DemoPage + private let contentStyle: DemoPageContentStyle - init(page: DemoPage) { + init(page: DemoPage, contentStyle: DemoPageContentStyle) { self.page = page + self.contentStyle = contentStyle super.init(nibName: nil, bundle: nil) } @@ -265,14 +376,25 @@ private final class DemoPageViewController: NSViewController { } override func loadView() { + view = makeRootView() + } + + private func makeRootView() -> NSView { + switch contentStyle { + case .centeredTitle: + return makeCenteredTitleRootView() + case .horizontalScroll: + return makeHorizontalScrollRootView() + } + } + + private func makeCenteredTitleRootView() -> NSView { let view = DemoClickablePageView() view.onClick = { [weak self] in self?.onPush?() } view.wantsLayer = true view.layer?.backgroundColor = page.backgroundColor.cgColor - self.view = view - let titleLabel = makeLabel(text: page.title, font: NSFont.systemFont(ofSize: 46, weight: .bold), alpha: 1.0) let addressLabel = makeLabel(text: page.address, font: NSFont.monospacedSystemFont(ofSize: 13, weight: .medium), alpha: 0.72) let titleStack = NSStackView(views: [titleLabel, addressLabel]) @@ -289,6 +411,89 @@ private final class DemoPageViewController: NSViewController { titleStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36), titleStack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36), ]) + + return view + } + + private func makeHorizontalScrollRootView() -> NSView { + let scrollView = DemoHorizontalScrollView() + scrollView.pageBackgroundColor = page.backgroundColor + scrollView.drawsBackground = true + scrollView.backgroundColor = page.backgroundColor + scrollView.borderType = .noBorder + scrollView.hasHorizontalScroller = true + scrollView.hasVerticalScroller = false + scrollView.autohidesScrollers = false + scrollView.horizontalScrollElasticity = .allowed + scrollView.verticalScrollElasticity = .none + scrollView.documentView = makeHorizontalScrollDocumentView() + return scrollView + } + + private func makeHorizontalScrollDocumentView() -> NSView { + let contentTop: CGFloat = 112 + let documentView = DemoHorizontalScrollDocumentView(frame: NSRect(x: 0, y: 0, width: 1680, height: 520)) + documentView.pageBackgroundColor = page.backgroundColor + documentView.onClick = { [weak self] in + self?.onPush?() + } + documentView.wantsLayer = true + documentView.layer?.backgroundColor = page.backgroundColor.cgColor + + let titleLabel = makeLabel(text: page.title, font: NSFont.systemFont(ofSize: 28, weight: .bold), alpha: 1.0) + titleLabel.alignment = .left + titleLabel.frame = NSRect(x: 28, y: contentTop, width: 320, height: 34) + + let addressLabel = makeLabel(text: page.address, font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), alpha: 0.72) + addressLabel.alignment = .left + addressLabel.frame = NSRect(x: 28, y: contentTop + 38, width: 360, height: 18) + + documentView.addSubview(titleLabel) + documentView.addSubview(addressLabel) + + for index in 0..<12 { + documentView.addSubview(makeScrollTile(index: index)) + } + + return documentView + } + + private func makeScrollTile(index: Int) -> NSView { + let tileWidth: CGFloat = 120 + let tileSpacing: CGFloat = 16 + let x = 24 + CGFloat(index) * (tileWidth + tileSpacing) + let tile = NSView(frame: NSRect(x: x, y: 204, width: tileWidth, height: 184)) + tile.wantsLayer = true + tile.layer?.cornerRadius = 10 + tile.layer?.backgroundColor = scrollTileColor(index: index).cgColor + + let label = NSTextField(labelWithString: "Item \(index + 1)") + label.font = NSFont.systemFont(ofSize: 16, weight: .semibold) + label.textColor = .white + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + tile.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: tile.centerXAnchor), + label.centerYAnchor.constraint(equalTo: tile.centerYAnchor), + label.leadingAnchor.constraint(greaterThanOrEqualTo: tile.leadingAnchor, constant: 8), + label.trailingAnchor.constraint(lessThanOrEqualTo: tile.trailingAnchor, constant: -8), + ]) + + return tile + } + + private func scrollTileColor(index: Int) -> NSColor { + let palette: [NSColor] = [ + NSColor(calibratedRed: 0.10, green: 0.34, blue: 0.45, alpha: 1.0), + NSColor(calibratedRed: 0.55, green: 0.20, blue: 0.28, alpha: 1.0), + NSColor(calibratedRed: 0.18, green: 0.42, blue: 0.24, alpha: 1.0), + NSColor(calibratedRed: 0.40, green: 0.28, blue: 0.58, alpha: 1.0), + ] + + return palette[(index + page.number) % palette.count] } private func makeLabel(text: String, font: NSFont, alpha: CGFloat) -> NSTextField { @@ -299,6 +504,7 @@ private final class DemoPageViewController: NSViewController { textField.lineBreakMode = .byTruncatingTail return textField } + } private final class DemoClickablePageView: NSView { @@ -317,6 +523,120 @@ private final class DemoClickablePageView: NSView { } } -#Preview { - ContentView() +private final class DemoHorizontalScrollView: NSScrollView { + private let minimumDocumentWidth: CGFloat = 1680 + var pageBackgroundColor: NSColor = .clear { + didSet { + updateBackground() + } + } + + override var isOpaque: Bool { + true + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateBackground() + } + + override func layout() { + super.layout() + + guard let documentView else { + return + } + + var documentFrame = documentView.frame + documentFrame.size.width = max(minimumDocumentWidth, contentView.bounds.width + 1) + documentFrame.size.height = max(contentView.bounds.height, 1) + + if documentView.frame.size != documentFrame.size { + documentView.frame = documentFrame + } + } + + override func draw(_ dirtyRect: NSRect) { + pageBackgroundColor.setFill() + dirtyRect.fill() + super.draw(dirtyRect) + } + + private func updateBackground() { + drawsBackground = true + backgroundColor = pageBackgroundColor + wantsLayer = true + layer?.backgroundColor = pageBackgroundColor.cgColor + contentView.drawsBackground = true + contentView.backgroundColor = pageBackgroundColor + contentView.wantsLayer = true + contentView.layer?.backgroundColor = pageBackgroundColor.cgColor + needsDisplay = true + contentView.needsDisplay = true + } +} + +private final class DemoHorizontalScrollDocumentView: NSView { + var onClick: (() -> Void)? + var pageBackgroundColor: NSColor = .clear { + didSet { + layer?.backgroundColor = pageBackgroundColor.cgColor + needsDisplay = true + } + } + private var mouseDownLocation: NSPoint? + + override var isFlipped: Bool { + true + } + + override var isOpaque: Bool { + true + } + + override func draw(_ dirtyRect: NSRect) { + pageBackgroundColor.setFill() + dirtyRect.fill() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard super.hitTest(point) != nil else { + return nil + } + + return self + } + + override func mouseDown(with event: NSEvent) { + mouseDownLocation = convert(event.locationInWindow, from: nil) + super.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + defer { + mouseDownLocation = nil + } + + guard let mouseDownLocation else { + super.mouseUp(with: event) + return + } + + let mouseUpLocation = convert(event.locationInWindow, from: nil) + let distance = hypot(mouseUpLocation.x - mouseDownLocation.x, mouseUpLocation.y - mouseDownLocation.y) + if distance <= 3 { + onClick?() + return + } + + super.mouseUp(with: event) + } +} + +#Preview("Split Navigation") { + ContentView(contentStyle: .centeredTitle) +} + +#Preview("Split Navigation Horizontal Scroll") { + ContentView(contentStyle: .horizontalScroll) } From 432ba251949c7fd537f0cb28aaf21825c3ba241e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 14 May 2026 16:57:41 +0900 Subject: [PATCH 2/5] refactor(navigation): rename forwarded swipe selector --- .../NavigationStackController.swift | 14 +++--- .../NavigationStackControllerTests.swift | 46 +++++++++---------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/Sources/NavigationStackController/NavigationStackController.swift b/Sources/NavigationStackController/NavigationStackController.swift index 8bd9d33..b88b20d 100644 --- a/Sources/NavigationStackController/NavigationStackController.swift +++ b/Sources/NavigationStackController/NavigationStackController.swift @@ -210,10 +210,9 @@ public final class NavigationStackController: NSViewController { return wants } - @objc(_tryToSwipeWithEvent:ignoringPinnedState:) - func _tryToSwipe(with event: NSEvent, ignoringPinnedState: Bool) -> Bool { - let handled = handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringPinnedState) - return handled + @objc(navigationStackControllerHandleSwipeEvent:ignoringHorizontalScrollViews:) + func handleForwardedSwipe(with event: NSEvent, ignoringHorizontalScrollViews: Bool) -> Bool { + handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringHorizontalScrollViews) } /// Replaces the current stack with a new set of view controllers. @@ -1021,10 +1020,9 @@ private final class NavigationStackContainerView: NSView { super.touchesCancelled(with: event) } - @objc(_tryToSwipeWithEvent:ignoringPinnedState:) - func _tryToSwipe(with event: NSEvent, ignoringPinnedState: Bool) -> Bool { - let handled = navigationController?.handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringPinnedState) ?? false - return handled + @objc(navigationStackControllerHandleSwipeEvent:ignoringHorizontalScrollViews:) + func handleForwardedSwipe(with event: NSEvent, ignoringHorizontalScrollViews: Bool) -> Bool { + navigationController?.handleScrollWheel(event, ignoringHorizontalScrollViews: ignoringHorizontalScrollViews) ?? false } } diff --git a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift index 1266db3..53751c9 100644 --- a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift +++ b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift @@ -501,19 +501,19 @@ import Testing } @MainActor -@Test func enclosingScrollViewCanAskNavigationContainerToTryWebKitStyleSwipe() { +@Test func enclosingScrollViewCanAskNavigationContainerToHandleSwipeThroughLibrarySelector() { let rootViewController = TestViewController() let navigationController = NavigationStackController(rootViewController: rootViewController) let enclosingScrollView = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) - let tryToSwipeSelector = #selector(NavigationStackController._tryToSwipe(with:ignoringPinnedState:)) + let handleSwipeSelector = #selector(NavigationStackController.handleForwardedSwipe(with:ignoringHorizontalScrollViews:)) _ = navigationController.view navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) enclosingScrollView.documentView = navigationController.view #expect(navigationController.view.enclosingScrollView === enclosingScrollView) - #expect(navigationController.view.responds(to: tryToSwipeSelector)) - #expect(navigationController.responds(to: tryToSwipeSelector)) + #expect(navigationController.view.responds(to: handleSwipeSelector)) + #expect(navigationController.responds(to: handleSwipeSelector)) } @MainActor @@ -526,9 +526,9 @@ import Testing navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) navigationController.pushViewController(pushedViewController, animated: false) - let didHandle = navigationController._tryToSwipe( + let didHandle = navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 1, phase: .began), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false ) #expect(didHandle) @@ -541,7 +541,7 @@ import Testing } @MainActor -@Test func webKitStyleTryToSwipeCanCommitBackNavigationAfterSmallBeganEvent() { +@Test func librarySwipeSelectorCanCommitBackNavigationAfterSmallBeganEvent() { let rootViewController = TestViewController() let pushedViewController = TestViewController() let navigationController = NavigationStackController(rootViewController: rootViewController) @@ -552,17 +552,17 @@ import Testing navigationController.maximumSwipeAnimationDuration = 0 navigationController.pushViewController(pushedViewController, animated: false) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 1, phase: .began), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 220, phase: .changed), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 0, momentumPhase: .began), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) RunLoop.current.run(until: Date().addingTimeInterval(0.01)) @@ -583,9 +583,9 @@ import Testing navigationController.maximumSwipeAnimationDuration = 0 navigationController.pushViewController(scrollRootViewController, animated: false) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 1, phase: .began), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) #expect(navigationController.isGestureEventMonitorInstalled) @@ -619,13 +619,13 @@ import Testing navigationController.maximumSwipeAnimationDuration = 0 navigationController.pushViewController(pushedViewController, animated: false) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 1, phase: .began), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 118, phase: .changed), - ignoringPinnedState: false + ignoringHorizontalScrollViews: false )) #expect(navigationController.finishActiveSwipeGesture(cancelled: false)) @@ -649,13 +649,13 @@ import Testing navigationController.maximumSwipeAnimationDuration = 0 navigationController.pushViewController(scrollRootViewController, animated: false) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 1, phase: .began), - ignoringPinnedState: true + ignoringHorizontalScrollViews: true )) - #expect(navigationController._tryToSwipe( + #expect(navigationController.handleForwardedSwipe( with: makeScrollWheelEvent(deltaX: 118, phase: .changed), - ignoringPinnedState: true + ignoringHorizontalScrollViews: true )) let scrollView = scrollRootViewController.view as! NSScrollView From aab3671b9d4857ec19f5a4c9ffd3d87ca4ffa3b2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 14 May 2026 17:08:31 +0900 Subject: [PATCH 3/5] fix(navigation): defer zero-delta scroll beginnings --- .../NavigationStackController.swift | 19 ++++-- .../NavigationStackControllerTests.swift | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/Sources/NavigationStackController/NavigationStackController.swift b/Sources/NavigationStackController/NavigationStackController.swift index b88b20d..73fc198 100644 --- a/Sources/NavigationStackController/NavigationStackController.swift +++ b/Sources/NavigationStackController/NavigationStackController.swift @@ -908,14 +908,15 @@ extension NavigationStackController { @MainActor struct NavigationHorizontalScrollConflictResolver { static func canScrollHorizontally(from hitView: NSView?, inside boundaryView: NSView, deltaX: CGFloat) -> Bool { - guard deltaX != 0 else { - return false - } - var currentView = hitView while let view = currentView { if let scrollView = view as? NSScrollView { - let canScroll = canScrollHorizontally(scrollView, deltaX: deltaX) + let canScroll = if deltaX == 0 { + canScrollHorizontallyInAnyDirection(scrollView) + } else { + canScrollHorizontally(scrollView, deltaX: deltaX) + } + if canScroll { return true } @@ -931,7 +932,15 @@ struct NavigationHorizontalScrollConflictResolver { return false } + static func canScrollHorizontallyInAnyDirection(_ scrollView: NSScrollView) -> Bool { + canScrollHorizontally(scrollView, deltaX: 1) || canScrollHorizontally(scrollView, deltaX: -1) + } + static func canScrollHorizontally(_ scrollView: NSScrollView, deltaX: CGFloat) -> Bool { + guard deltaX != 0 else { + return false + } + guard let documentView = scrollView.documentView else { return false } diff --git a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift index 53751c9..3fc5c1d 100644 --- a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift +++ b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift @@ -481,6 +481,22 @@ import Testing #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: -20)) } +@MainActor +@Test func horizontalScrollConflictResolverDefersZeroDeltaOverScrollableView() { + let containerView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let scrollView = makeHorizontalTestScrollView(visibleWidth: 100, documentWidth: 300) + containerView.addSubview(scrollView) + + let hitView = containerView.hitTest(NSPoint(x: 50, y: 40)) + + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 0)) + + scrollView.contentView.scroll(to: NSPoint(x: 200, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 0)) +} + @MainActor @Test func rootScrollViewCanForwardSwipeTrackingThroughNavigationControllerResponder() { let rootViewController = TestViewController() @@ -606,6 +622,36 @@ import Testing #expect(!navigationController.isGestureEventMonitorInstalled) } +@MainActor +@Test func zeroDeltaBeginningOverScrollableDescendantDoesNotCapturePendingSwipe() { + let rootViewController = TestViewController() + let scrollRootViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(scrollRootViewController, animated: false) + + let scrollView = scrollRootViewController.view as! NSScrollView + scrollView.contentView.scroll(to: NSPoint(x: 100, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + #expect(!navigationController.handleForwardedSwipe( + with: makeScrollWheelEvent(deltaX: 0, phase: .began, location: NSPoint(x: 50, y: 40)), + ignoringHorizontalScrollViews: false + )) + #expect(!navigationController.isGestureEventMonitorInstalled) + + #expect(!navigationController.handleForwardedSwipe( + with: makeScrollWheelEvent(deltaX: 20, phase: .changed, location: NSPoint(x: 50, y: 40)), + ignoringHorizontalScrollViews: false + )) + #expect(navigationController.topViewController === scrollRootViewController) + #expect(navigationController.forwardViewControllers.isEmpty) +} + @MainActor @Test func touchEndFinishesSwipeWhenScrollWheelTerminalEventIsMissing() { let rootViewController = TestViewController() @@ -854,8 +900,21 @@ private func isForward(_ direction: NavigationStackDirection?) -> Bool { return false } -private func makeScrollWheelEvent(deltaX: Int32, deltaY: Int32 = 0, phase: NSEvent.Phase = [], momentumPhase: NSEvent.Phase = []) -> NSEvent { +private func makeScrollWheelEvent( + deltaX: Int32, + deltaY: Int32 = 0, + phase: NSEvent.Phase = [], + momentumPhase: NSEvent.Phase = [], + location: NSPoint? = nil +) -> NSEvent { let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: deltaY, wheel2: deltaX, wheel3: 0)! + if let location { + if let screenFrame = NSScreen.main?.frame { + event.location = NSPoint(x: screenFrame.minX + location.x, y: screenFrame.maxY - location.y) + } else { + event.location = location + } + } event.setIntegerValueField(.scrollWheelEventIsContinuous, value: 1) event.setIntegerValueField(.scrollWheelEventScrollPhase, value: cgScrollPhaseValue(for: phase)) event.setIntegerValueField(.scrollWheelEventMomentumPhase, value: cgScrollPhaseValue(for: momentumPhase)) From 0344c2ad89e049701f175d978de1271e0a21f423 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 14 May 2026 17:20:39 +0900 Subject: [PATCH 4/5] test(navigation): stabilize synthetic scroll events --- .../NavigationStackControllerTests.swift | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift index 3fc5c1d..896c37b 100644 --- a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift +++ b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift @@ -907,40 +907,60 @@ private func makeScrollWheelEvent( momentumPhase: NSEvent.Phase = [], location: NSPoint? = nil ) -> NSEvent { - let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: deltaY, wheel2: deltaX, wheel3: 0)! - if let location { - if let screenFrame = NSScreen.main?.frame { - event.location = NSPoint(x: screenFrame.minX + location.x, y: screenFrame.maxY - location.y) - } else { - event.location = location - } - } - event.setIntegerValueField(.scrollWheelEventIsContinuous, value: 1) - event.setIntegerValueField(.scrollWheelEventScrollPhase, value: cgScrollPhaseValue(for: phase)) - event.setIntegerValueField(.scrollWheelEventMomentumPhase, value: cgScrollPhaseValue(for: momentumPhase)) - return NSEvent(cgEvent: event)! + TestScrollWheelEvent( + deltaX: CGFloat(deltaX), + deltaY: CGFloat(deltaY), + phase: phase, + momentumPhase: momentumPhase, + location: location ?? .zero + ) } -private func cgScrollPhaseValue(for phase: NSEvent.Phase) -> Int64 { - if phase.contains(.mayBegin) { - return 128 +private final class TestScrollWheelEvent: NSEvent { + private let eventDeltaX: CGFloat + private let eventDeltaY: CGFloat + private let eventPhase: NSEvent.Phase + private let eventMomentumPhase: NSEvent.Phase + private let eventLocation: NSPoint + + init(deltaX: CGFloat, deltaY: CGFloat, phase: NSEvent.Phase, momentumPhase: NSEvent.Phase, location: NSPoint) { + self.eventDeltaX = deltaX + self.eventDeltaY = deltaY + self.eventPhase = phase + self.eventMomentumPhase = momentumPhase + self.eventLocation = location + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - if phase.contains(.began) { - return 1 + override var type: NSEvent.EventType { + .scrollWheel } - if phase.contains(.changed) { - return 2 + override var locationInWindow: NSPoint { + eventLocation } - if phase.contains(.ended) { - return 4 + override var scrollingDeltaX: CGFloat { + eventDeltaX } - if phase.contains(.cancelled) { - return 8 + override var scrollingDeltaY: CGFloat { + eventDeltaY } - return 0 + override var hasPreciseScrollingDeltas: Bool { + true + } + + override var phase: NSEvent.Phase { + eventPhase + } + + override var momentumPhase: NSEvent.Phase { + eventMomentumPhase + } } From 7f3c4fa96a5ae8c42a8b641fd6ec9ec8e1fb4453 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 14 May 2026 17:23:56 +0900 Subject: [PATCH 5/5] fix(navigation): defer pre-threshold scroll beginnings --- .../NavigationStackController.swift | 8 +++-- .../NavigationViewGestureController.swift | 4 ++- .../NavigationStackControllerTests.swift | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Sources/NavigationStackController/NavigationStackController.swift b/Sources/NavigationStackController/NavigationStackController.swift index 73fc198..0b1cedd 100644 --- a/Sources/NavigationStackController/NavigationStackController.swift +++ b/Sources/NavigationStackController/NavigationStackController.swift @@ -422,7 +422,11 @@ public final class NavigationStackController: NSViewController { } func shouldDeferSwipeTrackingToHorizontalScrollView(for event: NSEvent) -> Bool { - let localPoint = containerView.convert(event.locationInWindow, from: nil) + let localPoint = if unsafe containerView.window == nil { + event.locationInWindow + } else { + containerView.convert(event.locationInWindow, from: nil) + } let hitView = containerView.hitTest(localPoint) let shouldDefer = NavigationHorizontalScrollConflictResolver.canScrollHorizontally( @@ -911,7 +915,7 @@ struct NavigationHorizontalScrollConflictResolver { var currentView = hitView while let view = currentView { if let scrollView = view as? NSScrollView { - let canScroll = if deltaX == 0 { + let canScroll = if abs(deltaX) < NavigationSwipeStartClassifier.defaultMinimumHorizontalDistance { canScrollHorizontallyInAnyDirection(scrollView) } else { canScrollHorizontally(scrollView, deltaX: deltaX) diff --git a/Sources/NavigationStackController/NavigationViewGestureController.swift b/Sources/NavigationStackController/NavigationViewGestureController.swift index de6b4e1..3c873b8 100644 --- a/Sources/NavigationStackController/NavigationViewGestureController.swift +++ b/Sources/NavigationStackController/NavigationViewGestureController.swift @@ -133,7 +133,9 @@ struct NavigationSwipeStartClassifier { case cancel } - var minimumHorizontalDistance: CGFloat = 10 + static let defaultMinimumHorizontalDistance: CGFloat = 10 + + var minimumHorizontalDistance: CGFloat = Self.defaultMinimumHorizontalDistance var verticalHysteresis: CGFloat = 1.2 func decision(deltaX: CGFloat, deltaY: CGFloat) -> Decision { diff --git a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift index 896c37b..b7036ab 100644 --- a/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift +++ b/Tests/NavigationStackControllerTests/NavigationStackControllerTests.swift @@ -489,12 +489,14 @@ import Testing let hitView = containerView.hitTest(NSPoint(x: 50, y: 40)) + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 1)) #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 0)) scrollView.contentView.scroll(to: NSPoint(x: 200, y: 0)) scrollView.reflectScrolledClipView(scrollView.contentView) #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: 0)) + #expect(NavigationHorizontalScrollConflictResolver.canScrollHorizontally(from: hitView, inside: containerView, deltaX: -1)) } @MainActor @@ -635,6 +637,8 @@ import Testing navigationController.pushViewController(scrollRootViewController, animated: false) let scrollView = scrollRootViewController.view as! NSScrollView + scrollView.documentView?.frame.size.width = 600 + scrollView.layoutSubtreeIfNeeded() scrollView.contentView.scroll(to: NSPoint(x: 100, y: 0)) scrollView.reflectScrolledClipView(scrollView.contentView) @@ -652,6 +656,36 @@ import Testing #expect(navigationController.forwardViewControllers.isEmpty) } +@MainActor +@Test func preThresholdBeginningOverScrollableDescendantDoesNotCapturePendingSwipe() { + let rootViewController = TestViewController() + let scrollRootViewController = HorizontalScrollRootViewController() + let navigationController = NavigationStackController(rootViewController: rootViewController) + + _ = navigationController.view + navigationController.view.frame = NSRect(x: 0, y: 0, width: 300, height: 80) + navigationController.minimumSwipeAnimationDuration = 0 + navigationController.maximumSwipeAnimationDuration = 0 + navigationController.pushViewController(scrollRootViewController, animated: false) + + let scrollView = scrollRootViewController.view as! NSScrollView + scrollView.documentView?.frame.size.width = 600 + scrollView.layoutSubtreeIfNeeded() + + #expect(!navigationController.handleForwardedSwipe( + with: makeScrollWheelEvent(deltaX: 1, phase: .began, location: NSPoint(x: 50, y: 40)), + ignoringHorizontalScrollViews: false + )) + #expect(!navigationController.isGestureEventMonitorInstalled) + + #expect(!navigationController.handleForwardedSwipe( + with: makeScrollWheelEvent(deltaX: -20, phase: .changed, location: NSPoint(x: 50, y: 40)), + ignoringHorizontalScrollViews: false + )) + #expect(navigationController.topViewController === scrollRootViewController) + #expect(navigationController.forwardViewControllers.isEmpty) +} + @MainActor @Test func touchEndFinishesSwipeWhenScrollWheelTerminalEventIsMissing() { let rootViewController = TestViewController()