From 24041fe7d9f37caa4539b37ca570b835dd3cf685 Mon Sep 17 00:00:00 2001 From: MultiScott Date: Thu, 22 Jan 2026 13:25:46 -0800 Subject: [PATCH 1/5] Update Presentation.swift --- .../Navigation/Presentation.swift | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/Sources/UIKitNavigation/Navigation/Presentation.swift b/Sources/UIKitNavigation/Navigation/Presentation.swift index 0a88ade2b..4b1b9d113 100644 --- a/Sources/UIKitNavigation/Navigation/Presentation.swift +++ b/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -227,6 +227,175 @@ } } + /// Pushes view controllers onto the receiver's stack using the given items as data sources, + /// coordinating between them to avoid race conditions when switching destinations. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit, and handles + /// switching between multiple destinations by using `setViewControllers` instead of separate + /// dismiss and push operations. + /// + /// - Parameters: + /// - item1: A binding to an optional source of truth for the first view controller. + /// - content1: A closure that returns the first view controller to display. + /// - item2: A binding to an optional source of truth for the second view controller. + /// - content2: A closure that returns the second view controller to display. + @_disfavoredOverload + @discardableResult + public func navigationDestination( + item1: UIBinding, + content1: @escaping (UIBinding) -> UIViewController, + item2: UIBinding, + content2: @escaping (UIBinding) -> UIViewController + ) -> ObserveToken { + let key1 = UIBindingIdentifier(item1) + let key2 = UIBindingIdentifier(item2) + + return observe { [weak self] transaction in + guard let self else { return } + guard + let navigationController = self.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't present navigation item: "navigationController" is "nil". + """ + ) + return + } + + let presented1 = presentedByID[key1] + let presented2 = presentedByID[key2] + let unwrappedItem1 = UIBinding(item1) + let unwrappedItem2 = UIBinding(item2) + + // Case 1: Switching from item1 to item2 + if presented1?.controller != nil, unwrappedItem1 == nil, let item = unwrappedItem2 { + let childController = content2(item) + let onDismiss = { [weak self] in + if item2.wrappedValue != nil { + item2.wrappedValue = nil + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[key2] = Presented(childController, id: nil) + self.presentedByID[key1] = nil + + var currentStack = navigationController.viewControllers + if let lastVC = currentStack.last, lastVC !== self { + currentStack[currentStack.count - 1] = childController + navigationController.setViewControllers( + currentStack, animated: !transaction.uiKit.disablesAnimations + ) + } else { + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + } + return + } + + // Case 2: Switching from item2 to item1 + if presented2?.controller != nil, unwrappedItem2 == nil, let item = unwrappedItem1 { + let childController = content1(item) + let onDismiss = { [weak self] in + if item1.wrappedValue != nil { + item1.wrappedValue = nil + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[key1] = Presented(childController, id: nil) + self.presentedByID[key2] = nil + + var currentStack = navigationController.viewControllers + if let lastVC = currentStack.last, lastVC !== self { + currentStack[currentStack.count - 1] = childController + navigationController.setViewControllers( + currentStack, animated: !transaction.uiKit.disablesAnimations + ) + } else { + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + } + return + } + + // Case 3: Normal push for item1 + if let item = unwrappedItem1, presented1 == nil { + let childController = content1(item) + let onDismiss = { [weak self] in + if item1.wrappedValue != nil { + item1.wrappedValue = nil + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[key1] = Presented(childController, id: nil) + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + return + } + + // Case 4: Normal push for item2 + if let item = unwrappedItem2, presented2 == nil { + let childController = content2(item) + let onDismiss = { [weak self] in + if item2.wrappedValue != nil { + item2.wrappedValue = nil + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[key2] = Presented(childController, id: nil) + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + return + } + + // Case 5: Normal dismiss for item1 + if unwrappedItem1 == nil, let presented = presented1, let controller = presented.controller { + self.presentedByID[key1] = nil + navigationController.popFromViewController( + controller, animated: !transaction.uiKit.disablesAnimations + ) + return + } + + // Case 6: Normal dismiss for item2 + if unwrappedItem2 == nil, let presented = presented2, let controller = presented.controller { + self.presentedByID[key2] = nil + navigationController.popFromViewController( + controller, animated: !transaction.uiKit.disablesAnimations + ) + return + } + } + } + /// Presents a view controller when a binding to a Boolean value you provide is true. /// /// This helper powers ``present(isPresented:onDismiss:content:)`` and From 1abd123863114aa3363ad0c2c6de3d2ce370758c Mon Sep 17 00:00:00 2001 From: MultiScott Date: Thu, 22 Jan 2026 13:42:31 -0800 Subject: [PATCH 2/5] navigationDestinations function --- .../Navigation/Presentation.swift | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/Sources/UIKitNavigation/Navigation/Presentation.swift b/Sources/UIKitNavigation/Navigation/Presentation.swift index 4b1b9d113..559cfa082 100644 --- a/Sources/UIKitNavigation/Navigation/Presentation.swift +++ b/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -396,6 +396,150 @@ } } + /// A type-erased navigation destination that can be stored in a collection. + public struct NavigationDestinationItem { + let key: UIBindingIdentifier + let isPresented: () -> Bool + let makeViewController: () -> UIViewController + let clearBinding: () -> Void + + public init( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController + ) { + self.key = UIBindingIdentifier(item) + self.isPresented = { item.wrappedValue != nil } + self.makeViewController = { + guard let unwrapped = UIBinding(item) else { + fatalError("Attempted to make view controller when item is nil") + } + return content(unwrapped) + } + self.clearBinding = { item.wrappedValue = nil } + } + } + + /// Pushes view controllers onto the receiver's stack using multiple items as data sources, + /// coordinating between them to avoid race conditions when switching destinations. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit, and handles + /// switching between multiple destinations by using `setViewControllers` instead of separate + /// dismiss and push operations. + /// + /// - Parameter destinations: A dictionary mapping identifiers to navigation destination items. + @_disfavoredOverload + @discardableResult + public func navigationDestinations( + _ destinations: [UIBindingIdentifier: NavigationDestinationItem] + ) -> ObserveToken { + return observe { [weak self] transaction in + guard let self else { return } + guard + let navigationController = self.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't present navigation item: "navigationController" is "nil". + """ + ) + return + } + + // Find which destination is currently presented (O(n) but only once) + var currentlyPresentedKey: UIBindingIdentifier? + for (key, _) in destinations { + if presentedByID[key]?.controller != nil { + currentlyPresentedKey = key + break + } + } + + // Find which destination should be presented (O(n) but only once) + var shouldPresentKey: UIBindingIdentifier? + for (key, destination) in destinations { + if destination.isPresented() { + shouldPresentKey = key + break + } + } + + // Case 1: Switching from one destination to another + if let currentKey = currentlyPresentedKey, + let newKey = shouldPresentKey, + currentKey != newKey, + let newDestination = destinations[newKey] { + + let childController = newDestination.makeViewController() + let onDismiss = { [weak self] in + if newDestination.isPresented() { + newDestination.clearBinding() + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[newKey] = Presented(childController, id: nil) + self.presentedByID[currentKey] = nil + + var currentStack = navigationController.viewControllers + if let lastVC = currentStack.last, lastVC !== self { + currentStack[currentStack.count - 1] = childController + navigationController.setViewControllers( + currentStack, animated: !transaction.uiKit.disablesAnimations + ) + } else { + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + } + return + } + + // Case 2: Normal push (no destination currently presented) + if currentlyPresentedKey == nil, + let newKey = shouldPresentKey, + let newDestination = destinations[newKey] { + + let childController = newDestination.makeViewController() + let onDismiss = { [weak self] in + if newDestination.isPresented() { + newDestination.clearBinding() + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[newKey] = Presented(childController, id: nil) + navigationController.pushViewController( + childController, animated: !transaction.uiKit.disablesAnimations + ) + return + } + + // Case 3: Normal dismiss (destination presented but should not be) + if let currentKey = currentlyPresentedKey, + shouldPresentKey == nil { + + if let presented = presentedByID[currentKey], + let controller = presented.controller { + self.presentedByID[currentKey] = nil + navigationController.popFromViewController( + controller, animated: !transaction.uiKit.disablesAnimations + ) + } + return + } + } + } + /// Presents a view controller when a binding to a Boolean value you provide is true. /// /// This helper powers ``present(isPresented:onDismiss:content:)`` and From ad145840364eac1d76d89c1d4da2c9b0879f4d51 Mon Sep 17 00:00:00 2001 From: MultiScott Date: Thu, 22 Jan 2026 14:53:38 -0800 Subject: [PATCH 3/5] Add variadic presentation method & tests for both --- .../Navigation/Presentation.swift | 116 +++++++++++++++++- .../MemoryManagementTests.swift | 45 +++++++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/Presentation.swift b/Sources/UIKitNavigation/Navigation/Presentation.swift index 559cfa082..7f2b406cc 100644 --- a/Sources/UIKitNavigation/Navigation/Presentation.swift +++ b/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -138,6 +138,113 @@ } } + /// Presents view controllers modally using multiple items as data sources, + /// coordinating between them to avoid race conditions when switching destinations. + /// + /// Like SwiftUI's `sheet` or `fullScreenCover` view modifiers, but for UIKit, and handles + /// switching between multiple destinations by dismissing the old one and presenting the new one + /// while invalidating the old view controller's onDismiss to avoid double-dismissal. + /// + /// - Parameter destinations: A dictionary mapping identifiers to destination items. + @_disfavoredOverload + @discardableResult + public func presents( + _ destinations: [UIBindingIdentifier: DestinationItem] + ) -> ObserveToken { + return observe { [weak self] transaction in + guard let self else { return } + + // Find which destination is currently presented + var currentlyPresentedKey: UIBindingIdentifier? + for (key, _) in destinations { + if presentedByID[key]?.controller != nil { + currentlyPresentedKey = key + break + } + } + + // Find which destination should be presented + var shouldPresentKey: UIBindingIdentifier? + for (key, destination) in destinations { + if destination.isPresented() { + shouldPresentKey = key + break + } + } + + // Case 1: Switching from one destination to another + if let currentKey = currentlyPresentedKey, + let newKey = shouldPresentKey, + currentKey != newKey, + let newDestination = destinations[newKey], + let oldVC = presentedByID[currentKey]?.controller { + + let childController = newDestination.makeViewController() + let onDismiss = { [weak self] in + if newDestination.isPresented() { + newDestination.clearBinding() + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + // Invalidate the old view controller's onDismiss to prevent double dismiss + oldVC._UIKitNavigation_onDismiss = {} + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + oldVC.traitOverrides.dismiss = UIDismissAction { _ in } + } + + self.presentedByID[newKey] = Presented(childController, id: nil) + self.presentedByID[currentKey] = nil + + // Dismiss the old VC then present new from self + oldVC.dismiss(animated: false) { + self.present(childController, animated: !transaction.uiKit.disablesAnimations) + } + return + } + + // Case 2: Normal present (no destination currently presented) + if currentlyPresentedKey == nil, + let newKey = shouldPresentKey, + let newDestination = destinations[newKey] { + + let childController = newDestination.makeViewController() + let onDismiss = { [weak self] in + if newDestination.isPresented() { + newDestination.clearBinding() + } + } + childController._UIKitNavigation_onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + + self.presentedByID[newKey] = Presented(childController, id: nil) + self.present(childController, animated: !transaction.uiKit.disablesAnimations) + return + } + + // Case 3: Normal dismiss (destination presented but should not be) + if let currentKey = currentlyPresentedKey, + shouldPresentKey == nil { + + if let presented = presentedByID[currentKey], + let controller = presented.controller { + self.presentedByID[currentKey] = nil + controller.dismiss(animated: !transaction.uiKit.disablesAnimations) + } + return + } + } + } + /// Pushes a view controller onto the receiver's stack when a binding to a Boolean value you /// provide is true. /// @@ -396,8 +503,9 @@ } } - /// A type-erased navigation destination that can be stored in a collection. - public struct NavigationDestinationItem { + /// A type-erased destination that can be stored in a collection. + /// Used for both navigation and modal presentation destinations. + public struct DestinationItem { let key: UIBindingIdentifier let isPresented: () -> Bool let makeViewController: () -> UIViewController @@ -426,11 +534,11 @@ /// switching between multiple destinations by using `setViewControllers` instead of separate /// dismiss and push operations. /// - /// - Parameter destinations: A dictionary mapping identifiers to navigation destination items. + /// - Parameter destinations: A dictionary mapping identifiers to destination items. @_disfavoredOverload @discardableResult public func navigationDestinations( - _ destinations: [UIBindingIdentifier: NavigationDestinationItem] + _ destinations: [UIBindingIdentifier: DestinationItem] ) -> ObserveToken { return observe { [weak self] transaction in guard let self else { return } diff --git a/Tests/UIKitNavigationTests/MemoryManagementTests.swift b/Tests/UIKitNavigationTests/MemoryManagementTests.swift index fa983e8c5..fd539b054 100644 --- a/Tests/UIKitNavigationTests/MemoryManagementTests.swift +++ b/Tests/UIKitNavigationTests/MemoryManagementTests.swift @@ -57,12 +57,57 @@ } XCTAssertNil(weakModel) } + + @MainActor + func testNavigationDestinations_ObservationDoesNotRetainModel() { + weak var weakModel: Model? + do { + @UIBindable var model = Model() + weakModel = model + let vc = UIViewController() + let nav = UINavigationController(rootViewController: vc) + _ = nav.view // Force view to load + vc.navigationDestinations([ + UIBindingIdentifier($model.child): UIViewController.DestinationItem( + item: $model.child, + content: { _ in UIViewController() } + ), + UIBindingIdentifier($model.child2): UIViewController.DestinationItem( + item: $model.child2, + content: { _ in UIViewController() } + ) + ]) + } + XCTAssertNil(weakModel) + } + + @MainActor + func testPresents_ObservationDoesNotRetainModel() { + weak var weakModel: Model? + do { + @UIBindable var model = Model() + weakModel = model + let vc = UIViewController() + vc.presents([ + UIBindingIdentifier($model.child): UIViewController.DestinationItem( + item: $model.child, + content: { _ in UIViewController() } + ), + UIBindingIdentifier($model.child2): UIViewController.DestinationItem( + item: $model.child2, + content: { _ in UIViewController() } + ) + ]) + } + XCTAssertNil(weakModel) + } } @Perceptible private final class Model: Identifiable { var isPresented = false var child: Model? = nil + var child2: Model? = nil var path = UINavigationPath() var text = "" } From b6ab9b6527ebcedcd92f3bc9616ef209e1f7a6ff Mon Sep 17 00:00:00 2001 From: MultiScott Date: Thu, 22 Jan 2026 15:05:45 -0800 Subject: [PATCH 4/5] Rename package to swift-navigation-fork to avoid conflicts --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index c2fabcfb1..9c1917b1b 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( - name: "swift-navigation", + name: "swift-navigation-fork", platforms: [ .iOS(.v13), .macOS(.v10_15), From a3e8ceada0119253d08776ede27ab366bd9ab5a3 Mon Sep 17 00:00:00 2001 From: MultiScott Date: Thu, 22 Jan 2026 15:07:45 -0800 Subject: [PATCH 5/5] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 9c1917b1b..c2fabcfb1 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( - name: "swift-navigation-fork", + name: "swift-navigation", platforms: [ .iOS(.v13), .macOS(.v10_15),