From 5e196639ceddc60969d9f5b409098622001d9784 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Tue, 7 Jul 2026 12:30:36 -0500 Subject: [PATCH 1/2] feat: add HostToastPresenting for host-scoped toast presentation Adds a HostToastPresenting protocol that modal hosts conform to, vending a ToastPresenter (contentToastPresenter) that presents from the root of the host's content. Toasts presented this way are decoupled from the view controller that triggered them: they survive navigation, including removal of the triggering view controller, until dismissed via their ModalLifetime. Previously the only way to achieve this was to walk the view controller hierarchy to locate the host's content and present from it directly, which requires knowledge of the modal presentation system's internal containers. Both ModalHostContainerViewController and the WorkflowModals host conform; consumers reach the presenter via `rootModalHost as? HostToastPresenting`. Delegates to the content's existing ToastPresenter, so environment propagation, presentation filters, toast ordering, and lifetime semantics are unchanged from toasts presented from any descendent view controller. Co-Authored-By: Claude Fable 5 --- .../ModalHostContainerViewController.swift | 9 +++ .../Sources/Toasts/HostToastPresenting.swift | 32 +++++++++ Modals/Tests/HostToastPresentingTests.swift | 71 +++++++++++++++++++ .../Sources/ModalHostContainer.swift | 12 +++- .../Tests/ModalHostContainerTests.swift | 27 +++++++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 Modals/Sources/Toasts/HostToastPresenting.swift create mode 100644 Modals/Tests/HostToastPresentingTests.swift diff --git a/Modals/Sources/ModalHostContainerViewController.swift b/Modals/Sources/ModalHostContainerViewController.swift index 81ad3a30..337612c5 100644 --- a/Modals/Sources/ModalHostContainerViewController.swift +++ b/Modals/Sources/ModalHostContainerViewController.swift @@ -343,6 +343,15 @@ public final class ModalHostContainerViewController: UIViewController, ModalHost } } +extension ModalHostContainerViewController: HostToastPresenting { + + /// A `ToastPresenter` that presents toasts from the root of this host's content. See + /// [HostToastPresenting](x-source-tag://HostToastPresenting). + public var contentToastPresenter: ToastPresenter { + content.toastPresenter + } +} + private final class ModalHostView: UIView { private let passthroughSizeThatFits: (CGSize) -> CGSize private let ancestorPresentationView: () -> UIView? diff --git a/Modals/Sources/Toasts/HostToastPresenting.swift b/Modals/Sources/Toasts/HostToastPresenting.swift new file mode 100644 index 00000000..099ba591 --- /dev/null +++ b/Modals/Sources/Toasts/HostToastPresenting.swift @@ -0,0 +1,32 @@ +import UIKit + + +/// A [ModalHost](x-source-tag://ModalHost) that can provide a `ToastPresenter` for presenting +/// toasts scoped to the host, rather than to the presenting view controller. +/// +/// Toasts presented through `contentToastPresenter` are owned by the host's content, so their +/// lifetime is decoupled from the view controller that triggered them: they remain presented +/// across navigation — including removal of the triggering view controller from the hierarchy — +/// until dismissed via their `ModalLifetime`, or until the host's content itself leaves the +/// hierarchy. Use this for fire-and-forget notification toasts, such as a toast presented while +/// the screen that triggered it is being popped. +/// +/// To scope a toast's lifetime to a particular view controller instead, use that view +/// controller's `toastPresenter`. +/// +/// You can reach a host from any descendent view controller via `modalHost` or `rootModalHost`: +/// +/// ```swift +/// if let host = rootModalHost as? HostToastPresenting { +/// self.toastLifetime = host.contentToastPresenter.present(toastViewController) +/// } +/// ``` +/// +/// - Tag: HostToastPresenting +/// +public protocol HostToastPresenting: ModalHost { + + /// A `ToastPresenter` that presents toasts from the root of the host's content, decoupling + /// their lifetime from any particular descendent view controller. + var contentToastPresenter: ToastPresenter { get } +} diff --git a/Modals/Tests/HostToastPresentingTests.swift b/Modals/Tests/HostToastPresentingTests.swift new file mode 100644 index 00000000..09b82cf9 --- /dev/null +++ b/Modals/Tests/HostToastPresentingTests.swift @@ -0,0 +1,71 @@ +import TestingSupport +import UIKit +import XCTest +@testable import Modals + +final class HostToastPresentingTests: XCTestCase { + + func test_presents_from_host_content() { + let content = UIViewController() + let host = ModalHostContainerViewController(content: content) + + let lifetime = host.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + + // The toast is owned by the host's content, making it visible to the host's aggregation. + XCTAssertEqual(content.aggregateModals().toasts.count, 1) + + show(vc: host) { host in + XCTAssertTrue(host.toastPresentation.hasVisiblePresentations) + + lifetime.dismiss() + XCTAssertEqual(content.aggregateModals().toasts.count, 0) + } + } + + func test_toast_outlives_presenting_descendent() { + let content = UIViewController() + let screen = UIViewController() + content.addChild(screen) + content.view.addSubview(screen.view) + screen.didMove(toParent: content) + + let host = ModalHostContainerViewController(content: content) + + let lifetime = host.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + defer { lifetime.dismiss() } + + // Remove the view controller that triggered the toast, as a navigation pop would. + screen.willMove(toParent: nil) + screen.view.removeFromSuperview() + screen.removeFromParent() + + XCTAssertEqual( + content.aggregateModals().toasts.count, + 1, + "The toast should remain presented after the triggering view controller is removed." + ) + } + + func test_host_is_reachable_from_descendents() { + let content = UIViewController() + let screen = UIViewController() + content.addChild(screen) + content.view.addSubview(screen.view) + screen.didMove(toParent: content) + + let host = ModalHostContainerViewController(content: content) + + show(vc: host) { host in + let found = screen.rootModalHost as? HostToastPresenting + XCTAssertTrue(found === host) + } + } +} diff --git a/WorkflowModals/Sources/ModalHostContainer.swift b/WorkflowModals/Sources/ModalHostContainer.swift index 04a4b055..2ee87f2e 100644 --- a/WorkflowModals/Sources/ModalHostContainer.swift +++ b/WorkflowModals/Sources/ModalHostContainer.swift @@ -141,7 +141,9 @@ extension ModalHostContainer: Screen where Content: Screen { // TODO: This should be extracted from `ModalHostContainer` so its type isnt dependent on ``. - final class ViewController: ScreenViewController, ModalHost, ToastPresentationViewControllerDelegate { + final class ViewController: ScreenViewController, ModalHost, HostToastPresenting, + ToastPresentationViewControllerDelegate + { private(set) var content: UIViewController let modalPresentationController: ModalPresentationViewController let toastPresentationController: ToastPresentationViewController @@ -328,6 +330,14 @@ extension ModalHostContainer: Screen where Content: Screen { return presentationFilter.presentedByAncestor(modalList) } + // MARK: HostToastPresenting + + /// A `ToastPresenter` that presents toasts from the root of this host's content. See + /// [HostToastPresenting](x-source-tag://HostToastPresenting). + var contentToastPresenter: ToastPresenter { + content.toastPresenter + } + private var ancestorModalHost: ModalHost? { parent?.modalHost } diff --git a/WorkflowModals/Tests/ModalHostContainerTests.swift b/WorkflowModals/Tests/ModalHostContainerTests.swift index 6544940c..c56ef8ae 100644 --- a/WorkflowModals/Tests/ModalHostContainerTests.swift +++ b/WorkflowModals/Tests/ModalHostContainerTests.swift @@ -574,6 +574,33 @@ class ModalHostContainerTests: XCTestCase { hostContainer.view.layoutIfNeeded() XCTAssertEqual(hostContainer.preferredContentSize, CGSize(width: axisSize, height: axisSize)) } + func test_host_toast_presenter_presents_from_content() { + let viewController = ModalHostContainer.ViewController( + screen: .init( + content: EmptyScreen(), + toastContainerStyle: .fixture + ), + environment: .empty + ) + + let host: HostToastPresenting = viewController + + let lifetime = host.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + + // The toast is owned by the host's content, making it visible to the host's aggregation. + XCTAssertEqual(viewController.content.aggregateModals().toasts.count, 1) + + show(vc: viewController) { viewController in + XCTAssertTrue(viewController.toastPresentationController.hasVisiblePresentations) + + lifetime.dismiss() + XCTAssertEqual(viewController.content.aggregateModals().toasts.count, 0) + } + } private func makeToastHost() -> ModalHostContainer.ViewController { ModalHostContainer.ViewController( From 3700d8335ff443d557710bdcd3653eb38f3c0c92 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Wed, 8 Jul 2026 17:24:32 -0500 Subject: [PATCH 2/2] docs: clarify HostToastPresenting lifetime and filters --- .../Sources/Toasts/HostToastPresenting.swift | 40 +++++-- Modals/Tests/HostToastPresentingTests.swift | 108 +++++++++++++++--- .../Tests/ModalHostContainerTests.swift | 52 ++++++++- 3 files changed, 174 insertions(+), 26 deletions(-) diff --git a/Modals/Sources/Toasts/HostToastPresenting.swift b/Modals/Sources/Toasts/HostToastPresenting.swift index 099ba591..f03dcecd 100644 --- a/Modals/Sources/Toasts/HostToastPresenting.swift +++ b/Modals/Sources/Toasts/HostToastPresenting.swift @@ -2,14 +2,22 @@ import UIKit /// A [ModalHost](x-source-tag://ModalHost) that can provide a `ToastPresenter` for presenting -/// toasts scoped to the host, rather than to the presenting view controller. +/// toasts scoped to the host's content, rather than to the presenting view controller. /// -/// Toasts presented through `contentToastPresenter` are owned by the host's content, so their -/// lifetime is decoupled from the view controller that triggered them: they remain presented -/// across navigation — including removal of the triggering view controller from the hierarchy — -/// until dismissed via their `ModalLifetime`, or until the host's content itself leaves the -/// hierarchy. Use this for fire-and-forget notification toasts, such as a toast presented while -/// the screen that triggered it is being popped. +/// Toasts presented through `contentToastPresenter` are stored by the host's content, so removing +/// the triggering view controller from the hierarchy (e.g. with a navigation pop) does not dismiss +/// them. As with any toast presentation, the returned `ModalLifetime` must be retained — +/// deallocating it dismisses the toast — so retain it with an owner that outlives the triggering +/// view controller. +/// +/// Storage and visibility have separate lifetimes: retaining the `ModalLifetime` keeps the toast +/// stored by the content presenter, while the toast is visible only when the host is attached to +/// an active modal-host hierarchy. Detaching a nested host removes its forwarded toast from the +/// former ancestor without dismissing the retained lifetime. +/// +/// Presented toasts participate in the host's presentation filter like any other toast within +/// its content: with the default pass-through-toasts filter, a nested host forwards them to its +/// ancestor, so they are displayed by the outermost host. /// /// To scope a toast's lifetime to a particular view controller instead, use that view /// controller's `toastPresenter`. @@ -17,8 +25,22 @@ import UIKit /// You can reach a host from any descendent view controller via `modalHost` or `rootModalHost`: /// /// ```swift -/// if let host = rootModalHost as? HostToastPresenting { -/// self.toastLifetime = host.contentToastPresenter.present(toastViewController) +/// final class ToastCoordinator { +/// private var toastLifetime: ModalLifetime? +/// +/// func present( +/// _ toastViewController: some UIViewController & ToastPresentable, +/// from trigger: UIViewController +/// ) { +/// guard let host = trigger.rootModalHost as? HostToastPresenting else { return } +/// +/// toastLifetime = host.contentToastPresenter.present(toastViewController) +/// } +/// +/// func dismissToast() { +/// toastLifetime?.dismiss() +/// toastLifetime = nil +/// } /// } /// ``` /// diff --git a/Modals/Tests/HostToastPresentingTests.swift b/Modals/Tests/HostToastPresentingTests.swift index 09b82cf9..8714f4c7 100644 --- a/Modals/Tests/HostToastPresentingTests.swift +++ b/Modals/Tests/HostToastPresentingTests.swift @@ -26,32 +26,110 @@ final class HostToastPresentingTests: XCTestCase { } } - func test_toast_outlives_presenting_descendent() { + func test_toast_outlives_presenting_descendent() throws { let content = UIViewController() - let screen = UIViewController() - content.addChild(screen) - content.view.addSubview(screen.view) - screen.didMove(toParent: content) - + weak var weakScreen: UIViewController? let host = ModalHostContainerViewController(content: content) - let lifetime = host.contentToastPresenter.present( + func presentFromDescendent() throws -> ModalLifetime { + let screen = UIViewController() + weakScreen = screen + content.addChild(screen) + content.view.addSubview(screen.view) + screen.didMove(toParent: content) + + // Resolve the host from the triggering view controller, as a consumer would, and + // return the lifetime to an owner outside it. + let resolvedHost = try XCTUnwrap(screen.rootModalHost as? HostToastPresenting) + let lifetime = resolvedHost.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + + // Remove the view controller that triggered the toast, as a navigation pop would. + screen.willMove(toParent: nil) + screen.view.removeFromSuperview() + screen.removeFromParent() + + return lifetime + } + + let lifetime = try presentFromDescendent() + + XCTAssertNil(weakScreen) + + XCTAssertEqual( + content.aggregateModals().toasts.count, + 1, + "The toast should remain presented after the triggering view controller is removed." + ) + + show(vc: host) { host in + XCTAssertTrue(host.toastPresentation.hasVisiblePresentations) + + lifetime.dismiss() + host.view.layoutIfNeeded() + + XCTAssertTrue(host.toastPresentation.presentedViewControllers.isEmpty) + } + } + + func test_nested_host_forwards_toasts_to_ancestor_by_default() { + let innerContent = UIViewController() + let innerHost = ModalHostContainerViewController(content: innerContent) + + let outerContent = UIViewController() + outerContent.addChild(innerHost) + outerContent.view.addSubview(innerHost.view) + innerHost.didMove(toParent: outerContent) + + let outerHost = ModalHostContainerViewController(content: outerContent) + + let lifetime = innerHost.contentToastPresenter.present( UIViewController(), style: .init(ToastPresentationStyleFixture()), accessibilityAnnouncement: "Toast." ) defer { lifetime.dismiss() } - // Remove the view controller that triggered the toast, as a navigation pop would. - screen.willMove(toParent: nil) - screen.view.removeFromSuperview() - screen.removeFromParent() + show(vc: outerHost) { outerHost in + innerHost.view.layoutIfNeeded() - XCTAssertEqual( - content.aggregateModals().toasts.count, - 1, - "The toast should remain presented after the triggering view controller is removed." + // The default pass-through-toasts filter forwards the toast to the ancestor host. + XCTAssertFalse(innerHost.toastPresentation.hasVisiblePresentations) + XCTAssertTrue(outerHost.toastPresentation.hasVisiblePresentations) + } + } + + func test_nested_host_presents_toasts_locally_when_not_passing_through() { + let innerContent = UIViewController() + let innerHost = ModalHostContainerViewController( + content: innerContent, + shouldPassthroughToasts: false + ) + + let outerContent = UIViewController() + outerContent.addChild(innerHost) + outerContent.view.addSubview(innerHost.view) + innerHost.didMove(toParent: outerContent) + + let outerHost = ModalHostContainerViewController(content: outerContent) + + let lifetime = innerHost.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." ) + defer { lifetime.dismiss() } + + show(vc: outerHost) { outerHost in + innerHost.view.layoutIfNeeded() + + // Without the pass-through filter, the inner host displays its own toasts. + XCTAssertTrue(innerHost.toastPresentation.hasVisiblePresentations) + XCTAssertFalse(outerHost.toastPresentation.hasVisiblePresentations) + } } func test_host_is_reachable_from_descendents() { diff --git a/WorkflowModals/Tests/ModalHostContainerTests.swift b/WorkflowModals/Tests/ModalHostContainerTests.swift index c56ef8ae..82a10bd6 100644 --- a/WorkflowModals/Tests/ModalHostContainerTests.swift +++ b/WorkflowModals/Tests/ModalHostContainerTests.swift @@ -574,6 +574,7 @@ class ModalHostContainerTests: XCTestCase { hostContainer.view.layoutIfNeeded() XCTAssertEqual(hostContainer.preferredContentSize, CGSize(width: axisSize, height: axisSize)) } + func test_host_toast_presenter_presents_from_content() { let viewController = ModalHostContainer.ViewController( screen: .init( @@ -602,11 +603,58 @@ class ModalHostContainerTests: XCTestCase { } } - private func makeToastHost() -> ModalHostContainer.ViewController { + func test_nested_host_toast_presenter_forwards_to_ancestor_by_default() throws { + let innerHost = makeToastHost() + let outerHost = makeToastHost() + nest(innerHost, in: outerHost) + + let resolvedHost = try XCTUnwrap(innerHost.content.modalHost as? HostToastPresenting) + XCTAssertTrue((resolvedHost as? UIViewController) === innerHost) + XCTAssertTrue((innerHost.content.rootModalHost as? UIViewController) === outerHost) + + let lifetime = resolvedHost.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + defer { lifetime.dismiss() } + + show(vc: outerHost) { outerHost in + innerHost.view.layoutIfNeeded() + + XCTAssertTrue(innerHost.toastPresentationController.presentedViewControllers.isEmpty) + XCTAssertEqual(outerHost.toastPresentationController.presentedViewControllers.count, 1) + } + } + + func test_nested_host_toast_presenter_presents_locally_without_passthrough() { + let innerHost = makeToastHost(shouldPassthroughToasts: false) + let outerHost = makeToastHost() + nest(innerHost, in: outerHost) + + let lifetime = innerHost.contentToastPresenter.present( + UIViewController(), + style: .init(ToastPresentationStyleFixture()), + accessibilityAnnouncement: "Toast." + ) + defer { lifetime.dismiss() } + + show(vc: outerHost) { outerHost in + innerHost.view.layoutIfNeeded() + + XCTAssertEqual(innerHost.toastPresentationController.presentedViewControllers.count, 1) + XCTAssertTrue(outerHost.toastPresentationController.presentedViewControllers.isEmpty) + } + } + + private func makeToastHost( + shouldPassthroughToasts: Bool = true + ) -> ModalHostContainer.ViewController { ModalHostContainer.ViewController( screen: .init( content: EmptyScreen(), - toastContainerStyle: .fixture + toastContainerStyle: .fixture, + shouldPassthroughToasts: shouldPassthroughToasts ), environment: .empty )