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..f03dcecd --- /dev/null +++ b/Modals/Sources/Toasts/HostToastPresenting.swift @@ -0,0 +1,54 @@ +import UIKit + + +/// A [ModalHost](x-source-tag://ModalHost) that can provide a `ToastPresenter` for presenting +/// toasts scoped to the host's content, rather than to the presenting view controller. +/// +/// 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`. +/// +/// You can reach a host from any descendent view controller via `modalHost` or `rootModalHost`: +/// +/// ```swift +/// 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 +/// } +/// } +/// ``` +/// +/// - 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..8714f4c7 --- /dev/null +++ b/Modals/Tests/HostToastPresentingTests.swift @@ -0,0 +1,149 @@ +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() throws { + let content = UIViewController() + weak var weakScreen: UIViewController? + let host = ModalHostContainerViewController(content: content) + + 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() } + + show(vc: outerHost) { outerHost in + innerHost.view.layoutIfNeeded() + + // 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() { + 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..82a10bd6 100644 --- a/WorkflowModals/Tests/ModalHostContainerTests.swift +++ b/WorkflowModals/Tests/ModalHostContainerTests.swift @@ -575,14 +575,89 @@ class ModalHostContainerTests: XCTestCase { XCTAssertEqual(hostContainer.preferredContentSize, CGSize(width: axisSize, height: axisSize)) } - private func makeToastHost() -> ModalHostContainer.ViewController { - ModalHostContainer.ViewController( + 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) + } + } + + 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, + shouldPassthroughToasts: shouldPassthroughToasts + ), + environment: .empty + ) } private func makeModalFilteringHost() -> ModalHostContainer.ViewController {