Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -52,6 +54,7 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy {
showSheetButton,
drillDownButton,
showSheetFromBooleanButton,
presentedLabel,
dismissLabel,
])
stack.axis = .vertical
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
}
) {
Expand Down
129 changes: 129 additions & 0 deletions Examples/CaseStudiesTests/PresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
47 changes: 40 additions & 7 deletions Sources/UIKitNavigation/Navigation/Presentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,10 +22,13 @@
@discardableResult
public func present(
isPresented: UIBinding<Bool>,
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.
Expand All @@ -36,6 +41,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.
Expand All @@ -45,10 +52,13 @@
@discardableResult
public func present<Item: Identifiable>(
item: UIBinding<Item?>,
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.
Expand All @@ -61,6 +71,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.
Expand All @@ -71,10 +83,13 @@
@discardableResult
public func present<Item: Identifiable>(
item: UIBinding<Item?>,
onPresentation: (() -> Void)? = nil,
onDismiss: (() -> Void)? = nil,
content: @escaping (UIBinding<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.
Expand All @@ -88,6 +103,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.
Expand All @@ -98,10 +115,11 @@
public func present<Item, ID: Hashable>(
item: UIBinding<Item?>,
id: KeyPath<Item, ID>,
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)
}
}
Expand All @@ -117,6 +135,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.
Expand All @@ -128,6 +148,7 @@
public func present<Item, ID: Hashable>(
item: UIBinding<Item?>,
id: KeyPath<Item, ID>,
onPresentation: (() -> Void)? = nil,
onDismiss: (() -> Void)? = nil,
content: @escaping (UIBinding<Item>) -> UIViewController
) -> ObserveToken {
Expand All @@ -145,18 +166,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) {
Expand Down