From 09e99f20b04a35f0f15b4f9f7b389c8362c42aac Mon Sep 17 00:00:00 2001 From: Jon Shier Date: Tue, 23 Jun 2026 21:14:17 -0400 Subject: [PATCH 1/3] Simple onPresentation handler. --- .../ConciseEnumNavigationViewController.swift | 18 ++++++++ .../Navigation/Presentation.swift | 41 +++++++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift index ab3e14aa4..a49ce2987 100644 --- a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift @@ -44,6 +44,8 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { primaryAction: UIAction { [weak self] _ in self?.model.destination = .drillDown(.random(in: 1...1_000)) }) + let presentedLabel = UILabel(frame: .zero) + presentedLabel.textAlignment = .center let dismissLabel = UILabel(frame: .zero) dismissLabel.textAlignment = .center @@ -52,6 +54,7 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { showSheetButton, drillDownButton, showSheetFromBooleanButton, + presentedLabel, dismissLabel, ]) stack.axis = .vertical @@ -89,7 +92,12 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { present( item: $model.destination.alert, id: \.self, + onPresentation: { + presentedLabel.text = "Alert presented" + dismissLabel.text = "" + }, onDismiss: { + presentedLabel.text = "" dismissLabel.text = "Alert dismissed" } ) { message in @@ -104,7 +112,12 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { present( item: $model.destination.sheet, id: \.self, + onPresentation: { + presentedLabel.text = "Sheet presented" + dismissLabel.text = "" + }, onDismiss: { + presentedLabel.text = "" dismissLabel.text = "Sheet dismissed" } ) { count in @@ -116,7 +129,12 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { } present( isPresented: UIBinding($model.destination.sheetWithoutPayload), + onPresentation: { + presentedLabel.text = "Sheet from boolean presented" + dismissLabel.text = "" + }, onDismiss: { + presentedLabel.text = "" dismissLabel.text = "Sheet from boolean dismissed" } ) { diff --git a/Sources/UIKitNavigation/Navigation/Presentation.swift b/Sources/UIKitNavigation/Navigation/Presentation.swift index 5fb5dcb3a..6b66ac1fc 100644 --- a/Sources/UIKitNavigation/Navigation/Presentation.swift +++ b/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -11,6 +11,8 @@ /// - Parameters: /// - isPresented: A binding to a Boolean value that determines whether to present the view /// controller. + /// - onPresentation: The closure to execute when presentation completes. Equivalent to + /// UIKit's standard presentation `completion` handler. /// - onDismiss: The closure to execute when dismissing the view controller. /// - content: A closure that returns the view controller to display over the current view /// controller's content. @@ -20,10 +22,11 @@ @discardableResult public func present( isPresented: UIBinding, + onPresentation: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, content: @escaping () -> UIViewController ) -> ObserveToken { - present(item: isPresented.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + present(item: isPresented.toOptionalUnit, onPresentation: onPresentation, onDismiss: onDismiss) { _ in content() } } /// Presents a view controller modally using the given item as a data source for its content. @@ -36,6 +39,8 @@ /// content in a view controller that you create that is displayed to the user. If `item`'s /// identity changes, the view controller is dismissed and replaced with a new one using the /// same process. + /// - onPresentation: The closure to execute when presentation completes. Equivalent to + /// UIKit's standard presentation `completion` handler. /// - onDismiss: The closure to execute when dismissing the view controller. /// - content: A closure that returns the view controller to display over the current view /// controller's content. @@ -45,10 +50,11 @@ @discardableResult public func present( item: UIBinding, + onPresentation: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> UIViewController ) -> ObserveToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -61,6 +67,8 @@ /// content in a view controller that you create that is displayed to the user. If `item`'s /// identity changes, the view controller is dismissed and replaced with a new one using the /// same process. + /// - onPresentation: The closure to execute when presentation completes. Equivalent to + /// UIKit's standard presentation `completion` handler. /// - onDismiss: The closure to execute when dismissing the view controller. /// - content: A closure that returns the view controller to display over the current view /// controller's content. @@ -71,10 +79,11 @@ @discardableResult public func present( item: UIBinding, + onPresentation: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> UIViewController ) -> ObserveToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -88,6 +97,8 @@ /// identity changes, the view controller is dismissed and replaced with a new one using the /// same process. /// - id: The key path to the provided item's identifier. + /// - onPresentation: The closure to execute when presentation completes. Equivalent to + /// UIKit's standard presentation `completion` handler. /// - onDismiss: The closure to execute when dismissing the view controller. /// - content: A closure that returns the view controller to display over the current view /// controller's content. @@ -98,10 +109,11 @@ public func present( item: UIBinding, id: KeyPath, + onPresentation: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> UIViewController ) -> ObserveToken { - present(item: item, id: id, onDismiss: onDismiss) { + present(item: item, id: id, onPresentation: onPresentation, onDismiss: onDismiss) { content($0.wrappedValue) } } @@ -117,6 +129,8 @@ /// identity changes, the view controller is dismissed and replaced with a new one using the /// same process. /// - id: The key path to the provided item's identifier. + /// - onPresentation: The closure to execute when presentation completes. Equivalent to + /// UIKit's standard presentation `completion` handler. /// - onDismiss: The closure to execute when dismissing the view controller. /// - content: A closure that returns the view controller to display over the current view /// controller's content. @@ -128,6 +142,7 @@ public func present( item: UIBinding, id: KeyPath, + onPresentation: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> UIViewController ) -> ObserveToken { @@ -145,18 +160,30 @@ presentedViewController._UIKitNavigation_onDismiss = { oldViewControllerOnDismiss?() if isRepresenting { onDismiss?() } - self.present(child, animated: !transaction.uiKit.disablesAnimations) + self.present( + child, + animated: !transaction.uiKit.disablesAnimations, + completion: onPresentation + ) } } else { self.dismiss( animated: !transaction.uiKit.disablesAnimations ) { if isRepresenting { onDismiss?() } - self.present(child, animated: !transaction.uiKit.disablesAnimations) + self.present( + child, + animated: !transaction.uiKit.disablesAnimations, + completion: onPresentation + ) } } } else { - self.present(child, animated: !transaction.uiKit.disablesAnimations) + self.present( + child, + animated: !transaction.uiKit.disablesAnimations, + completion: onPresentation + ) } } dismiss: { child, transaction in child.dismiss(animated: !transaction.uiKit.disablesAnimations) { From 9b4f89cb5e7cf0cac6205e39de9c5667563c7b54 Mon Sep 17 00:00:00 2001 From: Jon Shier Date: Fri, 3 Jul 2026 02:58:13 -0400 Subject: [PATCH 2/3] Formatting. --- .../UIKitNavigation/Navigation/Presentation.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/UIKitNavigation/Navigation/Presentation.swift b/Sources/UIKitNavigation/Navigation/Presentation.swift index 6b66ac1fc..d65c9f04c 100644 --- a/Sources/UIKitNavigation/Navigation/Presentation.swift +++ b/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -26,7 +26,9 @@ onDismiss: (() -> Void)? = nil, content: @escaping () -> UIViewController ) -> ObserveToken { - present(item: isPresented.toOptionalUnit, onPresentation: onPresentation, onDismiss: onDismiss) { _ in content() } + present( + item: isPresented.toOptionalUnit, onPresentation: onPresentation, onDismiss: onDismiss + ) { _ in content() } } /// Presents a view controller modally using the given item as a data source for its content. @@ -54,7 +56,9 @@ onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> UIViewController ) -> ObserveToken { - present(item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content) + present( + item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content + ) } /// Presents a view controller modally using the given item as a data source for its content. @@ -83,7 +87,9 @@ onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> UIViewController ) -> ObserveToken { - present(item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content) + present( + item: item, id: \.id, onPresentation: onPresentation, onDismiss: onDismiss, content: content + ) } /// Presents a view controller modally using the given item as a data source for its content. From d547984096a0df4debc1cfc46aec6c05e2b3ba1c Mon Sep 17 00:00:00 2001 From: Jon Shier Date: Fri, 3 Jul 2026 03:09:27 -0400 Subject: [PATCH 3/3] Add tests. --- .../CaseStudiesTests/PresentationTests.swift | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/Examples/CaseStudiesTests/PresentationTests.swift b/Examples/CaseStudiesTests/PresentationTests.swift index da1483175..9d81e0920 100644 --- a/Examples/CaseStudiesTests/PresentationTests.swift +++ b/Examples/CaseStudiesTests/PresentationTests.swift @@ -540,6 +540,47 @@ final class PresentationTests: XCTestCase { var destination: Destination? } + @MainActor func testOnPresentationNotCalledForUnrelatedPresentation() async throws { + class A: ViewController {} + class B: ViewController {} + class VC: ViewController { + @UIBindable var model = Destinations() + var onPresentationA: (() -> Void)? + var onPresentationB: (() -> Void)? + override func viewDidLoad() { + super.viewDidLoad() + present(isPresented: UIBinding($model.destination.presentedA)) { [weak self] in + self?.onPresentationA?() + } onDismiss: { + } content: { + A() + } + present(isPresented: UIBinding($model.destination.presentedB)) { [weak self] in + self?.onPresentationB?() + } onDismiss: { + } content: { + B() + } + } + } + let vc = VC() + vc.onPresentationB = { XCTFail() } + try await setUp(controller: vc) + + await assertEventuallyNil(vc.presentedViewController) + + withUITransaction(\.uiKit.disablesAnimations, true) { + vc.model.destination = .presentedA + } + await assertEventually(vc.presentedViewController is A) + + vc.onPresentationB = nil + withUITransaction(\.uiKit.disablesAnimations, true) { + vc.model.destination = .presentedB + } + await assertEventually(vc.presentedViewController is B) + } + @MainActor func testOnDismissNotCalledForUnrelatedDismissal() async throws { class A: ViewController {} class B: ViewController {} @@ -594,6 +635,52 @@ final class PresentationTests: XCTestCase { await assertEventuallyNil(vc.model.pushedChild) } + @MainActor + func testRepresentWhileDismissing_StillCallsOnDismissAndOnPresentation() async throws { + final class Counter { var count = 0 } + let dismissCounter = Counter() + let presentationCounter = Counter() + + final class VC: ViewController { + @UIBinding var presentedChild: Model? + let presentationCount: Counter + let dismissCount: Counter + init(presentationCounter: Counter, dismissCounter: Counter) { + self.presentationCount = presentationCounter + self.dismissCount = dismissCounter + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { + super.viewDidLoad() + present(item: $presentedChild) { [presentationCount] in + presentationCount.count += 1 + } onDismiss: { [dismissCount] in + dismissCount.count += 1 + } content: { _ in + ViewController() + } + } + } + + let vc = VC(presentationCounter: presentationCounter, dismissCounter: dismissCounter) + try await setUp(controller: vc) + + vc.presentedChild = Model() + await assertEventuallyNotNil(vc.presentedViewController) + try await Task.sleep(for: .seconds(0.5)) + + vc.presentedChild = Model() + try await Task.sleep(for: .seconds(0.05)) + vc.presentedChild = Model() + + try await Task.sleep(for: .seconds(1)) + await assertEventuallyNotNil(vc.presentedViewController) + + XCTAssertEqual(dismissCounter.count, 2) + XCTAssertEqual(presentationCounter.count, 2) + } + @MainActor func testRepresentWhileDismissing_StillCallsOnDismiss() async throws { final class DismissCounter { var count = 0 } @@ -633,6 +720,48 @@ final class PresentationTests: XCTestCase { XCTAssertEqual(counter.count, 2) } + + @MainActor + func testPresentCallsMultiplePresentationClosuresInOrder() async throws { + enum Event: String { case content, onPresentation, onDismiss } + final class Events { var events: [Event] = [] } + let events = Events() + + final class VC: ViewController { + @UIBinding var presentedChild: Model? + let events: Events + init(events: Events) { + self.events = events + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { + super.viewDidLoad() + present(item: $presentedChild) { [events] in + events.events.append(.onPresentation) + } onDismiss: { [events] in + events.events.append(.onDismiss) + } content: { [events] _ in + events.events.append(.content) + return ViewController() + } + } + } + + let vc = VC(events: events) + try await setUp(controller: vc) + + vc.presentedChild = Model() + await assertEventuallyNotNil(vc.presentedViewController) + try await Task.sleep(for: .seconds(0.75)) + + vc.presentedChild = Model() + try await Task.sleep(for: .seconds(0.75)) + await assertEventuallyNotNil(vc.presentedViewController) + + XCTAssertEqual( + events.events, [.content, .onPresentation, .content, .onDismiss, .onPresentation]) + } } @Observable