From 71a99ce9728e7d8302225ace3509918cdd0aee29 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 18 Oct 2024 15:03:33 -0700 Subject: [PATCH 01/14] Track VCs being popped from stack from UIKit Tapping the back button or interactively popping via gesture can conflict with observation when the view controller being popped to executes a mutation in its `will`- or `didAppear`. While this isn't common in vanilla UIKit, in TCA sending even a no-op action in these lifecycle hooks can lead to immediately re-pushing the view controller that was just popped. This commit does what it can to detect when the back button was pushed or the interactive pop gesture was invoked so that if/when the pop is committed, we can update the path more eagerly, avoiding the re-presentation. --- .../SwiftNavigation/UINavigationPath.swift | 11 +++++- .../NavigationStackController.swift | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNavigation/UINavigationPath.swift b/Sources/SwiftNavigation/UINavigationPath.swift index 14d03bff2..135bb0b55 100644 --- a/Sources/SwiftNavigation/UINavigationPath.swift +++ b/Sources/SwiftNavigation/UINavigationPath.swift @@ -8,7 +8,7 @@ public struct UINavigationPath: Equatable { public var elements: [Element] = [] @_spi(Internals) - public enum Element: Equatable { + public enum Element: Hashable { case eager(AnyHashable) case lazy(Lazy) @@ -52,6 +52,15 @@ public struct UINavigationPath: Equatable { return CodableRepresentation.Element(eager) == lazy } } + + public func hash(into hasher: inout Hasher) { + switch self { + case let .eager(value), let .lazy(.element(value)): + hasher.combine(value) + case let .lazy(.codable(value)): + hasher.combine(value.decode()) + } + } } /// The number of elements in this path. diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 54cc23f48..529b7980b 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -19,6 +19,7 @@ } } } + private var didPop: Set = [] private let pathDelegate = PathDelegate() private var root: UIViewController? @@ -62,6 +63,10 @@ super.delegate = pathDelegate + interactivePopGestureRecognizer?.addTarget( + self, action: #selector(interactivePopGestureRecognizerAction) + ) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { traitOverrides.push = UIPushAction { [weak self] value in self?._push(value: value) @@ -154,6 +159,17 @@ } } + @objc private func interactivePopGestureRecognizerAction( + _ gesture: UIScreenEdgePanGestureRecognizer + ) { + guard + gesture.state == .began, + let last = path.last, + !viewControllers.compactMap(\.navigationID).contains(last) + else { return } + didPop.insert(last) + } + fileprivate func viewController( for navigationID: UINavigationPath.Element ) -> UIViewController? { @@ -310,6 +326,29 @@ } } + extension NavigationStackController: UINavigationBarDelegate { + public func navigationBar( + _ navigationBar: UINavigationBar, shouldPop item: UINavigationItem + ) -> Bool { + if let navigationID = viewControllers + .first(where: { $0.navigationItem == item })? + .navigationID + { + didPop.insert(navigationID) + } + return true + } + + public func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) { + path.removeAll(where: { didPop.contains($0) }) + didPop.removeAll() + } + + public func navigationBar(_ navigationBar: UINavigationBar, didPush item: UINavigationItem) { + didPop.removeAll() + } + } + extension UIViewController { @available(iOS, deprecated: 17, renamed: "traitCollection.push") @available(macOS, deprecated: 14, renamed: "traitCollection.push") From a17e7ed04c9cdc6327fe69d47a6f83ca409fb696 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 18 Oct 2024 17:43:52 -0700 Subject: [PATCH 02/14] wip --- .../NavigationStackController.swift | 28 ++++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 3 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 529b7980b..ad6cf901d 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -63,9 +63,11 @@ super.delegate = pathDelegate - interactivePopGestureRecognizer?.addTarget( - self, action: #selector(interactivePopGestureRecognizerAction) - ) + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + interactivePopGestureRecognizer?.addTarget( + self, action: #selector(interactivePopGestureRecognizerAction) + ) + #endif if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { traitOverrides.push = UIPushAction { [weak self] value in @@ -159,16 +161,16 @@ } } - @objc private func interactivePopGestureRecognizerAction( - _ gesture: UIScreenEdgePanGestureRecognizer - ) { - guard - gesture.state == .began, - let last = path.last, - !viewControllers.compactMap(\.navigationID).contains(last) - else { return } - didPop.insert(last) - } + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + @objc private func interactivePopGestureRecognizerAction(_ gesture: UIGestureRecognizer) { + guard + gesture.state == .began, + let last = path.last, + !viewControllers.compactMap(\.navigationID).contains(last) + else { return } + didPop.insert(last) + } + #endif fileprivate func viewController( for navigationID: UINavigationPath.Element diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 23b807902..e8bbeefb7 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "1aa2cea9c52ab13ad534b6405a1cfcfb2d8073454900d1936a525c0929ceb2f0", "pins" : [ { "identity" : "combine-schedulers", @@ -127,5 +128,5 @@ } } ], - "version" : 2 + "version" : 3 } From 1326f3308679749579bc8218a0214f9c83278778 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 21 Oct 2024 10:36:51 -0700 Subject: [PATCH 03/14] wip --- .../NavigationStackController.swift | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index ad6cf901d..2fdec8414 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -19,11 +19,11 @@ } } } - private var didPop: Set = [] + private var elementsBeingPopped: Set = [] private let pathDelegate = PathDelegate() private var root: UIViewController? - public override weak var delegate: (any UINavigationControllerDelegate)? { + open override weak var delegate: (any UINavigationControllerDelegate)? { get { pathDelegate.base } set { pathDelegate.base = newValue } } @@ -161,6 +161,45 @@ } } + open override func popToRootViewController(animated: Bool) -> [UIViewController]? { + path.removeAll() + return super.popToRootViewController(animated: animated) + } + + open override func popToViewController( + _ viewController: UIViewController, animated: Bool + ) -> [UIViewController]? { + let viewControllers = super.popToViewController(viewController, animated: animated) + if let viewControllers { + for viewController in viewControllers { + if let navigationID = viewController.navigationID { + path.removeAll(where: { $0 == navigationID }) + } + } + } + return viewControllers + } + + open override func popViewController(animated: Bool) -> UIViewController? { + let viewController = super.popViewController(animated: animated) + if let viewController, let navigationID = viewController.navigationID { + switch interactivePopGestureRecognizer?.state { + case .possible?, nil: + path.removeAll(where: { $0 == navigationID }) + case .began, .changed, .ended, .cancelled, .failed: + fallthrough + @unknown default: + break + } + } + return viewController + } + + open override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { + path = viewControllers.compactMap(\.navigationID) + super.setViewControllers(viewControllers, animated: animated) + } + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) @objc private func interactivePopGestureRecognizerAction(_ gesture: UIGestureRecognizer) { guard @@ -168,7 +207,7 @@ let last = path.last, !viewControllers.compactMap(\.navigationID).contains(last) else { return } - didPop.insert(last) + elementsBeingPopped.insert(last) } #endif @@ -336,18 +375,18 @@ .first(where: { $0.navigationItem == item })? .navigationID { - didPop.insert(navigationID) + elementsBeingPopped.insert(navigationID) } return true } public func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) { - path.removeAll(where: { didPop.contains($0) }) - didPop.removeAll() + path.removeAll(where: { elementsBeingPopped.contains($0) }) + elementsBeingPopped.removeAll() } public func navigationBar(_ navigationBar: UINavigationBar, didPush item: UINavigationItem) { - didPop.removeAll() + elementsBeingPopped.removeAll() } } From e54d93c89556f1c71c9bd8bf2c6f21db2b467563 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 21 Oct 2024 11:50:26 -0700 Subject: [PATCH 04/14] wip --- .../Navigation/NavigationStackController.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 2fdec8414..c00d95727 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -183,14 +183,18 @@ open override func popViewController(animated: Bool) -> UIViewController? { let viewController = super.popViewController(animated: animated) if let viewController, let navigationID = viewController.navigationID { - switch interactivePopGestureRecognizer?.state { - case .possible?, nil: + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + switch interactivePopGestureRecognizer?.state { + case .possible?, nil: + path.removeAll(where: { $0 == navigationID }) + case .began, .changed, .ended, .cancelled, .failed: + fallthrough + @unknown default: + break + } + #else path.removeAll(where: { $0 == navigationID }) - case .began, .changed, .ended, .cancelled, .failed: - fallthrough - @unknown default: - break - } + #endif } return viewController } From f1acf046fa17cfc0b810048da0b6fd8283422511 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 21 Oct 2024 13:34:21 -0700 Subject: [PATCH 05/14] wip --- .../NavigationStackController.swift | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index c00d95727..367ca3bab 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -38,7 +38,7 @@ self._path = path.path let root = root() self.root = root - self.viewControllers = [root] + super.viewControllers = [root] } public required init( @@ -51,7 +51,7 @@ self._path = path.elements let root = root() self.root = root - self.viewControllers = [root] + super.viewControllers = [root] } public required init?(coder aDecoder: NSCoder) { @@ -97,9 +97,9 @@ } else if difference.count == 1, case .remove(newPath.count, _, nil) = difference.first { - popViewController(animated: !transaction.uiKit.disablesAnimations) + _popViewController(animated: !transaction.uiKit.disablesAnimations) } else if difference.insertions.isEmpty, newPath.isEmpty { - popToRootViewController(animated: !transaction.uiKit.disablesAnimations) + _popToRootViewController(animated: !transaction.uiKit.disablesAnimations) } else if difference.insertions.isEmpty, case let offsets = difference.removals.map(\.offset), let first = offsets.first, @@ -107,7 +107,7 @@ offsets.elementsEqual(first...last), first == newPath.count { - popToViewController( + _popToViewController( viewControllers[first], animated: !transaction.uiKit.disablesAnimations ) } else { @@ -156,51 +156,77 @@ } } path.remove(atOffsets: invalidIndices) - setViewControllers(newViewControllers, animated: !transaction.uiKit.disablesAnimations) + _setViewControllers( + newViewControllers, animated: !transaction.uiKit.disablesAnimations + ) } } } - open override func popToRootViewController(animated: Bool) -> [UIViewController]? { - path.removeAll() - return super.popToRootViewController(animated: animated) +// @discardableResult +// open override func popToRootViewController(animated: Bool) -> [UIViewController]? { +// path.removeAll() +// return super.popToRootViewController(animated: animated) +// } + + @discardableResult + private func _popToRootViewController(animated: Bool) -> [UIViewController]? { + super.popToRootViewController(animated: animated) } - open override func popToViewController( +// @discardableResult +// open override func popToViewController( +// _ viewController: UIViewController, animated: Bool +// ) -> [UIViewController]? { +// let viewControllers = super.popToViewController(viewController, animated: animated) +// if let viewControllers { +// for viewController in viewControllers { +// if let navigationID = viewController.navigationID { +// path.removeAll(where: { $0 == navigationID }) +// } +// } +// } +// return viewControllers +// } + + @discardableResult + private func _popToViewController( _ viewController: UIViewController, animated: Bool ) -> [UIViewController]? { - let viewControllers = super.popToViewController(viewController, animated: animated) - if let viewControllers { - for viewController in viewControllers { - if let navigationID = viewController.navigationID { - path.removeAll(where: { $0 == navigationID }) - } - } - } - return viewControllers + super.popToViewController(viewController, animated: animated) } - open override func popViewController(animated: Bool) -> UIViewController? { - let viewController = super.popViewController(animated: animated) - if let viewController, let navigationID = viewController.navigationID { - #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) - switch interactivePopGestureRecognizer?.state { - case .possible?, nil: - path.removeAll(where: { $0 == navigationID }) - case .began, .changed, .ended, .cancelled, .failed: - fallthrough - @unknown default: - break - } - #else - path.removeAll(where: { $0 == navigationID }) - #endif - } - return viewController +// @discardableResult +// open override func popViewController(animated: Bool) -> UIViewController? { +// let viewController = super.popViewController(animated: animated) +// if let viewController, let navigationID = viewController.navigationID { +// #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) +// switch interactivePopGestureRecognizer?.state { +// case .possible?, nil: +// path.removeAll(where: { $0 == navigationID }) +// case .began, .changed, .ended, .cancelled, .failed: +// fallthrough +// @unknown default: +// break +// } +// #else +// path.removeAll(where: { $0 == navigationID }) +// #endif +// } +// return viewController +// } + + @discardableResult + private func _popViewController(animated: Bool) -> UIViewController? { + super.popViewController(animated: animated) } - open override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { - path = viewControllers.compactMap(\.navigationID) +// open override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { +// path = viewControllers.compactMap(\.navigationID) +// super.setViewControllers(viewControllers, animated: animated) +// } + + private func _setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { super.setViewControllers(viewControllers, animated: animated) } From 78205bf4575899237c3c3772498cf6bf80522c1c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 21 Oct 2024 13:40:04 -0700 Subject: [PATCH 06/14] wip --- .../UIKitNavigation/Navigation/NavigationStackController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 367ca3bab..090d3c5be 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -411,6 +411,7 @@ } public func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) { + guard !elementsBeingPopped.isEmpty else { return } path.removeAll(where: { elementsBeingPopped.contains($0) }) elementsBeingPopped.removeAll() } From 81d3620785de9fda823961b4d6e560ec3efaa039 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 21 Oct 2024 15:22:46 -0700 Subject: [PATCH 07/14] wip --- .../NavigationStackController.swift | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 090d3c5be..dfae55247 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -38,7 +38,7 @@ self._path = path.path let root = root() self.root = root - super.viewControllers = [root] + self._setViewControllers([root], animated: true) } public required init( @@ -51,7 +51,7 @@ self._path = path.elements let root = root() self.root = root - super.viewControllers = [root] + self._setViewControllers([root], animated: true) } public required init?(coder aDecoder: NSCoder) { @@ -163,31 +163,31 @@ } } -// @discardableResult -// open override func popToRootViewController(animated: Bool) -> [UIViewController]? { -// path.removeAll() -// return super.popToRootViewController(animated: animated) -// } + @discardableResult + open override func popToRootViewController(animated: Bool) -> [UIViewController]? { + path.removeAll() + return super.popToRootViewController(animated: animated) + } @discardableResult private func _popToRootViewController(animated: Bool) -> [UIViewController]? { super.popToRootViewController(animated: animated) } -// @discardableResult -// open override func popToViewController( -// _ viewController: UIViewController, animated: Bool -// ) -> [UIViewController]? { -// let viewControllers = super.popToViewController(viewController, animated: animated) -// if let viewControllers { -// for viewController in viewControllers { -// if let navigationID = viewController.navigationID { -// path.removeAll(where: { $0 == navigationID }) -// } -// } -// } -// return viewControllers -// } + @discardableResult + open override func popToViewController( + _ viewController: UIViewController, animated: Bool + ) -> [UIViewController]? { + let viewControllers = super.popToViewController(viewController, animated: animated) + if let viewControllers { + for viewController in viewControllers { + if let navigationID = viewController.navigationID { + path.removeAll(where: { $0 == navigationID }) + } + } + } + return viewControllers + } @discardableResult private func _popToViewController( @@ -196,35 +196,35 @@ super.popToViewController(viewController, animated: animated) } -// @discardableResult -// open override func popViewController(animated: Bool) -> UIViewController? { -// let viewController = super.popViewController(animated: animated) -// if let viewController, let navigationID = viewController.navigationID { -// #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) -// switch interactivePopGestureRecognizer?.state { -// case .possible?, nil: -// path.removeAll(where: { $0 == navigationID }) -// case .began, .changed, .ended, .cancelled, .failed: -// fallthrough -// @unknown default: -// break -// } -// #else -// path.removeAll(where: { $0 == navigationID }) -// #endif -// } -// return viewController -// } + @discardableResult + open override func popViewController(animated: Bool) -> UIViewController? { + let viewController = super.popViewController(animated: animated) + if let viewController, let navigationID = viewController.navigationID { + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + switch interactivePopGestureRecognizer?.state { + case .possible?, nil: + path.removeAll(where: { $0 == navigationID }) + case .began, .changed, .ended, .cancelled, .failed: + fallthrough + @unknown default: + break + } + #else + path.removeAll(where: { $0 == navigationID }) + #endif + } + return viewController + } @discardableResult private func _popViewController(animated: Bool) -> UIViewController? { super.popViewController(animated: animated) } -// open override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { -// path = viewControllers.compactMap(\.navigationID) -// super.setViewControllers(viewControllers, animated: animated) -// } + open override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { + path = viewControllers.compactMap(\.navigationID) + super.setViewControllers(viewControllers, animated: animated) + } private func _setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { super.setViewControllers(viewControllers, animated: animated) From 1fd75f7f1542aa01c825335392f41c2755bd4fed Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 25 Feb 2025 09:13:07 -0800 Subject: [PATCH 08/14] wip --- Sources/SwiftNavigation/UINavigationPath.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/SwiftNavigation/UINavigationPath.swift b/Sources/SwiftNavigation/UINavigationPath.swift index 135bb0b55..a931abb53 100644 --- a/Sources/SwiftNavigation/UINavigationPath.swift +++ b/Sources/SwiftNavigation/UINavigationPath.swift @@ -125,10 +125,6 @@ public struct UINavigationPath: Equatable { let tag: String let item: String - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.tag == rhs.tag && lhs.item == rhs.item - } - public init(tag: String, item: String) { self.tag = tag self.item = item From 4abcbe31493e10ac14d2b247bb2462aa05910836 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 19 Jun 2026 13:20:15 -0700 Subject: [PATCH 09/14] test --- .../NavigationStackTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Examples/CaseStudiesTests/NavigationStackTests.swift b/Examples/CaseStudiesTests/NavigationStackTests.swift index e533c10c0..40fdc1149 100644 --- a/Examples/CaseStudiesTests/NavigationStackTests.swift +++ b/Examples/CaseStudiesTests/NavigationStackTests.swift @@ -261,6 +261,30 @@ final class NavigationStackTests: XCTestCase { await assertEventuallyEqual(path, [1, 2]) } + @MainActor + func testPopWithReentrantMutation_DoesNotRepush() async throws { + @UIBinding var path = [Int]() + let nav = NavigationStackController(path: $path) { + UIViewController() + } + nav.navigationDestination(for: Int.self) { number in + ChildViewController(number: number) + } + try await setUp(controller: nav) + + withUITransaction(\.uiKit.disablesAnimations, true) { + path = [1, 2] + } + await assertEventuallyEqual(nav.viewControllers.count, 3) + await assertEventuallyEqual(path, [1, 2]) + + nav.popViewController(animated: false) + path = path + + await assertEventuallyEqual(nav.viewControllers.count, 2) + await assertEventuallyEqual(path, [1]) + } + @MainActor func testPushAction() async throws { @UIBinding var path = [Int]() From 22025290f621d7cf89bf9efb077580e8416054c5 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jun 2026 14:39:00 -0700 Subject: [PATCH 10/14] fix --- .../Navigation/NavigationStackController.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index b281f37b2..dca8b6c42 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -360,16 +360,6 @@ } return } - DispatchQueue.main.async { - let oldPath = navigationController.path.filter { - guard case .eager = $0 else { return false } - return true - } - let newPath = navigationController.viewControllers.compactMap(\.navigationID) - if oldPath.count > newPath.count { - navigationController.path = newPath - } - } } #if !os(tvOS) && !os(watchOS) From 9e4e0f1f3b232dd08cc480a81fc7150ae124536a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 22 Jun 2026 14:57:35 -0700 Subject: [PATCH 11/14] simplify --- Sources/SwiftNavigation/UINavigationPath.swift | 15 +++++---------- .../Navigation/NavigationStackController.swift | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftNavigation/UINavigationPath.swift b/Sources/SwiftNavigation/UINavigationPath.swift index 8670a42d3..27ab04e6a 100644 --- a/Sources/SwiftNavigation/UINavigationPath.swift +++ b/Sources/SwiftNavigation/UINavigationPath.swift @@ -8,7 +8,7 @@ public struct UINavigationPath: Equatable { public var elements: [Element] = [] @_spi(Internals) - public enum Element: Hashable { + public enum Element: Equatable { case eager(AnyHashable) case lazy(Lazy) @@ -52,15 +52,6 @@ public struct UINavigationPath: Equatable { return CodableRepresentation.Element(eager) == lazy } } - - public func hash(into hasher: inout Hasher) { - switch self { - case let .eager(value), let .lazy(.element(value)): - hasher.combine(value) - case let .lazy(.codable(value)): - hasher.combine(value.decode()) - } - } } /// The number of elements in this path. @@ -125,6 +116,10 @@ public struct UINavigationPath: Equatable { let tag: String let item: String + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.tag == rhs.tag && lhs.item == rhs.item + } + public init(tag: String, item: String) { self.tag = tag self.item = item diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index dca8b6c42..ee179ea3d 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -24,7 +24,7 @@ } } } - private var elementsBeingPopped: Set = [] + private var elementsBeingPopped: [UINavigationPath.Element] = [] private let pathDelegate = PathDelegate() private var root: UIViewController? @@ -245,7 +245,7 @@ let last = path.last, !viewControllers.compactMap(\.navigationID).contains(last) else { return } - elementsBeingPopped.insert(last) + elementsBeingPopped.append(last) } #endif @@ -414,7 +414,7 @@ .first(where: { $0.navigationItem == item })? .navigationID { - elementsBeingPopped.insert(navigationID) + elementsBeingPopped.append(navigationID) } return true } From af75a13920d917f9fab70d9749f4449c39c1abab Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 25 Jun 2026 23:23:02 -0700 Subject: [PATCH 12/14] wip --- .../NavigationStackController.swift | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index ee179ea3d..8e374c09c 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -186,15 +186,11 @@ open override func popToViewController( _ viewController: UIViewController, animated: Bool ) -> [UIViewController]? { - let viewControllers = super.popToViewController(viewController, animated: animated) - if let viewControllers { - for viewController in viewControllers { - if let navigationID = viewController.navigationID { - path.removeAll(where: { $0 == navigationID }) - } - } + if let index = viewControllers.firstIndex(of: viewController) { + let poppedNavigationIDs = viewControllers[index...].dropFirst().compactMap(\.navigationID) + path.removeAll(where: { poppedNavigationIDs.contains($0) }) } - return viewControllers + return super.popToViewController(viewController, animated: animated) } @discardableResult @@ -206,19 +202,20 @@ @discardableResult open override func popViewController(animated: Bool) -> UIViewController? { + let poppedNavigationID = viewControllers.last?.navigationID let viewController = super.popViewController(animated: animated) - if let viewController, let navigationID = viewController.navigationID { + if let poppedNavigationID { #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) switch interactivePopGestureRecognizer?.state { case .possible?, nil: - path.removeAll(where: { $0 == navigationID }) + path.removeAll(where: { $0 == poppedNavigationID }) case .began, .changed, .ended, .cancelled, .failed: fallthrough @unknown default: break } #else - path.removeAll(where: { $0 == navigationID }) + path.removeAll(where: { $0 == poppedNavigationID }) #endif } return viewController From f1ec9ff1fe34d5fe4058923ac8bbe271629eb642 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 25 Jun 2026 23:43:48 -0700 Subject: [PATCH 13/14] wip --- .../NavigationStackController.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 8e374c09c..15c3211df 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -70,7 +70,8 @@ #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) interactivePopGestureRecognizer?.addTarget( - self, action: #selector(interactivePopGestureRecognizerAction) + self, + action: #selector(interactivePopGestureRecognizerAction) ) #endif @@ -165,7 +166,8 @@ path.remove(atOffsets: invalidIndices) } _setViewControllers( - newViewControllers, animated: !transaction.uiKit.disablesAnimations + newViewControllers, + animated: !transaction.uiKit.disablesAnimations ) } } @@ -184,7 +186,8 @@ @discardableResult open override func popToViewController( - _ viewController: UIViewController, animated: Bool + _ viewController: UIViewController, + animated: Bool ) -> [UIViewController]? { if let index = viewControllers.firstIndex(of: viewController) { let poppedNavigationIDs = viewControllers[index...].dropFirst().compactMap(\.navigationID) @@ -195,7 +198,8 @@ @discardableResult private func _popToViewController( - _ viewController: UIViewController, animated: Bool + _ viewController: UIViewController, + animated: Bool ) -> [UIViewController]? { super.popToViewController(viewController, animated: animated) } @@ -342,7 +346,10 @@ """ ) } - navigationController.path.removeSubrange(nextIndex...) + DispatchQueue.main.async { + guard nextIndex < navigationController.path.count else { return } + navigationController.path.removeSubrange(nextIndex...) + } return } @@ -405,9 +412,11 @@ extension NavigationStackController: UINavigationBarDelegate { public func navigationBar( - _ navigationBar: UINavigationBar, shouldPop item: UINavigationItem + _ navigationBar: UINavigationBar, + shouldPop item: UINavigationItem ) -> Bool { - if let navigationID = viewControllers + if let navigationID = + viewControllers .first(where: { $0.navigationItem == item })? .navigationID { From b009cd5ba48abf7b78d48b72f9ada8828c150f96 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 26 Jun 2026 00:01:48 -0700 Subject: [PATCH 14/14] wip --- .../Navigation/NavigationStackController.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 62797448e..05e40bbee 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -269,13 +269,21 @@ return nil } viewController.navigationID = .eager(element) - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + #if Perception + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + viewController.traitOverrides + .dismiss = UIDismissAction { [weak self, weak viewController] transaction in + guard let self, let viewController else { return } + popFromViewController(viewController, animated: !transaction.uiKit.disablesAnimations) + } + } + #else viewController.traitOverrides .dismiss = UIDismissAction { [weak self, weak viewController] transaction in guard let self, let viewController else { return } popFromViewController(viewController, animated: !transaction.uiKit.disablesAnimations) } - } + #endif return viewController } @@ -418,6 +426,9 @@ } } + #if !Perception + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + #endif extension NavigationStackController: UINavigationBarDelegate { public func navigationBar( _ navigationBar: UINavigationBar, @@ -482,6 +493,9 @@ stackController.path.append(.lazy(.element(value))) } + #if !Perception + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + #endif public func navigationDestination( for data: D.Type, destination: @escaping (D) -> UIViewController