-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add HostToastPresenting for host-scoped toast presentation #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a pretty neat feature.