├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docs.yaml │ └── validations.yaml ├── .gitignore ├── .mise.toml ├── .swift-version ├── .swiftformat ├── Documentation ├── card-modal.gif ├── tips.md ├── uikit-usage.md └── workflow-usage.md ├── LICENSE ├── Modals ├── Resources │ ├── ca-ES.lproj │ │ └── Localizable.strings │ ├── en-AU.lproj │ │ └── Localizable.strings │ ├── en-CA.lproj │ │ └── Localizable.strings │ ├── en-GB.lproj │ │ └── Localizable.strings │ ├── en-IE.lproj │ │ └── Localizable.strings │ ├── en.lproj │ │ └── Localizable.strings │ ├── es-ES.lproj │ │ └── Localizable.strings │ ├── es.lproj │ │ └── Localizable.strings │ ├── fr-CA.lproj │ │ └── Localizable.strings │ ├── fr-FR.lproj │ │ └── Localizable.strings │ └── ja.lproj │ │ └── Localizable.strings ├── Sources │ ├── AccessibilityProxyView.swift │ ├── Bundle+Modals.swift │ ├── CACornerMask+Extensions.swift │ ├── CGSize+Extensions.swift │ ├── ClippingView.swift │ ├── Dynamics.swift │ ├── KeyboardObserver │ │ └── KeyboardObserver.swift │ ├── LocalizedStrings.swift │ ├── ModalAnimation.swift │ ├── ModalBehaviorContext.swift │ ├── ModalBehaviorPreferences.swift │ ├── ModalDecoration.swift │ ├── ModalDisplayValues.swift │ ├── ModalHeightSizing.swift │ ├── ModalHost.swift │ ├── ModalHostContainerViewController.swift │ ├── ModalHostValidation.swift │ ├── ModalInfo.swift │ ├── ModalList.swift │ ├── ModalListObserver.swift │ ├── ModalPreferenceContext.swift │ ├── ModalPresentable.swift │ ├── ModalPresentationContext.swift │ ├── ModalPresentationFilter.swift │ ├── ModalPresentationPassthroughView.swift │ ├── ModalPresentationStyle.swift │ ├── ModalPresentationStyleProvider.swift │ ├── ModalPresentationViewController+FocusRestoration.swift │ ├── ModalPresentationViewController.swift │ ├── ModalPresenter.swift │ ├── ModalReverseTransitionValues.swift │ ├── ModalRoundedCorners.swift │ ├── ModalShadow.swift │ ├── ModalTransitionValues+CrossFade.swift │ ├── ModalTransitionValues.swift │ ├── ModalWidthSizing.swift │ ├── ModalsLogging.swift │ ├── PreferredContentSize.swift │ ├── PresentableModal.swift │ ├── ShadowView.swift │ ├── Toasts │ │ ├── PresentableToast.swift │ │ ├── ToastBehaviorContext.swift │ │ ├── ToastBehaviorPreferences.swift │ │ ├── ToastContainerPresentationStyle.swift │ │ ├── ToastContainerPresentationStyleProvider.swift │ │ ├── ToastDisplayContext.swift │ │ ├── ToastDisplayValues.swift │ │ ├── ToastInteractiveExitContext.swift │ │ ├── ToastPreheatContext.swift │ │ ├── ToastPreheatValues.swift │ │ ├── ToastPresentable.swift │ │ ├── ToastPresentationStyle.swift │ │ ├── ToastPresentationStyleProvider.swift │ │ ├── ToastPresentationViewController+PanGesture.swift │ │ ├── ToastPresentationViewController+Presentation.swift │ │ ├── ToastPresentationViewController+StyleContexts.swift │ │ ├── ToastPresentationViewController+Timer.swift │ │ ├── ToastPresentationViewController+Transitions.swift │ │ ├── ToastPresentationViewController.swift │ │ ├── ToastPresentationViewControllerDelegate.swift │ │ ├── ToastPresenter.swift │ │ ├── ToastSafeAreaAnchor.swift │ │ ├── ToastTransitionContext.swift │ │ └── ToastTransitionValues.swift │ ├── TrampolineModalListObserver.swift │ ├── TrampolineModalPresenter.swift │ ├── UICoordinateSpace+Extensions.swift │ ├── UIResponder+Extensions.swift │ ├── UIView+Modals.swift │ ├── UIViewAnimating+Extensions.swift │ ├── UIViewController+Extensions.swift │ ├── UIViewController+Modals.swift │ ├── UIViewController+Orientation.swift │ └── Visibility.swift └── Tests │ ├── AccessibilityProxyTests.swift │ ├── AggregationTests.swift │ ├── ConvertInsetsTests.swift │ ├── FirstResponderRestorationTests.swift │ ├── KeyboardObserverTests.swift │ ├── ModalHostContainerViewControllerTests.swift │ ├── ModalListTests.swift │ ├── ModalPresentationViewControllerTests.swift │ ├── ModalsLoggingTests.swift │ ├── ScrollViewFinderTests.swift │ ├── TestFullScreenStyle.swift │ ├── TestLogHandler.swift │ ├── TestSupportedInterfaceOrientationsViewController.swift │ ├── TestToastPresentationStyle.swift │ ├── TextFieldViewController.swift │ ├── UIResponderTests.swift │ ├── UIViewControllerModalPresenterTests.swift │ └── VisibilityTests.swift ├── Package.swift ├── README.md ├── Samples ├── ExampleStyles │ ├── README.md │ └── Sources │ │ ├── CGSize+Extensions.swift │ │ ├── Comparable+Bounding.swift │ │ ├── ExampleStyle.swift │ │ ├── ModalDecoration+SheetHandle.swift │ │ ├── ModalPresentationStyleProvider+Examples.swift │ │ ├── ModalStyles │ │ ├── CardModalStyle.swift │ │ ├── FullModalStyle.swift │ │ ├── PopoverModalStyle.swift │ │ └── SheetModalStyle.swift │ │ ├── ModalStylesheet.swift │ │ ├── ToastContainerPresentationStyleProvider+Example.swift │ │ └── ToastStyles │ │ ├── ExampleToastContainerPresentationStyle.swift │ │ └── ExampleToastPresentationStyle.swift ├── Project.swift ├── Tuist │ ├── Package.resolved │ ├── Package.swift │ └── ProjectDescriptionHelpers │ │ └── Project+Modals.swift ├── UIKitApp │ └── Sources │ │ ├── AppDelegate.swift │ │ └── ExampleViewController.swift ├── WorkflowApp │ └── Sources │ │ ├── AppDelegate.swift │ │ ├── ExampleScreen.swift │ │ └── ExampleWorkflow.swift └── Workspace.swift ├── Scripts └── generate_docs.sh ├── TestingSupport ├── AppHost │ └── Sources │ │ └── AppDelegate.swift └── Sources │ ├── FullScreenModalStyle.swift │ ├── ModalHostContainerViewController+Testing.swift │ ├── ToastContainerPresentationStyleFixture.swift │ ├── ToastPresentationStyleFixture.swift │ └── XCTestCase+AppHost.swift └── WorkflowModals ├── Sources ├── AnyModalToastContainer.swift ├── Builder.swift ├── Modal.swift ├── ModalContainer.swift ├── ModalHostContainer.swift ├── ModalListObserver+Workflow.swift ├── ModalsRendering.swift ├── PresentedModalsManager.swift ├── Toast.swift ├── ToastContainer.swift ├── UIViewController+LoggingAttribution.swift └── WorkflowModalListProvider.swift └── Tests ├── EmptyScreen.swift ├── ModalContainerTests.swift ├── ModalHostContainerTests.swift ├── ToastContainerTests.swift └── WorkflowModalListProviderTests.swift /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file configures code owners (https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners), to automatically add reviewers to PRs. 2 | 3 | * @square/ui-systems-ios 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | ## Checklist 3 | 4 | - [ ] Unit Tests 5 | - [ ] Documentation 6 | - [ ] Pull request title follows conventional commits 7 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Generate and publish docs 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | env: 8 | # Xcode 16.3 gets us Swift 6.1, required for docc merge 9 | XCODE_VERSION: 16.3 10 | 11 | jobs: 12 | build: 13 | name: Generate API docs and publish to GitHub pages 14 | # macos-15 is required for Xcode 16.3 15 | runs-on: macos-15 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 21 | - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd 22 | with: 23 | xcode-version: ${{ env.XCODE_VERSION }} 24 | 25 | - name: Install dependencies 26 | run: tuist install --path Samples 27 | 28 | - name: Generate project 29 | run: tuist generate --path Samples --no-open 30 | 31 | - name: Generate Docs 32 | run: Scripts/generate_docs.sh 33 | 34 | - name: Deploy to GitHub Pages 35 | uses: crazy-max/ghaction-github-pages@df5cc2bfa78282ded844b354faee141f06b41865 36 | with: 37 | target_branch: gh-pages 38 | build_dir: generated_docs 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/validations.yaml: -------------------------------------------------------------------------------- 1 | name: Validations 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | XCODE_VERSION: 16.1 11 | TUIST_TEST_DEVICE: iPad (10th generation) 12 | TUIST_TEST_PLATFORM: iOS 13 | TUIST_TEST_OS: 17.2 14 | 15 | jobs: 16 | development-tests: 17 | runs-on: macos-latest 18 | 19 | strategy: 20 | matrix: 21 | scheme: 22 | - UnitTests 23 | # SnapshotTests 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 28 | - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd 29 | with: 30 | xcode-version: ${{ env.XCODE_VERSION }} 31 | 32 | - name: Install dependencies 33 | run: tuist install --path Samples 34 | 35 | - name: Test iOS 36 | run: tuist test --path Samples ${{ matrix.scheme }} 37 | 38 | samples: 39 | runs-on: macos-latest 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 44 | - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd 45 | with: 46 | xcode-version: ${{ env.XCODE_VERSION }} 47 | 48 | - name: Install dependencies 49 | run: tuist install --path Samples 50 | 51 | - name: Tutorial App 52 | run: tuist build --path Samples Samples 53 | 54 | swiftformat: 55 | runs-on: macos-latest 56 | 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 60 | 61 | - name: Run swiftformat 62 | run: swiftformat --lint . 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # SwiftPM 5 | .build/ 6 | /Packages 7 | xcuserdata/ 8 | DerivedData/ 9 | .swiftpm/configuration/registries.json 10 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 11 | .netrc 12 | 13 | # Tuist 14 | /Derived 15 | /Samples/Derived 16 | /Samples/*.xcodeproj 17 | /Samples/*.xcworkspace 18 | /*.xcodeproj 19 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | tuist = "4.23.0" 3 | swiftformat = "0.55.0" 4 | 5 | [settings] 6 | # do not try to read versions from .nvmrc, .ruby-version, etc. 7 | legacy_version_file = false 8 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 6.0.2 -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # These rules are based on the Google style guide 2 | 3 | --indent 4 4 | 5 | --maxwidth 120 6 | --closingparen balanced 7 | --extensionacl on-declarations 8 | --importgrouping testable-bottom 9 | --modifierorder "private,fileprivate,internal,public,open,private(set),fileprivate(set),internal(set),public(set),final,dynamic,optional,required,convenience,override,indirect,lazy,weak,unowned,static,class,mutating,nonmutating,prefix,postfix" # ordering used by the now deprecated specifiers rule 10 | --operatorfunc spaced 11 | --nospaceoperators ...,..< 12 | --shortoptionals always 13 | --voidtype void 14 | --wraparguments before-first 15 | --ifdef no-indent 16 | --header strip 17 | --someAny disabled # opaqueGenericParameters 18 | 19 | --rules anyObjectProtocol 20 | --rules blankLinesBetweenScopes 21 | --rules braces # https://google.github.io/swift/#braces 22 | --rules consecutiveSpaces 23 | --rules duplicateImports 24 | --rules elseOnSameLine 25 | --rules emptyBraces 26 | --rules extensionAccessControl 27 | --rules indent 28 | --rules fileHeader 29 | --rules leadingDelimiters 30 | --rules linebreakAtEndOfFile 31 | --rules modifierOrder 32 | --rules opaqueGenericParameters 33 | --rules redundantGet # https://google.github.io/swift/#properties-1 34 | --rules redundantInit # https://google.github.io/swift/#initializers-1 35 | --rules redundantParens # https://google.github.io/swift/#parentheses, https://google.github.io/swift/#enum-cases, https://google.github.io/swift/#trailing-closures 36 | # This is a style choice on top of the Google guide 37 | --rules redundantReturn 38 | # Also a style choice 39 | --rules redundantSelf 40 | --rules redundantVoidReturnType # https://google.github.io/swift/#types-with-shorthand-names 41 | --rules semicolons # https://google.github.io/swift/#semicolons 42 | --rules sortImports # https://google.github.io/swift/#import-statements 43 | --rules spaceAroundBraces # https://google.github.io/swift/#braces 44 | --rules spaceAroundBrackets # https://google.github.io/swift/#horizontal-whitespace 45 | --rules spaceAroundGenerics # https://google.github.io/swift/#horizontal-whitespace 46 | --rules spaceAroundOperators # https://google.github.io/swift/#horizontal-whitespace 47 | --rules spaceAroundParens 48 | --rules spaceInsideBraces # https://google.github.io/swift/#horizontal-whitespace 49 | --rules spaceInsideBrackets # https://google.github.io/swift/#horizontal-whitespace 50 | --rules spaceInsideComments # https://google.github.io/swift/#other-expressions 51 | --rules spaceInsideGenerics 52 | --rules spaceInsideParens 53 | --rules todos # https://google.github.io/swift/#type-variable-and-function-declarations 54 | --rules trailingClosures # https://google.github.io/swift/#trailing-closures 55 | --rules trailingCommas # https://google.github.io/swift/#trailing-commas 56 | --rules trailingSpace # https://google.github.io/swift/#horizontal-whitespace 57 | --rules typeSugar # https://google.github.io/swift/#types-with-shorthand-names 58 | --rules void # https://google.github.io/swift/#types-with-shorthand-names 59 | --rules wrapArguments 60 | --rules wrapMultilineStatementBraces # https://google.github.io/swift/#braces 61 | -------------------------------------------------------------------------------- /Documentation/card-modal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/swift-modals/d060bea1f9242f4acc2bd8e124fce45e52c1af6b/Documentation/card-modal.gif -------------------------------------------------------------------------------- /Documentation/workflow-usage.md: -------------------------------------------------------------------------------- 1 | # Workflow Usage 2 | 3 | ## 1. Install a modal host 4 | 5 | _**Note**: If you're using `Modals` and set up a `ModalHostContainerViewController` at the root of your app, you do not need to set up a `ModalHostContainer`._ 6 | 7 | `WorkflowModals` uses a container screen to host presented modals. In order to present modals within your application, you must install a `ModalHostContainer` at the root of your workflow hierarchy. For example, in your scene delegate you could map your workflow's rendering: 8 | 9 | ```swift 10 | func scene( 11 | _ scene: UIScene, 12 | willConnectTo session: UISceneSession, 13 | options connectionOptions: UIScene.ConnectionOptions 14 | ) { 15 | guard let windowScene = scene as? UIWindowScene else { return } 16 | 17 | let window = UIWindow(windowScene: windowScene) 18 | 19 | // Set up the modal host 20 | let rootWorkflow = MyRootWorkflow() 21 | .ignoringOutput() 22 | .mapRendering(ModalHostContainer.init) 23 | 24 | window.rootViewController = WorkflowHostingController(workflow: rootWorkflow) 25 | 26 | self.window = window 27 | window.makeKeyAndVisible() 28 | } 29 | ``` 30 | 31 | ## 2. Define a modal presentation style 32 | 33 | `WorkflowModals` uses the same presentation style types as `Modals` - for an example style, see the [vanilla UIKit usage guidelines](uikit-usage.md#2-define-a-modal-presentation-style). 34 | 35 | ## 3. Render a modal 36 | 37 | Now that we have a host and a modal style, render a modal in our workflow. The framework provides a `ModalContainer` screen, which allows you to render a "base" screen and an array of modals to present over that screen. `ModalContainer` is generic over two parameters: the base screen type, and the screen type for the modal (which can be `AnyScreen` if modals of different screen types are being rendered). Each modal can have its own presentation style, which is not tied to the rendering type in any way - instead, create a `Modal` with your screen, presentation style, and identifying key, and pass those modals into the `ModalContainer`: 38 | 39 | ```swift 40 | struct ModalWorkflow: Workflow { 41 | typealias Rendering = ModalContainer 42 | 43 | struct State { 44 | var showModal: Bool 45 | } 46 | 47 | enum Action: WorkflowAction { 48 | typealias WorkflowType = ModalWorkflow 49 | 50 | case showModal 51 | case dismissModal 52 | 53 | func apply(toState state: inout ModalWorkflow.State) -> ModalWorkflow.Output? { 54 | switch self { 55 | case .showModal: 56 | state.showModal = true 57 | case .dismissModal: 58 | state.showModal = false 59 | } 60 | return nil 61 | } 62 | } 63 | 64 | func makeInitialState() -> State { 65 | State(showModal: false) 66 | } 67 | 68 | func render(state: State, context: RenderContext) -> Rendering { 69 | let sink = context.makeSink(of: Action.self) 70 | 71 | let baseScreen = ModalScreen(buttonText: "Present Modal") { 72 | sink.send(.showModal) 73 | } 74 | 75 | return baseScreen.presenting { 76 | if state.showingModal { 77 | ModalScreen(buttonText: "Dismiss Modal") { 78 | sink.send(.dismissModal) 79 | }.modal(key: "modal", style: MyModalStyle()) 80 | } 81 | } 82 | } 83 | } 84 | 85 | struct ModalScreen: Screen { 86 | var buttonText: String 87 | var onTap: () -> Void 88 | 89 | // screen implementation here 90 | } 91 | ``` 92 | 93 | ![card-modal](card-modal.gif) 94 | -------------------------------------------------------------------------------- /Modals/Resources/ca-ES.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Toca dues vegades per ignorar la finestra emergent"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Ignora la finestra emergent"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/en-AU.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Double tap to dismiss popup window"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Dismiss popup"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/en-CA.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Double-tap to dismiss popup window"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Dismiss popup"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/en-GB.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Double tap to dismiss pop-up window"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Dismiss pop-up"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/en-IE.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Double tap to dismiss the pop-up window"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Dismiss pop-up"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Double tap to dismiss popup window"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Dismiss popup"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/es-ES.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Pulsa dos veces para ignorar la ventana emergente"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Ignorar ventana emergente"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Pulsar dos veces para ignorar la ventana emergente"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Ignorar ventana emergente"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/fr-CA.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Toucher deux fois pour ignorer la fenêtre contextuelle"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Fermer la fenêtre contextuelle"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/fr-FR.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "Appuyez deux fois pour fermer la fenêtre contextuelle"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "Fermer la fenêtre contextuelle"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Resources/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Read by Voiceover to help the user learn how to dismiss a popup. */ 2 | "modal_overlay_dismiss_a11y_hint" = "ダブルタップしてポップアップウィンドウを閉じる"; 3 | 4 | /* Read by Voiceover to indicate that the action will dismiss a popup. */ 5 | "modal_overlay_dismiss_a11y_label" = "ポップアップを閉じる"; 6 | 7 | -------------------------------------------------------------------------------- /Modals/Sources/Bundle+Modals.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private final class MarkerClass {} 4 | 5 | extension Bundle { 6 | 7 | static let modalsResources: Bundle = { 8 | #if SWIFT_PACKAGE 9 | return .module 10 | #else 11 | let modals = Bundle(for: MarkerClass.self) 12 | 13 | guard let resourcePath = modals.path(forResource: "ModalsResources", ofType: "bundle"), 14 | let bundle = Bundle(path: resourcePath) 15 | else { 16 | fatalError("Could not load bundle ModalsResources") 17 | } 18 | return bundle 19 | #endif 20 | }() 21 | } 22 | -------------------------------------------------------------------------------- /Modals/Sources/CACornerMask+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CACornerMask { 4 | /// A mask containing all the corners. 5 | public static let all: CACornerMask = [ 6 | .layerMinXMinYCorner, 7 | .layerMaxXMinYCorner, 8 | .layerMaxXMaxYCorner, 9 | .layerMinXMaxYCorner, 10 | ] 11 | 12 | /// A mask containing only the top corners: `[.layerMinXMinYCorner, .layerMaxXMinYCorner]` 13 | public static let top: CACornerMask = [ 14 | .layerMinXMinYCorner, 15 | .layerMaxXMinYCorner, 16 | ] 17 | 18 | /// A mask containing only the left corners: `[.layerMinXMinYCorner, .layerMinXMaxYCorner]` 19 | public static let left: CACornerMask = [ 20 | .layerMinXMinYCorner, 21 | .layerMinXMaxYCorner, 22 | ] 23 | 24 | /// A mask containing only the right corners: `[.layerMaxXMinYCorner, .layerMaxXMaxYCorner]` 25 | public static let right: CACornerMask = [ 26 | .layerMaxXMinYCorner, 27 | .layerMaxXMaxYCorner, 28 | ] 29 | } 30 | 31 | extension UIRectCorner { 32 | init(cornerMask: CACornerMask) { 33 | self.init() 34 | 35 | if cornerMask.contains(.layerMinXMinYCorner) { 36 | insert(.topLeft) 37 | } 38 | if cornerMask.contains(.layerMaxXMinYCorner) { 39 | insert(.topRight) 40 | } 41 | if cornerMask.contains(.layerMinXMaxYCorner) { 42 | insert(.bottomLeft) 43 | } 44 | if cornerMask.contains(.layerMaxXMaxYCorner) { 45 | insert(.bottomRight) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Modals/Sources/CGSize+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension CGSize { 5 | init(_ offset: UIOffset) { 6 | self.init(width: offset.horizontal, height: offset.vertical) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Modals/Sources/ClippingView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | final class ClippingView: UIView { 5 | let content: UIView 6 | 7 | init(content: UIView) { 8 | self.content = content 9 | 10 | super.init(frame: .zero) 11 | 12 | addSubview(content) 13 | clipsToBounds = true 14 | } 15 | 16 | @available(*, unavailable) 17 | required init?(coder: NSCoder) { fatalError() } 18 | 19 | override func layoutSubviews() { 20 | super.layoutSubviews() 21 | content.frame = bounds 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Modals/Sources/Dynamics.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum Dynamics { 4 | static let defaultDeceleration = UIScrollView.DecelerationRate.normal 5 | 6 | /// Project the distance traveled in pts based on an initial velocity in pts/s 7 | /// (the units for a pan gesture) and deceleration rate. 8 | /// 9 | /// Based on sample code from "Designing Fluid Interfaces" WWDC 2018. The original formula is 10 | /// `(initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)`, but it has been 11 | /// optimized below for floating point accuracy. 12 | /// 13 | /// - Tag: projectDistance 14 | /// 15 | /// - Parameters: 16 | /// - initialVelocity: The current velocity in pts/s. 17 | /// - decelerationRate: The rate of deceleration. Defaults to 18 | /// `UIScrollView.DecelerationRate.normal`. Must be between 0-1, non-inclusive; 19 | /// providing a value less than or equal to zero, or greater than or equal to 1, is a 20 | /// programmer error. 21 | /// - Returns: The projected distance travelled based on the velocity and deceleration rate. 22 | static func projectedDistance( 23 | initialVelocity: CGFloat, 24 | decelerationRate: UIScrollView.DecelerationRate = defaultDeceleration 25 | ) -> CGFloat { 26 | precondition( 27 | decelerationRate.rawValue < 1 && decelerationRate.rawValue > 0, 28 | "Deceleration rate must be greater than 0 and less than 1 to compute a finite distance traveled" 29 | ) 30 | return initialVelocity / ((1000 / decelerationRate.rawValue) - 1000) 31 | } 32 | 33 | /// Project a destination frame for the given frame, velocity, and deceleration rate. 34 | /// 35 | /// - See Also: [projectDistance](x-source-tag://projectDistance) 36 | /// 37 | /// - Parameters: 38 | /// - frame: The current frame to project a new frame from. 39 | /// - initialVelocity: The current velocity in pts/s. 40 | /// - decelerationRate: The rate of deceleration. Defaults to 41 | /// `UIScrollView.DecelerationRate.normal`. Must be between 0-1, non-inclusive; 42 | /// providing a value less than or equal to zero, or greater than or equal to 1, is a 43 | /// programmer error. 44 | /// - Returns: The projected frame based on the velocity and deceleration rate. 45 | static func projectedFrame( 46 | from frame: CGRect, 47 | initialVelocity: CGPoint, 48 | decelerationRate: UIScrollView.DecelerationRate = defaultDeceleration 49 | ) -> CGRect { 50 | frame.offsetBy( 51 | dx: projectedDistance( 52 | initialVelocity: initialVelocity.x, 53 | decelerationRate: decelerationRate 54 | ), 55 | dy: projectedDistance( 56 | initialVelocity: initialVelocity.y, 57 | decelerationRate: decelerationRate 58 | ) 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Modals/Sources/LocalizedStrings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | enum LocalizedStrings { 5 | 6 | 7 | enum ModalOverlay { 8 | 9 | static var dismissPopupAccessibilityLabel: String { 10 | NSLocalizedString( 11 | "modal_overlay_dismiss_a11y_label", 12 | tableName: nil, 13 | bundle: .modalsResources, 14 | value: "Dismiss popup", 15 | comment: "Read by Voiceover to indicate that the action will dismiss a popup." 16 | ) 17 | } 18 | 19 | static var dismissPopupAccessibilityHint: String { 20 | NSLocalizedString( 21 | "modal_overlay_dismiss_a11y_hint", 22 | tableName: nil, 23 | bundle: .modalsResources, 24 | value: "Double tap to dismiss popup window", 25 | comment: "Read by Voiceover to help the user learn how to dismiss a popup." 26 | ) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Modals/Sources/ModalAnimation.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Models the different types of animation options for modal transitions. 4 | public enum ModalAnimation: Equatable { 5 | /// An animation based off a `UIView.AnimationCurve`. 6 | case curve(UIView.AnimationCurve, duration: TimeInterval) 7 | 8 | /// An animation whose easing curve is determined by two control points. 9 | case cubicBezier(controlPoint1: CGPoint, controlPoint2: CGPoint, duration: TimeInterval) 10 | 11 | /// A spring animation based on a damping ratio and initial velocity. 12 | case dampenedSpring(dampingRatio: CGFloat = 1, initialVelocity: CGVector = .zero, duration: TimeInterval) 13 | 14 | /// A spring animation based off the physics of an object with mass, a spring stiffness and damping, 15 | /// and initial velocity. The duration of the animation is determined by the physics of the spring. 16 | /// 17 | /// The default arguments for each parameter match those of the system spring animation used for transitions 18 | /// such as modal presentation, navigation controller push/pop, and keyboard animations. You can match that 19 | /// animation with `.spring()`. 20 | case spring( 21 | mass: CGFloat = 3, 22 | stiffness: CGFloat = 1000, 23 | damping: CGFloat = 500, 24 | initialVelocity: CGVector = .zero 25 | ) 26 | } 27 | 28 | extension UIViewPropertyAnimator { 29 | convenience init(animation: ModalAnimation, animations: (() -> Void)? = nil) { 30 | switch animation { 31 | case .curve(let curve, let duration): 32 | self.init(duration: duration, curve: curve, animations: animations) 33 | 34 | case .cubicBezier(let controlPoint1, let controlPoint2, let duration): 35 | self.init( 36 | duration: duration, 37 | controlPoint1: controlPoint1, 38 | controlPoint2: controlPoint2 39 | ) 40 | 41 | case .dampenedSpring(let dampingRatio, let initialVelocity, let duration): 42 | let parameters = UISpringTimingParameters( 43 | dampingRatio: dampingRatio, 44 | initialVelocity: initialVelocity 45 | ) 46 | self.init(duration: duration, timingParameters: parameters) 47 | 48 | case .spring(let mass, let stiffness, let damping, let initialVelocity): 49 | let parameters = UISpringTimingParameters( 50 | mass: mass, 51 | stiffness: stiffness, 52 | damping: damping, 53 | initialVelocity: initialVelocity 54 | ) 55 | self.init(duration: 0, timingParameters: parameters) 56 | } 57 | 58 | if let animations { 59 | addAnimations(animations) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Modals/Sources/ModalBehaviorContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Contextual information provided to a `ModalPresentationStyle` when getting display preferences. 4 | public struct ModalBehaviorContext { 5 | /// Create a preference context. 6 | public init() {} 7 | } 8 | -------------------------------------------------------------------------------- /Modals/Sources/ModalDecoration.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Use modal decorations to add decorative views to modal presentations. 4 | /// 5 | /// Note that you must provide a frame. The frame is defined _relative to the frame of the presented 6 | /// modal_. That is, if you want the decoration to appear at the modal's origin, use `(0, 0)`. 7 | /// 8 | /// This frame will be used relative to the enter, display or exit values of your modal, 9 | /// depending on the state of the presentation. This means the decoration will move alongside 10 | /// your modal view. 11 | public struct ModalDecoration { 12 | /// The frame of the direction, expressed _relative to the frame of the presented modal_. 13 | public var frame: CGRect 14 | /// Invoked when creating a new modal decoration view. 15 | public var build: () -> (UIView) 16 | /// Invoked when updating an existing modal decoration view. 17 | public var update: (UIView) -> Void 18 | /// Whether or not `update` can be invoked on the given view. 19 | public var canUpdate: (UIView) -> Bool 20 | 21 | /// Create a new modal decoration. 22 | public init( 23 | frame: CGRect, 24 | build: @escaping () -> View, 25 | update: @escaping (View) -> Void, 26 | canUpdate: @escaping (UIView) -> Bool = { $0 is View } 27 | ) { 28 | self.frame = frame 29 | self.build = build 30 | self.update = { update($0 as! View) } 31 | self.canUpdate = canUpdate 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Modals/Sources/ModalDisplayValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Display values describe how a modal should be presented in a specific context. They are used by 4 | /// the modal presentation system to position modal containers, perform transitions, and add 5 | /// additional UI elements, such as shadows and overlay views. 6 | public struct ModalDisplayValues { 7 | /// The frame, in the coordinate space of the container specified by the 8 | /// `ModalPresentationContext`. 9 | public var frame: CGRect 10 | /// An alpha value to apply to the modal's view. Defaults to `1`. 11 | public var alpha: CGFloat 12 | /// The corner style to apply to the modal. Defaults to `.none`, for square corners. 13 | public var roundedCorners: ModalRoundedCorners 14 | /// An opacity to apply to the overlay view behind the modal. Defaults to `0`, for a 15 | /// completely transparent overlay view. 16 | public var overlayOpacity: CGFloat 17 | /// The color of the overlay view behind the modal. Defaults to `.black`. 18 | public var overlayColor: UIColor 19 | /// A shadow to show behind the modal. 20 | public var shadow: ModalShadow 21 | /// Decorations to show alongside the modal. 22 | public var decorations: [ModalDecoration] 23 | 24 | /// Create a new set of modal display values. 25 | public init( 26 | frame: CGRect, 27 | alpha: CGFloat = 1, 28 | roundedCorners: ModalRoundedCorners = .none, 29 | overlayOpacity: CGFloat = 0, 30 | overlayColor: UIColor = .black, 31 | shadow: ModalShadow = .none, 32 | decorations: [ModalDecoration] = [] 33 | ) { 34 | self.frame = frame 35 | self.alpha = alpha 36 | self.roundedCorners = roundedCorners 37 | self.overlayOpacity = overlayOpacity 38 | self.overlayColor = overlayColor 39 | self.shadow = shadow 40 | self.decorations = decorations 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Modals/Sources/ModalHeightSizing.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import Foundation 3 | 4 | /// The sizing behavior for the height of the modal. 5 | public enum ModalHeightSizing: Equatable { 6 | /// The modal will be sized relative to its preferred content size. If the content size 7 | /// is larger than the available space, it will be rendered at a maximum height. 8 | /// This is the default option, and preferred for modals with a single screen. 9 | case content 10 | 11 | /// The modal will be full height (less the styling insets) regardless of the preferred 12 | /// content size. This option should be used if your modal has a flow with multiple steps, 13 | /// since they might vary in height. 14 | case fixed 15 | 16 | /// Returns the height to use based on the provided preferred content size and maximum height. 17 | /// 18 | /// If the modal height behavior is relative to content and we have a preferred content size, 19 | /// use the preferred content size; otherwise, use the fixed behavior and use the maximum height. 20 | public func height( 21 | for preferredContentSize: ModalPresentationContext.PreferredContentSize, 22 | maximumHeight: CGFloat 23 | ) -> CGFloat { 24 | switch (self, preferredContentSize) { 25 | case (.content, .known(let size)): 26 | min(size.height, maximumHeight) 27 | default: 28 | maximumHeight 29 | } 30 | } 31 | 32 | /// Whether or not this modal height sizing behavior requires a `preferredContentSize` to be calculated. 33 | public var usesPreferredContentSize: Bool { 34 | switch self { 35 | case .content: 36 | true 37 | 38 | case .fixed: 39 | false 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Modals/Sources/ModalHost.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This protocol indicates that a view controller acts as the host for modal presentation. 4 | /// 5 | /// Descendent view controllers that present modals will call `setNeedsModalUpdate` when their list 6 | /// of presented view controllers has changed. The host should then call the `modals` property to 7 | /// traverse the view hierarchy for an updated list of modals to present. 8 | /// 9 | /// ## See Also: 10 | /// - [aggregateModals](x-source-tag://UIViewController.aggregateModals) 11 | /// 12 | /// - Tag: ModalHost 13 | /// 14 | @objc(MDLModalHost) 15 | public protocol ModalHost { 16 | /// Notifies this host that the presented modals of its descendent view controllers have 17 | /// changed, and should be updated. The update may not happen synchronously. 18 | @objc func setNeedsModalUpdate() 19 | } 20 | -------------------------------------------------------------------------------- /Modals/Sources/ModalInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ``ModalInfo`` stores heterogeneous key value pairs while maintaining strong-typing. 4 | /// 5 | /// Users may add to a known ``ModalInfo`` type by registering their own ``ModalInfoKey``. 6 | /// 7 | /// ```swift 8 | /// struct ModalElevation: ModalInfoKey { 9 | /// typealias Value = MyElevationEnum 10 | /// } 11 | /// ``` 12 | /// 13 | /// - SeeAlso: `ViewEnvironment` 14 | public struct ModalInfo { 15 | /// Create an empty ``ModalInfo`` for a given domain. 16 | public static func empty() -> ModalInfo { 17 | .init() 18 | } 19 | 20 | private var storage: [ObjectIdentifier: Any] 21 | 22 | /// Private empty initializer to make the `empty` environment explicit. 23 | public init() { 24 | storage = [:] 25 | } 26 | 27 | /// Get or set for the given `ModalInfoKey`. 28 | public subscript( 29 | key: Key.Type 30 | ) -> Key.Value? { 31 | get { 32 | storage[ObjectIdentifier(key)] as? Key.Value 33 | } 34 | set { 35 | storage[ObjectIdentifier(key)] = newValue 36 | } 37 | } 38 | 39 | /// Update the value for a given key. 40 | public func setting( 41 | key: Key.Type, 42 | value: Key.Value 43 | ) -> Self { 44 | var newInfo = self 45 | newInfo[key] = value 46 | return newInfo 47 | } 48 | 49 | public func setting( 50 | uniqueKey key: (some UniqueModalInfoKey).Type 51 | ) -> Self { 52 | setting(key: key, value: ()) 53 | } 54 | 55 | public func contains( 56 | _ key: (some ModalInfoKey).Type 57 | ) -> Bool { 58 | storage.keys.contains(ObjectIdentifier(key)) 59 | } 60 | } 61 | 62 | /// A type used to store values that share a domain. 63 | public protocol ModalInfoKey { 64 | 65 | /// The type of value stored at the key. 66 | associatedtype Value 67 | } 68 | 69 | /// ``UniqueModalInfoKey`` describes a key that is expected to be unique and therefore needs 70 | /// no user-defined valued. 71 | public protocol UniqueModalInfoKey: ModalInfoKey where Value == Void {} 72 | -------------------------------------------------------------------------------- /Modals/Sources/ModalList.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An Objective-C friendly wrapper for an array of modals. 4 | @objc(MDLModalList) 5 | public final class ModalList: NSObject { 6 | /// The modal array. 7 | public let modals: [PresentableModal] 8 | 9 | /// The toast array. 10 | public let toasts: [PresentableToast] 11 | 12 | /// The toast safe area anchors array. 13 | public let toastSafeAreaAnchors: [ToastSafeAreaAnchor] 14 | 15 | /// Create a modal list. 16 | public init( 17 | modals: [PresentableModal] = [], 18 | toasts: [PresentableToast] = [], 19 | toastSafeAreaAnchors: [ToastSafeAreaAnchor] = [] 20 | ) { 21 | self.modals = modals 22 | self.toasts = toasts 23 | self.toastSafeAreaAnchors = toastSafeAreaAnchors 24 | } 25 | 26 | /// Adds two modal lists together, appending the right hand side modals to the left hand side. 27 | public static func + (lhs: ModalList, rhs: ModalList) -> ModalList { 28 | ModalList( 29 | modals: lhs.modals + rhs.modals, 30 | toasts: lhs.toasts + rhs.toasts, 31 | toastSafeAreaAnchors: lhs.toastSafeAreaAnchors + rhs.toastSafeAreaAnchors 32 | ) 33 | } 34 | 35 | /// Returns a `ModalList` created by appending a collection of modals, toasts, and toast safe area anchors. 36 | public func appending( 37 | modals: [PresentableModal] = [], 38 | toasts: [PresentableToast] = [], 39 | toastSafeAreaAnchors: [ToastSafeAreaAnchor] = [] 40 | ) -> ModalList { 41 | self + ModalList( 42 | modals: modals, 43 | toasts: toasts, 44 | toastSafeAreaAnchors: toastSafeAreaAnchors 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Modals/Sources/ModalListObserver.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ViewEnvironment 3 | 4 | 5 | /// Observes a list of modals and/or toasts, presenting and dismissing them as the list dynamically changes over time. 6 | /// 7 | /// This interface provides a reactive alternative to the imperative presentation methods on `ModalPresenter`. 8 | /// 9 | /// A concrete implementation of this type can be access on `UIViewController`: 10 | /// [`modalListObserver`](x-source-tag://UIViewController.modalListObserver). 11 | /// 12 | public protocol ModalListObserver { 13 | 14 | /// Begins the observation of a ``ModalListProvider``. The modals from the observed object will be added to the 15 | /// list of modals on the view controller that owns this observer. 16 | /// 17 | /// This function returns a token that must be retained. To stop observing a list and dismiss related modals, call 18 | /// the `stopObserving` method on the token. If the token is deallocated, `stopObserving` will be called 19 | /// automatically. 20 | /// 21 | /// - Parameter provider: A provider to begin observing. Modals from the list will be presented and dismissed 22 | /// dynamically as the list changes over time. 23 | /// 24 | /// - Returns: A token that must be kept to continue observing and presenting modals. 25 | /// 26 | func observe(_ provider: ModalListProvider) -> ModalListObservationLifetime 27 | } 28 | 29 | 30 | /// A type that provides a list of modals and/or toasts that can change over time. 31 | /// 32 | public protocol ModalListProvider: AnyObject { 33 | 34 | /// Updates the `ViewEnvironment` used for modal presentation styles and the content of the modals. 35 | /// 36 | /// Called by the observer when observation begins, and whenever the `ViewEnvironment` needs to be re-applied. 37 | /// 38 | func update(environment: ViewEnvironment) 39 | 40 | /// Returns the presented modals associated with this observer, and any descendants of those modals. 41 | /// 42 | /// The observer will call this when its owning view controller is aggregating modals. 43 | /// 44 | /// ## See Also: 45 | /// - [UIViewController.aggregateModals()](x-source-tag://UIViewController.aggregateModals) 46 | /// 47 | func aggregateModalList() -> ModalList 48 | 49 | /// Sends `Void` whenever the `ModalList` content should be re-requested (e.g. the list of modals changes). 50 | /// 51 | var modalListDidChange: AnyPublisher { get } 52 | } 53 | 54 | 55 | /// An opaque token used to end a `ModalListObserver` observation. 56 | /// 57 | /// If this token is deallocated, the observation will end automatically and all modals currently observed will be 58 | /// dismissed. 59 | /// 60 | /// - SeeAlso: ``ModalListObserver`` 61 | /// 62 | public protocol ModalListObservationLifetime: AnyObject { 63 | 64 | /// Ends the modal list observation lifetime associated with this token. 65 | /// 66 | func stopObserving() 67 | } 68 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPreferenceContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Contextual information provided to a `ModalPresentationStyle` when getting display preferences. 4 | public struct ModalPreferenceContext { 5 | /// The viewport size. 6 | public var viewportSize: CGSize 7 | 8 | /// Create a preference context. 9 | public init(viewportSize: CGSize) { 10 | self.viewportSize = viewportSize 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPresentable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This is a convenience for view controllers or screens to specify their own modal styles. Types 4 | /// do not need to conform to this protocol to be presented as modals, but this allows types to 5 | /// provide a standard `ModalPresentationStyleProvider` to be presented with. 6 | /// 7 | /// If your view controller or workflow screen has a standard modal presentation style (for example, 8 | /// a dialog view controller), it can conform to this protocol and return that style from the 9 | /// `presentationStyle` property so consumers don't have to specify a style. In UIKit, 10 | /// `ModalPresenter` has a `present` method for view controllers that conform to this protocol. 11 | /// In Workflows, `Modal` has an initializer that takes in screens conforming to this protocol. 12 | /// 13 | public protocol ModalPresentable { 14 | 15 | var presentationStyle: ModalPresentationStyleProvider { get } 16 | 17 | var info: ModalInfo { get } 18 | } 19 | 20 | extension ModalPresentable { 21 | public var info: ModalInfo { .empty() } 22 | } 23 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPresentationFilter.swift: -------------------------------------------------------------------------------- 1 | /// A description of how the contents of a ``ModalList`` should be filtered for presentation. 2 | public struct ModalPresentationFilter { 3 | /// A unique identifier for the filter. A change in identifier should trigger a re-evaluation of modal 4 | /// presentation. 5 | @_spi(ModalsImplementation) 6 | public let identifier: AnyHashable 7 | 8 | /// `true` if the modal should be presented locally and `false` if it should be forwarded 9 | /// to an ancestor. 10 | let modalPredicate: (PresentableModal) -> Bool 11 | 12 | /// `true` if a toast should be presented locally and `false` if it should be forwarded 13 | /// to an ancestor. 14 | let toastPredicate: (PresentableToast) -> Bool 15 | 16 | @_spi(ModalsImplementation) 17 | public func presentedLocally(_ list: ModalList) -> ModalList { 18 | ModalList( 19 | modals: list.modals.filter(modalPredicate), 20 | toasts: list.toasts.filter(toastPredicate), 21 | toastSafeAreaAnchors: list.toastSafeAreaAnchors 22 | ) 23 | } 24 | 25 | @_spi(ModalsImplementation) 26 | public func presentedByAncestor(_ list: ModalList) -> ModalList { 27 | ModalList( 28 | modals: list.modals.filter { !modalPredicate($0) }, 29 | toasts: list.toasts.filter { !toastPredicate($0) }, 30 | toastSafeAreaAnchors: list.toastSafeAreaAnchors 31 | ) 32 | } 33 | 34 | public static var passThroughToasts: ModalPresentationFilter { 35 | .init( 36 | identifier: "pass-through-toasts", 37 | modalPredicate: { _ in true }, 38 | toastPredicate: { _ in false } 39 | ) 40 | } 41 | 42 | public static func containsUniqueKey( 43 | _ key: (some UniqueModalInfoKey).Type 44 | ) -> ModalPresentationFilter { 45 | .init( 46 | identifier: ObjectIdentifier(key), 47 | modalPredicate: { $0.info.contains(key) }, 48 | toastPredicate: { _ in false } 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPresentationPassthroughView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Modal and toast presentation views both need to pass through touches on themselves (e.g., only subviews should be 4 | /// interactable). They also need to increase their hit target to match the frame of their ancestor presentation 5 | /// view, if one exists. 6 | final class ModalPresentationPassthroughView: UIView { 7 | private let ancestorView: () -> UIView? 8 | 9 | init(frame: CGRect, ancestorView: @escaping () -> UIView?) { 10 | self.ancestorView = ancestorView 11 | super.init(frame: frame) 12 | } 13 | 14 | required init?(coder: NSCoder) { 15 | fatalError("init(coder:) has not been implemented") 16 | } 17 | 18 | override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 19 | /// If we have an ancestor view, return true if the point is inside that view (which may have a larger frame). 20 | if let ancestorView = ancestorView() { 21 | let point = convert(point, to: ancestorView) 22 | return ancestorView.point(inside: point, with: event) 23 | } else { 24 | return super.point(inside: point, with: event) 25 | } 26 | } 27 | 28 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 29 | /// Only return the result if it's not `self` (e.g., if its one of our subviews). 30 | let result = super.hitTest(point, with: event) 31 | return result == self ? nil : result 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ViewEnvironment 3 | 4 | /// The modal presentation system uses a modal presentation style to determine the appearance and 5 | /// behavior of a modal. This includes: 6 | /// - the container size and position 7 | /// - transitions 8 | /// - chrome UI, such as shadows and the overlay view 9 | /// 10 | /// This the is the main customization point for changing the way a modal behaves. 11 | /// 12 | public protocol ModalPresentationStyle { 13 | 14 | /// Get the modal's behavior preferences, with the context where it is presented. 15 | /// 16 | /// This will be called to determine how certain behaviors are configured in the modal. 17 | func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences 18 | 19 | /// Get the modal's current display values, with the context where it is presented. 20 | /// 21 | /// The modal presentation system may call this repeatedly to update the modal in response to 22 | /// context changes. 23 | func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues 24 | 25 | /// Gets the initial values to use during the transition when this modal is appearing. The 26 | /// presentation system will animate the modal from these values into the values returned by 27 | /// `displayValues(for:)`. 28 | /// 29 | /// You should add support for `UIAccessibility.prefersCrossFadeTransitions` in your implementation for this 30 | /// function. E.g.: 31 | /// ```` 32 | /// public func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 33 | /// if !context.isInteractive, style.prefersCrossFadeTransitions { 34 | /// return .crossFadeValues(from: displayValues(for: context)) 35 | /// } 36 | /// 37 | /// ... // Return the standard transition values here. 38 | /// } 39 | /// ```` 40 | func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues 41 | 42 | /// Gets the final values to use during the transition when this modal is disappearing. The 43 | /// presentation system will animate the modal from the values returned by `displayValues(for:)` 44 | /// to these values before removing it. 45 | /// 46 | /// You should add support for `UIAccessibility.prefersCrossFadeTransitions` in your implementation for this 47 | /// function. E.g.: 48 | /// ```` 49 | /// public func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 50 | /// if !context.isInteractive, style.prefersCrossFadeTransitions { 51 | /// return .crossFadeValues(from: displayValues(for: context)) 52 | /// } 53 | /// 54 | /// ... // Return the standard transition values here. 55 | /// } 56 | /// ```` 57 | func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues 58 | 59 | /// Get the values to pan to when an interactive gesture is panned in the opposite direction of 60 | /// the outgoing direction. If no values are returned, then the interaction will have a 61 | /// hard-stop when panning in that direction, rather than rubber-banding. 62 | func reverseTransitionValues(for context: ModalPresentationContext) -> ModalReverseTransitionValues? 63 | 64 | /// Customizes the environment that is propagated to this modal's content. 65 | /// 66 | /// Modals inherit the environment from the view controller that presents them. You can use this 67 | /// customization point to apply any changes, and they will propagate to the modal as well as 68 | /// any content it subsequently presents. 69 | /// 70 | /// This is the recommended a way to communicate to the content about what type of modal style 71 | /// it is presented in. 72 | func customize(environment: inout ViewEnvironment) 73 | } 74 | 75 | extension ModalPresentationStyle { 76 | 77 | public func reverseTransitionValues(for context: ModalPresentationContext) -> ModalReverseTransitionValues? { 78 | nil 79 | } 80 | 81 | public func customize(environment: inout ViewEnvironment) {} 82 | } 83 | 84 | -------------------------------------------------------------------------------- /Modals/Sources/ModalPresentationStyleProvider.swift: -------------------------------------------------------------------------------- 1 | import ViewEnvironment 2 | 3 | 4 | /// This type provides a `ModalPresentationStyle` based on the `ViewEnvironment`. 5 | /// 6 | /// Modals sometimes need dynamic styles that can be updated when the "environment" changes (e.g., 7 | /// the theme or some traits about the context the modal is rendered in have changed). This type 8 | /// allows us to symbolically represent styles, by deferring resolving the actual 9 | /// `ModalPresentationStyle` until the environment is available, and allows us to get an updated 10 | /// style when the environment changes. 11 | /// 12 | public struct ModalPresentationStyleProvider { 13 | 14 | /// Closure alias that takes in a `ViewEnvironment` and returns a `ModalPresentationStyle`. 15 | public typealias ProvideStyle = (ViewEnvironment) -> ModalPresentationStyle 16 | 17 | /// The provider closure. 18 | public var provider: ProvideStyle 19 | 20 | /// Create a new provider. 21 | /// - Parameters: 22 | /// - provideStyle: A closure to resolve a modal style from the environment. 23 | public init(_ provideStyle: @escaping ProvideStyle) { 24 | provider = provideStyle 25 | } 26 | 27 | /// Create a new provider with a concrete style. 28 | /// - Parameters: 29 | /// - style: A modal style. 30 | public init(_ style: ModalPresentationStyle) { 31 | self.init { _ in style } 32 | } 33 | 34 | /// Convenience method for fetching the presentation style for a given environment. 35 | public func presentationStyle(for environment: ViewEnvironment) -> ModalPresentationStyle { 36 | provider(environment) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Modals/Sources/ModalReverseTransitionValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Reverse transition values describe how a modal should lay out during an interactive dismiss 4 | /// that is panned in the opposite direction of the outgoing direction. The reverse values will 5 | /// be scrubbed to by the interaction with an expontential decay for a spring effect. 6 | public struct ModalReverseTransitionValues { 7 | /// The frame, in the coordinate space of the container specified by the 8 | /// `ModalPresentationContext`. Generally this should be offset by ~80pts in the opposite 9 | /// direction of your dismissal, or offset and with the size increased for stretchy behavior. 10 | public var frame: CGRect 11 | 12 | /// A transform to scrub to when interacting in the reverse direction of the dismiss. 13 | public var transform: CGAffineTransform 14 | 15 | /// Create a new set of modal display values. 16 | public init( 17 | frame: CGRect, 18 | transform: CGAffineTransform = .identity 19 | ) { 20 | self.frame = frame 21 | self.transform = transform 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Modals/Sources/ModalRoundedCorners.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A corner rounding style that can be applied to presented modals from `ModalDisplayValues` or 4 | /// `ModalTransitionValues`. 5 | public struct ModalRoundedCorners { 6 | /// Square corners. 7 | public static let none = ModalRoundedCorners(radius: 0, corners: [], curve: .circular) 8 | 9 | /// The corner radius. 10 | public var radius: CGFloat 11 | /// The set of corners to be rounded. 12 | public var corners: CACornerMask 13 | /// The shape of the rounding. 14 | public var curve: Curve 15 | 16 | /// Create a new set of rounded corners. 17 | /// - Parameters: 18 | /// - radius: The corner radius. 19 | /// - corners: The set of corners to be rounded. Defaults to all corners. 20 | /// - curve: The shape of the curve. Defaults to a continuous curve. 21 | public init( 22 | radius: CGFloat, 23 | corners: CACornerMask = .all, 24 | curve: Curve = .continuous 25 | ) { 26 | self.radius = radius 27 | self.corners = corners 28 | self.curve = curve 29 | } 30 | 31 | /// A wrapper for `CALayerCornerCurve`. 32 | public enum Curve: Int { 33 | /// Corresponds to `CALayerCornerCurve.circular`. 34 | case circular 35 | /// Corresponds to `CALayerCornerCurve.continuous`. 36 | case continuous 37 | 38 | var caLayerCornerCurve: CALayerCornerCurve { 39 | switch self { 40 | case .circular: .circular 41 | case .continuous: .continuous 42 | } 43 | } 44 | } 45 | 46 | public func apply(toView view: UIView) { 47 | view.layer.cornerRadius = radius 48 | view.layer.maskedCorners = corners 49 | view.layer.cornerCurve = curve.caLayerCornerCurve 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Modals/Sources/ModalShadow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Options for a shadow to be placed behind a modal. 4 | public struct ModalShadow: Equatable { 5 | public static let none = ModalShadow(radius: 0, opacity: 0, offset: .zero, color: .black) 6 | 7 | /// The blur radius of the shadow. 8 | public var radius: CGFloat 9 | 10 | /// The opacity of the shadow. 11 | public var opacity: CGFloat 12 | 13 | /// The offset of the shadow. 14 | public var offset: UIOffset 15 | 16 | /// The color of the shadow. 17 | public var color: UIColor 18 | 19 | /// Create a new shadow 20 | public init(radius: CGFloat, opacity: CGFloat, offset: UIOffset, color: UIColor) { 21 | self.radius = radius 22 | self.opacity = opacity 23 | self.offset = offset 24 | self.color = color 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Modals/Sources/ModalTransitionValues+CrossFade.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | 5 | extension ModalTransitionValues { 6 | 7 | /// Derives a set of transition values for a simple cross fade, by taking the final display values and setting the 8 | /// opacity values to `0`. You can use this convenience to replace a moving transition when 9 | /// `UIAccessibility.prefersCrossFadeTransitions` is `true` and ``ModalPresentationContext/isInteractive`` is 10 | /// `false`. 11 | public static func crossFadeValues( 12 | from displayValues: ModalDisplayValues, 13 | animation: ModalAnimation = .curve(.easeInOut, duration: 0.3) 14 | ) -> Self { 15 | ModalTransitionValues( 16 | frame: displayValues.frame, 17 | alpha: 0, 18 | transform: .identity, 19 | overlayOpacity: 0, 20 | roundedCorners: displayValues.roundedCorners, 21 | decorationOpacity: 0, 22 | animation: animation 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Modals/Sources/ModalTransitionValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Transition values describe how a modal should appear at the start or end of its enter or 4 | /// exit transitions. Unless otherwise specified, the transition from or to these values will be 5 | /// animated. 6 | public struct ModalTransitionValues { 7 | /// The frame, in the coordinate space of the container specified by the 8 | /// `ModalPresentationContext`. 9 | public var frame: CGRect 10 | /// An alpha value to apply to the modal's view. Defaults to `1`. 11 | public var alpha: CGFloat 12 | /// A transform to apply to the modal. 13 | public var transform: CGAffineTransform 14 | /// A corner style to apply to the modal during transitions. This value is applied immediately 15 | /// at the start of transitions and is not animated. Defaults to `.none`, for square corners. 16 | public var roundedCorners: ModalRoundedCorners 17 | /// An opacity to apply to the overlay view behind the modal. Defaults to `0`, for a 18 | /// completely transparent overlay view. 19 | public var overlayOpacity: CGFloat 20 | /// An opacity to apply to decorations during transitions 21 | public var decorationOpacity: CGFloat 22 | /// The animation to use during the transition. Defaults to `.spring()` which matches the system. 23 | public var animation: ModalAnimation 24 | 25 | /// Create a new set of transition values. 26 | public init( 27 | frame: CGRect, 28 | alpha: CGFloat = 1, 29 | transform: CGAffineTransform = .identity, 30 | overlayOpacity: CGFloat = 0, 31 | roundedCorners: ModalRoundedCorners = .none, 32 | decorationOpacity: CGFloat = 0, 33 | animation: ModalAnimation = .spring() 34 | ) { 35 | self.frame = frame 36 | self.alpha = alpha 37 | self.transform = transform 38 | self.overlayOpacity = overlayOpacity 39 | self.roundedCorners = roundedCorners 40 | self.decorationOpacity = decorationOpacity 41 | self.animation = animation 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Modals/Sources/ModalWidthSizing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The sizing behavior for the width of a modal. 4 | public enum ModalWidthSizing { 5 | /// The default width, from the stylesheet. This is the default option 6 | case `default` 7 | /// A dynamic width, based off the `ModalPresentationContext`. If the width is larger than the 8 | /// available space, it will be rendered to fill the available space 9 | case dynamic((ModalPresentationContext) -> (CGFloat)) 10 | 11 | /// An explicit width, in pixels. If the width is larger than the available space, it will be 12 | /// rendered to fill the available space 13 | public static func explicit(_ width: CGFloat) -> Self { 14 | .dynamic { _ in width } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Modals/Sources/ModalsLogging.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | 3 | public enum ModalsLogging { 4 | public static let defaultLoggerLabel = "com.squareup.modals" 5 | public static let logger: Logging.Logger = Logger(label: ModalsLogging.defaultLoggerLabel) 6 | } 7 | -------------------------------------------------------------------------------- /Modals/Sources/PreferredContentSize.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ViewEnvironment 3 | 4 | 5 | extension UIViewController { 6 | 7 | /// Informs the view controller hierarchy that the `preferredContentSize` should 8 | /// be calculated, as it is required for its display / presentation. 9 | /// 10 | /// Defaults to `false`. 11 | /// 12 | /// ### When To Set This Value 13 | /// Set this value to `true` if your view controller or any of its 14 | /// children should calculate their `preferredContentSize` for proper display within 15 | /// a self-sizing modal, or other presentation context that requires self-sizing view controllers. 16 | /// 17 | /// ### When To Read This Value 18 | /// When `true`, view controllers which can calculate 19 | /// `preferredContentSize` should do so, in order to provide proper sizing to 20 | /// their containing modal or layout. The getter traverses the the parent view controller hierarchy 21 | /// to determine if any parent view controllers have requested a content size. 22 | /// 23 | /// ### Note 24 | /// You usually do not need to set this value yourself; it will be set by the `Modals` framework 25 | /// automatically. You only need to set this value yourself if you are managing your own presentation. 26 | /// 27 | public var presentationContextWantsPreferredContentSize: Bool { 28 | 29 | set { 30 | objc_setAssociatedObject(self, &Self.key, newValue, .OBJC_ASSOCIATION_RETAIN) 31 | } 32 | 33 | get { 34 | for vc in sequence(first: self, next: \.parent) { 35 | let wantsSize = objc_getAssociatedObject(vc, &Self.key) as? Bool ?? false 36 | 37 | if wantsSize { 38 | return true 39 | } 40 | } 41 | 42 | return false 43 | } 44 | } 45 | 46 | private static var key: UInt8 = 0 47 | } 48 | -------------------------------------------------------------------------------- /Modals/Sources/PresentableModal.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Contains a view controller and all the information needed to present it modally. 4 | /// 5 | /// PresentableModal instances are attached to view controllers and aggregated up the view controller hierarchy 6 | /// using the `UIViewController.aggregateModals` extension. 7 | /// 8 | /// Generally, you should not need to create `PresentableModal` instances yourself. Instead, use one of the 9 | /// following methods to present modals: 10 | /// 11 | /// From a vanilla view controller, use `UIViewController.presenter` to get a `ModalPresenter`, and 12 | /// call `ModalPresenter.present(_:,style:,completion:)`. 13 | /// 14 | /// From a workflow, render a `ModalContainer` screen containing your screen and the screens 15 | /// of any modals you want to present above it. 16 | /// 17 | /// ## See Also: 18 | /// - [ModalPresenter.present(_:,style:,completion:)](x-source-tag://ModalPresenter.present) 19 | /// - [ModalContainer](x-source-tag://ModalContainer) 20 | /// 21 | public final class PresentableModal { 22 | /// The view controller to be presented modally. 23 | public let viewController: UIViewController 24 | 25 | /// Describes the appearance and behavior of the modal presentation, including: 26 | /// - the container size and position 27 | /// - chrome UI, such as shadows and the overlay view 28 | /// - transitions 29 | /// 30 | public let presentationStyle: ModalPresentationStyle 31 | 32 | /// Additional information associated with the modal presentation. 33 | public let info: ModalInfo 34 | 35 | /// A closure that will be called after the modal has been presented. 36 | public let onDidPresent: (() -> Void)? 37 | 38 | /// Create a new modal. 39 | public init( 40 | viewController: UIViewController, 41 | presentationStyle: ModalPresentationStyle, 42 | info: ModalInfo, 43 | onDidPresent: (() -> Void)? 44 | ) { 45 | self.viewController = viewController 46 | self.presentationStyle = presentationStyle 47 | self.info = info 48 | self.onDidPresent = onDidPresent 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Modals/Sources/ShadowView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | final class ShadowView: UIView { 5 | let clippingView: ClippingView 6 | 7 | init(content: UIView) { 8 | clippingView = ClippingView(content: content) 9 | 10 | super.init(frame: .zero) 11 | 12 | addSubview(clippingView) 13 | clipsToBounds = false 14 | } 15 | 16 | required init?(coder: NSCoder) { 17 | fatalError("init(coder:) has not been implemented") 18 | } 19 | 20 | override func layoutSubviews() { 21 | super.layoutSubviews() 22 | clippingView.frame = bounds 23 | } 24 | 25 | func apply(shadow: ModalShadow, corners: ModalRoundedCorners) { 26 | let shadowPath = UIBezierPath( 27 | roundedRect: bounds, 28 | byRoundingCorners: .init(cornerMask: corners.corners), 29 | cornerRadii: .init(width: corners.radius, height: corners.radius) 30 | ).cgPath 31 | 32 | layer.shadowRadius = shadow.radius 33 | layer.shadowOpacity = Float(shadow.opacity) 34 | layer.shadowOffset = CGSize(shadow.offset) 35 | layer.shadowColor = shadow.color.cgColor 36 | layer.shadowPath = shadowPath 37 | } 38 | 39 | /// This method is overridden to provide an action (e.g., animation) for the `CALayer.shadowPath` event; 40 | /// this ensure the shadow implicitly animates alongside changes in the views size. 41 | override func action(for layer: CALayer, forKey event: String) -> CAAction? { 42 | let keyPath = #keyPath(CALayer.shadowPath) 43 | 44 | guard event == keyPath, 45 | let currentPath = layer.shadowPath, 46 | let sizeAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation 47 | else { 48 | return super.action(for: layer, forKey: event) 49 | } 50 | 51 | let animation = sizeAnimation.copy() as! CABasicAnimation 52 | animation.keyPath = keyPath 53 | animation.fromValue = currentPath 54 | return animation 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/PresentableToast.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | /// Contains a view controller and all the information needed to present it. 5 | /// 6 | /// PresentableToast instances are attached to view controllers and aggregated up the view controller hierarchy using 7 | /// the `UIViewController.aggregateModals` extension. 8 | /// 9 | /// Generally, you should not need to create `PresentableToast` instances yourself. Instead, use one of the following 10 | /// methods to present toasts: 11 | /// 12 | /// From a vanilla view controller, use `UIViewController.toastPresenter` to get a `ModalPresenter`, and call 13 | /// `ToastPresenter.present(_:,style:,accessibilityAnnouncement:)`. 14 | /// 15 | /// From a workflow, render a `ToastContainer` screen containing your screen and the screens of any toasts you want to 16 | /// present above it. 17 | /// 18 | /// ## See Also: 19 | /// - [ToastPresenter.present(_:,style:,accessibilityAnnouncement:)](x-source-tag://ToastPresenter.present) 20 | /// - [ToastContainer](x-source-tag://ToastContainer) 21 | /// 22 | public final class PresentableToast { 23 | 24 | /// The view controller to be presented. 25 | public let viewController: UIViewController 26 | 27 | /// Describes the behavior of the toast presentation (e.g. auto-dismiss, interactive dismissal, etc.). 28 | /// 29 | public let presentationStyle: ToastPresentationStyle 30 | 31 | /// The text to read using VoiceOver when the toast is displayed. 32 | /// 33 | public let accessibilityAnnouncement: String 34 | 35 | /// Creates a new toast. 36 | public init( 37 | viewController: UIViewController, 38 | presentationStyle: ToastPresentationStyle, 39 | accessibilityAnnouncement: String 40 | ) { 41 | self.viewController = viewController 42 | self.presentationStyle = presentationStyle 43 | self.accessibilityAnnouncement = accessibilityAnnouncement 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastBehaviorContext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Contextual information provided to a `ToastPresentationStyle` when getting display preferences. 5 | public struct ToastBehaviorContext { 6 | /// Create a preference context. 7 | public init() {} 8 | } 9 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastBehaviorPreferences.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | 5 | /// Toast behavior preferences are used by the toast presentation system to configure certain behaviors, such as whether 6 | /// the toast should be auto-dismissed after a delay or support interactive dismissal. 7 | /// 8 | public struct ToastBehaviorPreferences { 9 | 10 | /// The timed auto-dismiss behavior. 11 | /// 12 | public enum TimedDismissBehavior { 13 | 14 | /// Disables the timed auto-dismiss behavior. 15 | /// 16 | case disabled 17 | 18 | /// Dismisses the toast after the provided duration. 19 | /// 20 | case after(duration: TimeInterval, onDismiss: () -> Void) 21 | } 22 | 23 | /// The interactive dismissal behavior. 24 | /// 25 | public enum InteractiveDismissBehavior { 26 | 27 | /// Disables interactive dismissal. 28 | /// 29 | case disabled 30 | 31 | /// Dismisses the toast when the toast is swiped downward. 32 | /// 33 | /// Calls the provided closure when the dismiss animation completes. The toast should be dismissed (removed from 34 | /// aggregation) when this closure is called. 35 | /// 36 | case swipeDown(onDismiss: () -> Void) 37 | } 38 | 39 | /// The haptic feedback that should be performed when the toast is presented. 40 | /// 41 | public var presentationHaptic: UINotificationFeedbackGenerator.FeedbackType 42 | 43 | /// The timed auto-dismiss behavior. 44 | /// 45 | public var timedDismiss: TimedDismissBehavior 46 | 47 | /// The interactive dismissal behavior. 48 | /// 49 | public var interactiveDismiss: InteractiveDismissBehavior 50 | 51 | /// Creates toast behavior preferences. 52 | /// 53 | public init( 54 | presentationHaptic: UINotificationFeedbackGenerator.FeedbackType, 55 | timedDismiss: TimedDismissBehavior, 56 | interactiveDismiss: InteractiveDismissBehavior 57 | ) { 58 | self.presentationHaptic = presentationHaptic 59 | self.timedDismiss = timedDismiss 60 | self.interactiveDismiss = interactiveDismiss 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastContainerPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// The toast presentation system uses a toast presentation style to determine the appearance and behavior of the toast 5 | /// container. This includes: 6 | /// - the size and position of presented toasts 7 | /// - transitions 8 | /// - chrome UI, such as shadows 9 | /// 10 | /// This the is the main customization point for changing the way toasts appear on screen. 11 | /// 12 | /// - Note: Adding conformance to `Equatable` will allow for the default implementation of `isEqual(to:)` to be used. 13 | /// 14 | public protocol ToastContainerPresentationStyle { 15 | 16 | /// Calculates the view state of all toasts as if they are in the "presented" state. 17 | /// 18 | /// The context provided to this function includes the sizes of each toast "preheated". 19 | func displayValues(for context: ToastDisplayContext) -> ToastDisplayValues 20 | 21 | /// Calculates the view state of a specific toast for the enter transition. 22 | /// 23 | /// All values returned should describe the view state of the toast at the start of the transition. 24 | /// 25 | /// The provided context includes the frame of the toast in the "presented" state. 26 | func enterTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues 27 | 28 | /// Calculates the view state of a specific toast for the exit transition. 29 | /// 30 | /// All values returned should describe the view state of the toast at the end of the transition. 31 | /// 32 | /// The provided context includes the frame of the toast in the "presented" state. 33 | func exitTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues 34 | 35 | /// Calculates the view state of a specific toast during an interactive exit transition. 36 | /// 37 | /// All values returned should describe the view state of the toast at the end of the transition. 38 | /// 39 | /// The provided context includes the frame of the toast in the "presented" state. 40 | func interactiveExitTransitionValues(for context: ToastInteractiveExitContext) -> ToastTransitionValues 41 | 42 | /// Calculates the view state of a specific toast during a reverse interactive exit transition. 43 | /// 44 | /// All values returned should describe the view state of the toast at the end of the transition. 45 | /// 46 | /// The provided context includes the frame of the toast in the "presented" state. 47 | func reverseTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues 48 | 49 | /// Returns the available size for each toast to layout in during the "preheat" pass 50 | /// 51 | /// The Toast's view controller contents are laid out in sizes returned from this function before the 52 | /// `preferredContentSize` is queried. 53 | func preheatValues(for context: ToastPreheatContext) -> ToastPreheatValues 54 | 55 | /// Determines if two instances of a presentation style are equal. 56 | /// 57 | /// This is used to skip layouts when the environment updates, yet no changes to the style have been made between 58 | /// those updates. 59 | /// 60 | /// Conforming types can conform to `Equatable` to get an implementation of this function "for free". 61 | func isEqual(to other: ToastContainerPresentationStyle) -> Bool 62 | } 63 | 64 | extension ToastContainerPresentationStyle where Self: Equatable { 65 | 66 | public func isEqual(to other: ToastContainerPresentationStyle) -> Bool { 67 | guard let other = other as? Self else { 68 | return false 69 | } 70 | 71 | return self == other 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastContainerPresentationStyleProvider.swift: -------------------------------------------------------------------------------- 1 | import ViewEnvironment 2 | 3 | 4 | /// This type provides a `ToastContainerPresentationStyle` based on the generic `Environment` type. 5 | /// 6 | /// Toasts sometimes need dynamic styles that can be updated when the "environment" changes (e.g., the theme or some 7 | /// traits about the context the toast is rendered in have changed). This type allows us to symbolically represent 8 | /// styles, by deferring resolving the actual `ToastContainerPresentationStyle` until the environment is available, and 9 | /// allows us to get an updated style when the environment changes. 10 | /// 11 | /// The toast presentation infrastructure uses the concrete `ToastContainerPresentationStyleProvider` 12 | /// typealias for toast presentation, but this type is generic so it can be used with other environment types. 13 | /// 14 | public struct ToastContainerPresentationStyleProvider { 15 | 16 | /// Closure alias that takes in an `Environment` and returns a `ToastContainerPresentationStyle`. 17 | /// 18 | public typealias ProvideStyle = (ViewEnvironment) -> ToastContainerPresentationStyle 19 | 20 | /// The provider closure. 21 | /// 22 | public var provider: ProvideStyle 23 | 24 | /// Create a new `ToastContainerPresentationStyleProvider` that builds a style with a provider. 25 | /// 26 | public init(_ provideStyle: @escaping ProvideStyle) { 27 | provider = provideStyle 28 | } 29 | 30 | /// Create a new `ToastContainerPresentationStyleProvider` with a concrete style. 31 | /// 32 | public init(_ style: ToastContainerPresentationStyle) { 33 | provider = { _ in style } 34 | } 35 | 36 | /// Convenience method for fetching the presentation style for a given environment. 37 | /// 38 | public func style(for environment: ViewEnvironment) -> ToastContainerPresentationStyle { 39 | provider(environment) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastDisplayContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// Contextual information provided to a `ToastContainerPresentationStyle` when getting display values. 5 | /// 6 | public struct ToastDisplayContext { 7 | 8 | /// Contains resolved preheat (a layout pass that resolves a `preferredContentSize`) values for each toast. 9 | /// 10 | public struct PreheatValues { 11 | 12 | /// The resolved `preferredContentSize` of the toast's backing view controller. 13 | /// 14 | public var preferredContentSize: CGSize 15 | 16 | /// Creates a new set of preheat values. 17 | /// 18 | public init(preferredContentSize: CGSize) { 19 | self.preferredContentSize = preferredContentSize 20 | } 21 | } 22 | 23 | /// The size of the presentation container. 24 | /// 25 | public var containerSize: CGSize 26 | 27 | /// The safe area insets of the container. 28 | /// 29 | /// - Note: This accounts for the keyboard frame when appropriate. 30 | /// 31 | public var safeAreaInsets: UIEdgeInsets 32 | 33 | /// The natural scale factor associated with the screen the toasts are presented in. 34 | /// 35 | public var scale: CGFloat 36 | 37 | /// A collection of preheat (a layout pass that resolves a `preferredContentSize`) values that represent each toast 38 | /// being presented. 39 | /// 40 | /// This array is ordered in the order that toasts are presented in (oldest first). 41 | /// 42 | public var preheatValues: [PreheatValues] 43 | 44 | /// Creates a new display context. 45 | /// 46 | public init( 47 | containerSize: CGSize, 48 | safeAreaInsets: UIEdgeInsets, 49 | scale: CGFloat, 50 | preheatValues: [PreheatValues] 51 | ) { 52 | self.containerSize = containerSize 53 | self.safeAreaInsets = safeAreaInsets 54 | self.scale = scale 55 | self.preheatValues = preheatValues 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastDisplayValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// The display values for all presented toasts. 5 | /// 6 | /// These values are used by the toast presentation system to position toasts, perform transitions, and add additional 7 | /// chrome (e.g. shadows). 8 | /// 9 | public struct ToastDisplayValues { 10 | 11 | /// An array of transition values for each of the presented toasts. 12 | /// 13 | /// This array is ordered in the order that toasts are presented in (oldest first). 14 | /// 15 | public var presentedValues: [ToastTransitionValues] 16 | 17 | /// Creates display values. 18 | /// 19 | public init(presentedValues: [ToastTransitionValues]) { 20 | self.presentedValues = presentedValues 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastInteractiveExitContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The contextual information provided to a `ToastContainerPresentationStyle` when getting interactive exit transition 4 | /// values. 5 | /// 6 | public struct ToastInteractiveExitContext { 7 | 8 | /// The frame of the toast when it is in the "presented" state. 9 | /// 10 | public var presentedFrame: CGRect 11 | 12 | /// The size of the presentation container. 13 | /// 14 | public var containerSize: CGSize 15 | 16 | /// The safe area insets of the container. 17 | /// 18 | /// - Note: This accounts for the keyboard frame when appropriate. 19 | /// 20 | public var safeAreaInsets: UIEdgeInsets 21 | 22 | /// The natural scale factor associated with the screen the toasts are presented in. 23 | /// 24 | public var scale: CGFloat 25 | 26 | /// The velocity vector of the recognized dismiss gesture. 27 | /// 28 | public var velocity: CGVector 29 | 30 | /// Creates a new interactive exit context. 31 | /// 32 | public init( 33 | presentedFrame: CGRect, 34 | containerSize: CGSize, 35 | safeAreaInsets: UIEdgeInsets, 36 | scale: CGFloat, 37 | velocity: CGVector 38 | ) { 39 | self.presentedFrame = presentedFrame 40 | self.containerSize = containerSize 41 | self.safeAreaInsets = safeAreaInsets 42 | self.scale = scale 43 | self.velocity = velocity 44 | } 45 | 46 | /// A transition context derived from the interactive exit context, useful for calculating 47 | /// interactive values based on static exit values. 48 | public var transitionContext: ToastTransitionContext { 49 | .init( 50 | displayFrame: presentedFrame, 51 | containerSize: containerSize, 52 | safeAreaInsets: safeAreaInsets, 53 | scale: scale 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPreheatContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | public struct ToastPreheatContext { 5 | 6 | public var containerSize: CGSize 7 | 8 | public var safeAreaInsets: UIEdgeInsets 9 | 10 | public init( 11 | containerSize: CGSize, 12 | safeAreaInsets: UIEdgeInsets 13 | ) { 14 | self.containerSize = containerSize 15 | self.safeAreaInsets = safeAreaInsets 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPreheatValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// A collection of values that describes the available size for a toast to layout in during the "preheat" pass. 5 | /// 6 | /// The toast's view controller contents are laid out in sizes returned from this function before the 7 | /// `preferredContentSize` is queried on the view controller. 8 | /// 9 | public struct ToastPreheatValues { 10 | 11 | /// The maximum size of a toast which is used during a preheat pass. 12 | /// 13 | /// The toast's view controller contents are laid out in this size before the `preferredContentSize` is queried on 14 | /// the view controller. 15 | /// 16 | public var size: CGSize 17 | 18 | /// Creates a new set of preheat values. 19 | /// 20 | public init(size: CGSize) { 21 | self.size = size 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This is a convenience for view controllers or screens to specify their own toast styles. Types 4 | /// do not need to conform to this protocol to be presented as toasts, but this allows types to 5 | /// provide a standard `ToastPresentationStyleProvider` to be presented with. 6 | /// 7 | /// If your view controller or workflow screen has a standard toast presentation style, it can 8 | /// conform to this protocol and return that style from the `presentationStyle` property so 9 | /// consumers don't have to specify a style. In UIKit, `ToastPresenter` has a `present` method for 10 | /// view controllers that conform to this protocol. In Workflows, `Toast` has an initializer that 11 | /// takes in screens conforming to this protocol. 12 | /// 13 | public protocol ToastPresentable { 14 | 15 | /// The text to announce using VoiceOver when the toast is displayed. 16 | /// 17 | var accessibilityAnnouncement: String { get } 18 | 19 | /// A provider that vends a `ToastPresentationStyle` from an `Environment`. 20 | /// 21 | var presentationStyle: ToastPresentationStyleProvider { get } 22 | } 23 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// The presentation style to use for an individual toast. 5 | /// 6 | /// This style describes the behavior preferences for the toast (e.g. auto-dismiss, interactive dismissal, etc.)—to 7 | /// adjust appearance preferences, see [ToastContainerPresentationStyle](x-source-tag://ToastContainerPresentationStyle). 8 | /// 9 | public protocol ToastPresentationStyle { 10 | 11 | /// The behavior preferences of this toast presentation. 12 | /// 13 | func behaviorPreferences(for context: ToastBehaviorContext) -> ToastBehaviorPreferences 14 | } 15 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentationStyleProvider.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ViewEnvironment 3 | 4 | 5 | /// This type provides a `ToastPresentationStyle` based on the `ViewEnvironment`. 6 | /// 7 | /// Toasts sometimes need dynamic styles that can be updated when the "environment" changes (e.g., the theme or some 8 | /// traits about the context the toast is rendered in have changed). This type allows us to symbolically represent 9 | /// styles, by deferring resolving the actual `ToastPresentationStyle` until the environment is available, and allows us 10 | /// to get an updated style when the environment changes. 11 | /// 12 | public struct ToastPresentationStyleProvider { 13 | 14 | /// Closure alias that takes in an `Environment` and returns a `ToastPresentationStyle`. 15 | /// 16 | public typealias ProvideStyle = (ViewEnvironment) -> ToastPresentationStyle 17 | 18 | /// The provider closure. 19 | /// 20 | public var provider: ProvideStyle 21 | 22 | /// Create a new `ToastPresentationStyleProvider` that builds a style with a provider. 23 | /// 24 | public init(_ provideStyle: @escaping ProvideStyle) { 25 | provider = provideStyle 26 | } 27 | 28 | /// Create a new `ToastContainerPresentationStyleProvider` with a concrete style. 29 | /// 30 | public init(_ style: ToastPresentationStyle) { 31 | provider = { _ in style } 32 | } 33 | 34 | /// Convenience method for fetching the presentation style for a given environment. 35 | /// 36 | public func presentationStyle(for environment: ViewEnvironment) -> ToastPresentationStyle { 37 | provider(environment) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentationViewController+StyleContexts.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | 5 | extension ToastPresentationViewController { 6 | 7 | private var containerSize: CGSize { 8 | presentationView.frame.size 9 | } 10 | 11 | func makeDisplayContext(presentations: [Presentation]) -> ToastDisplayContext { 12 | ToastDisplayContext( 13 | containerSize: containerSize, 14 | safeAreaInsets: adjustedSafeAreaInsets, 15 | scale: view.layer.contentsScale, 16 | preheatValues: presentations.map { presentation in 17 | .init(preferredContentSize: presentation.viewController.preferredContentSize) 18 | } 19 | ) 20 | } 21 | 22 | func makeTransitionContext(presentedFrame: CGRect) -> ToastTransitionContext { 23 | ToastTransitionContext( 24 | displayFrame: presentedFrame, 25 | containerSize: containerSize, 26 | safeAreaInsets: adjustedSafeAreaInsets, 27 | scale: view.layer.contentsScale 28 | ) 29 | } 30 | 31 | func makeInteractiveExitContext( 32 | presentedFrame: CGRect, 33 | velocity: CGVector 34 | ) -> ToastInteractiveExitContext { 35 | ToastInteractiveExitContext( 36 | presentedFrame: presentedFrame, 37 | containerSize: containerSize, 38 | safeAreaInsets: adjustedSafeAreaInsets, 39 | scale: view.layer.contentsScale, 40 | velocity: velocity 41 | ) 42 | } 43 | 44 | func makeBehaviorContext() -> ToastBehaviorContext { 45 | ToastBehaviorContext() 46 | } 47 | 48 | func makePreheatContext() -> ToastPreheatContext { 49 | ToastPreheatContext( 50 | containerSize: containerSize, 51 | safeAreaInsets: adjustedSafeAreaInsets 52 | ) 53 | } 54 | 55 | private var adjustedSafeAreaInsets: UIEdgeInsets { 56 | let view: UIView = presentationView 57 | var safeAreaInsets = max(view.safeAreaInsets, safeAreaAnchorInsets) 58 | 59 | guard let keyboardFrame = keyboardObserver.currentFrame(in: view) else { 60 | return safeAreaInsets 61 | } 62 | 63 | switch keyboardFrame { 64 | case .nonOverlapping: 65 | return safeAreaInsets 66 | 67 | case .overlapping(frame: let overlappingFrame): 68 | guard overlappingFrame.maxY >= view.frame.maxY else { 69 | // Keyboard is likely floating—don't adjust insets. 70 | // TODO: Better handle fluid layout around floating keyboard 71 | return safeAreaInsets 72 | } 73 | 74 | let keyboardOffset = view.frame.maxY - overlappingFrame.minY 75 | safeAreaInsets.bottom = max(safeAreaInsets.bottom, keyboardOffset) 76 | 77 | return safeAreaInsets 78 | } 79 | } 80 | } 81 | 82 | 83 | private func max(_ lhs: UIEdgeInsets, _ rhs: UIEdgeInsets) -> UIEdgeInsets { 84 | .init( 85 | top: max(lhs.top, rhs.top), 86 | left: max(lhs.left, rhs.left), 87 | bottom: max(lhs.bottom, rhs.bottom), 88 | right: max(lhs.right, rhs.right) 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentationViewController+Timer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | extension ToastPresentationViewController { 5 | 6 | func configureTimer( 7 | presentation: Presentation, 8 | behaviorPreferences: ToastBehaviorPreferences 9 | ) { 10 | let now = Date() 11 | var displayStartDate: Date 12 | if let startTime = presentation.displayStartTime { 13 | displayStartDate = startTime 14 | } else { 15 | displayStartDate = now 16 | presentation.displayStartTime = displayStartDate 17 | } 18 | 19 | switch behaviorPreferences.timedDismiss { 20 | case .disabled: 21 | presentation.autoDismissDelay = nil 22 | 23 | case .after(duration: let duration, onDismiss: let onDismiss): 24 | presentation.autoDismissDelay = duration 25 | 26 | let endTime = displayStartDate.addingTimeInterval(duration) 27 | 28 | let timer = Timer(fire: endTime, interval: 0, repeats: false) { _ in 29 | onDismiss() 30 | } 31 | 32 | RunLoop.main.add(timer, forMode: .common) 33 | presentation.autoDismissTimer = timer 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresentationViewControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Allows consumers of ``ToastPresentationViewController`` to respond to updates to its content. 5 | /// 6 | public protocol ToastPresentationViewControllerDelegate: AnyObject { 7 | 8 | /// Called when the ``ToastPresentationViewController``'s number of visible toasts transitions from none to some or 9 | /// visa versa. 10 | /// 11 | /// - Note: that this is different from whether or not ``PresentableToast``s are currently provided to the 12 | /// ``ToastPresentationViewController``—when a toast is removed form the list of ``PresentableToast``s it still 13 | /// needs to perform the transition out animation. 14 | /// 15 | func toastPresentationViewControllerDidChange(hasVisiblePresentations: Bool) 16 | } 17 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastPresenter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// A toast presenter provides methods to present a toast in an imperative fashion, similar to vanilla UIKit modal 5 | /// presentation. 6 | /// 7 | public protocol ToastPresenter { 8 | 9 | /// Presents a toast, by adding it to the list of toasts on the view controller that owns this presenter. 10 | /// 11 | /// This function returns a token that must be retained. To dismiss the toast, call the `dismiss` method on the 12 | /// token. If the token is deallocated, `dismiss` will be called automatically. 13 | /// 14 | /// - Parameters: 15 | /// - viewControllerToPresent: The view controller to present. 16 | /// - style: The style describes the appearance and behavior of the toast (such as auto-dismiss behaviors). 17 | /// - accessibilityAnnouncement: The text to announce using VoiceOver when the toast is presented. 18 | /// - Returns: A token that must be kept to dismiss the toast. 19 | /// 20 | /// - Tag: ToastPresenter.present 21 | /// 22 | func present( 23 | _ viewControllerToPresent: UIViewController, 24 | style: ToastPresentationStyleProvider, 25 | accessibilityAnnouncement: String 26 | ) -> ModalLifetime 27 | } 28 | 29 | 30 | extension ToastPresenter { 31 | 32 | /// Presents a toast, by adding it to the list of modals on the view controller that owns this presenter. 33 | /// 34 | /// - Parameters: 35 | /// - viewControllerToPresent: The view controller to present. 36 | /// - Returns: A token that must be kept to dismiss the toast. 37 | /// 38 | /// ## See Also 39 | /// [present(_:style:accessibilityAnnouncement:)](x-source-tag://ToastPresenter.present) 40 | /// 41 | public func present( 42 | _ viewControllerToPresent: some UIViewController & ToastPresentable 43 | ) -> ModalLifetime { 44 | present( 45 | viewControllerToPresent, 46 | style: viewControllerToPresent.presentationStyle, 47 | accessibilityAnnouncement: viewControllerToPresent.accessibilityAnnouncement 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastSafeAreaAnchor.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | // The Obj-C Associated Object API requires this to be mutable. 5 | private var storedToastSafeAreaAnchorKey: UInt8 = 0 6 | 7 | 8 | extension UIViewController { 9 | 10 | /// Defines the safe area for toasts presented with the `Modals` framework. 11 | /// 12 | /// - Note: These anchors are only respected as long as no modals are present. 13 | /// 14 | public var toastSafeAreaAnchor: ToastSafeAreaAnchor { 15 | let anchor: ToastSafeAreaAnchor 16 | if let existingAnchor = storedToastSafeAreaAnchor { 17 | anchor = existingAnchor 18 | } else { 19 | anchor = ToastSafeAreaAnchor(onChange: { [weak self] in 20 | self?.modalHost?.setNeedsModalUpdate() 21 | }) 22 | objc_setAssociatedObject(self, &storedToastSafeAreaAnchorKey, anchor, .OBJC_ASSOCIATION_RETAIN) 23 | } 24 | 25 | if let view = viewIfLoaded { 26 | anchor.coordinateSpace = view 27 | } 28 | 29 | return anchor 30 | } 31 | 32 | /// If `toastSafeAreaAnchor` was previously accessed, `toastSafeAreaAnchor`. Otherwise,`nil`.` 33 | var storedToastSafeAreaAnchor: ToastSafeAreaAnchor? { 34 | objc_getAssociatedObject( 35 | self, 36 | &storedToastSafeAreaAnchorKey 37 | ) as? ToastSafeAreaAnchor 38 | } 39 | } 40 | 41 | 42 | /// Defines the safe area for toasts presented with the `Modals` framework. 43 | /// 44 | /// - Note: These anchors are only respected as long as no modals are present. 45 | /// 46 | public class ToastSafeAreaAnchor { 47 | 48 | /// Defines the edge insets which should indicate the edges that should influence the presented toasts safe area 49 | /// insets as well as the amount to be inset relative to the ``coordinateSpace``. 50 | /// 51 | /// A `nil` value for any edge indicates that it will not influence the safe are region for toasts. 52 | /// 53 | public struct EdgeInsets: Equatable { 54 | 55 | /// The amount to inset the safe area from the top iff non-nil. 56 | /// 57 | public var top: CGFloat? 58 | 59 | /// The amount to inset the safe area from the left iff non-nil. 60 | /// 61 | public var left: CGFloat? 62 | 63 | /// The amount to inset the safe area from the bottom iff non-nil. 64 | /// 65 | public var bottom: CGFloat? 66 | 67 | /// The amount to inset the safe area from the right iff non-nil. 68 | /// 69 | public var right: CGFloat? 70 | 71 | /// The amount to inset the safe area for any edges that are provided with non-nil values. 72 | /// 73 | public init( 74 | top: CGFloat? = nil, 75 | left: CGFloat? = nil, 76 | bottom: CGFloat? = nil, 77 | right: CGFloat? = nil 78 | ) { 79 | self.top = top 80 | self.left = left 81 | self.bottom = bottom 82 | self.right = right 83 | } 84 | 85 | var hasLimits: Bool { 86 | self != .init() 87 | } 88 | } 89 | 90 | /// Defines the edge insets which should indicate the edges that should influence the presented toasts safe area 91 | /// insets as well as the amount to be inset relative to the ``coordinateSpace``. 92 | /// 93 | /// A `nil` value for any edge indicates that it will not influence the safe are region for toasts. 94 | /// 95 | public var edgeInsets: EdgeInsets = .init() { 96 | didSet { 97 | guard edgeInsets != oldValue else { return } 98 | 99 | onChange() 100 | } 101 | } 102 | 103 | /// The coordinate space that this anchor is associated with. 104 | /// 105 | /// The values of ``edgeInsets-swift.property`` will be relative to this coordinate space. 106 | /// 107 | weak var coordinateSpace: UICoordinateSpace? { 108 | didSet { 109 | guard coordinateSpace !== oldValue else { return } 110 | 111 | onChange() 112 | } 113 | } 114 | 115 | var onChange: () -> Void 116 | 117 | init(onChange: @escaping () -> Void) { 118 | self.onChange = onChange 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastTransitionContext.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Contextual information provided to `ToastContainerPresentationStyle` when getting transition values for an 4 | /// individual toast. 5 | /// 6 | public struct ToastTransitionContext { 7 | 8 | /// The frame of the toast in the container in the "presented" state. 9 | /// 10 | public var displayFrame: CGRect 11 | 12 | /// The size of the presentation container. 13 | /// 14 | public var containerSize: CGSize 15 | 16 | /// The safe area insets of the container. 17 | /// 18 | /// - Note: This accounts for the keyboard frame when appropriate. 19 | /// 20 | public var safeAreaInsets: UIEdgeInsets 21 | 22 | /// The natural scale factor associated with the screen the toasts are presented in. 23 | /// 24 | public var scale: CGFloat 25 | 26 | /// Creates a new transition context. 27 | /// 28 | public init( 29 | displayFrame: CGRect, 30 | containerSize: CGSize, 31 | safeAreaInsets: UIEdgeInsets, 32 | scale: CGFloat 33 | ) { 34 | self.displayFrame = displayFrame 35 | self.containerSize = containerSize 36 | self.safeAreaInsets = safeAreaInsets 37 | self.scale = scale 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Modals/Sources/Toasts/ToastTransitionValues.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// Transition values describe how a toast should appear at the start or end of its enter or exit transitions. 5 | /// 6 | public struct ToastTransitionValues { 7 | 8 | /// The target frame of the toast for this transition within the container's frame. 9 | /// 10 | public var frame: CGRect 11 | 12 | /// The target opacity for this transition. 13 | /// 14 | /// Defaults to `1`. 15 | /// 16 | public var alpha: CGFloat 17 | 18 | /// The target transform for this transition. 19 | /// 20 | /// Defaults to `.identity`. 21 | /// 22 | public var transform: CGAffineTransform 23 | 24 | /// The animation to use during the transition. Defaults to `.spring()` which matches the system. 25 | /// 26 | /// Defaults to `.spring()`. 27 | /// 28 | public var animation: ModalAnimation 29 | 30 | /// The shadow to use for this transition. 31 | /// 32 | /// The default value is `.none`. 33 | /// 34 | public var shadow: ModalShadow 35 | 36 | /// A corner style to apply to the modal during transitions. 37 | /// 38 | /// The default value is `.none`. 39 | /// 40 | public var roundedCorners: ModalRoundedCorners 41 | 42 | /// Create a new set of transition values. 43 | /// 44 | public init( 45 | frame: CGRect, 46 | alpha: CGFloat = 1, 47 | transform: CGAffineTransform = .identity, 48 | animation: ModalAnimation = .spring(), 49 | shadow: ModalShadow = .none, 50 | roundedCorners: ModalRoundedCorners = .none 51 | ) { 52 | self.frame = frame 53 | self.alpha = alpha 54 | self.transform = transform 55 | self.animation = animation 56 | self.shadow = shadow 57 | self.roundedCorners = roundedCorners 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Modals/Sources/TrampolineModalListObserver.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | @_spi(ViewEnvironmentWiring) import ViewEnvironmentUI 4 | 5 | /// A concrete implementation of `ModalListObserver` which observes `ModalListProvider`s and presents/dismisses 6 | /// modals and toasts defined by that observable as it changes over time as long as the associated observation lifetime 7 | /// is retained. 8 | /// 9 | final class TrampolineModalListObserver: ModalListObserver { 10 | 11 | weak var owner: UIViewController? 12 | var environmentUpdateObservationLifetime: ViewEnvironmentUpdateObservationLifetime? 13 | private var modalListObservations: [ModalListObservation] = [] 14 | 15 | init(owner: UIViewController) { 16 | self.owner = owner 17 | 18 | // Listen for environment updates 19 | environmentUpdateObservationLifetime = owner.addEnvironmentNeedsUpdateObserver { [weak self] environment in 20 | guard let self else { return } 21 | 22 | for provider in modalListObservations.map(\.provider) { 23 | provider.update(environment: environment) 24 | } 25 | } 26 | } 27 | 28 | func setModalHostNeedsUpdate(requiringModalHost modalHostRequired: Bool) { 29 | guard let owner else { 30 | return 31 | } 32 | 33 | guard let host = owner.modalHost else { 34 | if modalHostRequired { 35 | ModalHostAsserts.noFoundModalHostFatalError(in: owner) 36 | } else { 37 | return 38 | } 39 | } 40 | 41 | host.setNeedsModalUpdate() 42 | } 43 | 44 | func observe(_ provider: ModalListProvider) -> ModalListObservationLifetime { 45 | guard let owner else { 46 | fatalError( 47 | """ 48 | No owning view controller was found when attempting to observe ModalListProvider. \ 49 | This is not expected to be nil, and indicates an error in the Modals framework. 50 | """ 51 | ) 52 | } 53 | 54 | if let currentObservation = modalListObservations.first(where: { $0.provider === provider }) { 55 | guard let lifetime = currentObservation.lifetime else { 56 | fatalError("ModalListProvider lifetime was nil when attempting to return an existing observation.") 57 | } 58 | 59 | return lifetime 60 | } 61 | 62 | // Ensure an up-to-date environment at the start of observation. 63 | provider.update(environment: owner.environment) 64 | 65 | let cancellable = provider.modalListDidChange.sink { [weak self] _ in 66 | guard let self, 67 | modalListObservations.isEmpty == false 68 | else { return } 69 | 70 | setModalHostNeedsUpdate(requiringModalHost: false) 71 | } 72 | 73 | let lifetime = ObservationLifetimeToken(onStopObserving: { [weak self] in 74 | guard let self else { return } 75 | 76 | guard let index = modalListObservations.firstIndex(where: { $0.provider === provider }) else { 77 | return 78 | } 79 | 80 | let observation = modalListObservations.remove(at: index) 81 | observation.cancellable.cancel() 82 | }) 83 | 84 | modalListObservations.append(.init( 85 | provider: provider, 86 | cancellable: cancellable, 87 | lifetime: lifetime 88 | )) 89 | 90 | setModalHostNeedsUpdate(requiringModalHost: false) 91 | 92 | return lifetime 93 | } 94 | 95 | func aggregateModalList() -> ModalList { 96 | modalListObservations 97 | .map { $0.provider.aggregateModalList() } 98 | .reduce(ModalList(), +) 99 | } 100 | } 101 | 102 | 103 | extension TrampolineModalListObserver { 104 | 105 | fileprivate final class ObservationLifetimeToken: ModalListObservationLifetime { 106 | private var onStopObserving: (() -> Void)? 107 | 108 | init(onStopObserving: @escaping () -> Void) { 109 | self.onStopObserving = onStopObserving 110 | } 111 | 112 | deinit { 113 | onStopObserving?() 114 | } 115 | 116 | func stopObserving() { 117 | onStopObserving?() 118 | onStopObserving = nil 119 | } 120 | } 121 | 122 | fileprivate struct ModalListObservation { 123 | var provider: ModalListProvider 124 | var cancellable: AnyCancellable 125 | weak var lifetime: ModalListObservationLifetime? 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Modals/Sources/UICoordinateSpace+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UICoordinateSpace { 4 | /// Converts insets from the coordinate space of the current object to the specified coordinate space. 5 | /// 6 | /// Note that each edge of the result is clamped to `0...insets.edge`, e.g., any edge of `coordinateSpace` that does 7 | /// not overlap with `insets` will be zero, and any edge that extends past the bounds of the receiver will be the 8 | /// the value of the edge from `insets`. 9 | /// 10 | /// - Parameters: 11 | /// - insets: An inset specified in the coordinate system of the current object. 12 | /// - coordinateSpace: The coordinate space into which insets is to be converted. 13 | /// - Returns: Insets specified in the target coordinate space. 14 | func convert(_ insets: UIEdgeInsets, to coordinateSpace: UICoordinateSpace) -> UIEdgeInsets { 15 | let toBounds = coordinateSpace.convert(coordinateSpace.bounds, to: self) 16 | 17 | func clamp(value: CGFloat, for path: KeyPath) -> CGFloat { 18 | let inset = insets[keyPath: path] 19 | return min(inset, max(0, value)) 20 | } 21 | 22 | let top = insets.top - (toBounds.minY - bounds.minY) 23 | let left = insets.left - (toBounds.minX - bounds.minX) 24 | let bottom = insets.bottom - (bounds.maxY - toBounds.maxY) 25 | let right = insets.right - (bounds.maxX - toBounds.maxX) 26 | 27 | return UIEdgeInsets( 28 | top: clamp(value: top, for: \.top), 29 | left: clamp(value: left, for: \.left), 30 | bottom: clamp(value: bottom, for: \.bottom), 31 | right: clamp(value: right, for: \.right) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Modals/Sources/UIResponder+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOSApplicationExtension, unavailable, message: "This depends on the UIApplication singleton") 5 | extension UIResponder { 6 | 7 | /// Calling `findFirstResponder` stores `self` into this property so that `currentFirstResponder` can return it. 8 | private weak static var _discoveredCurrentFirstResponder: UIResponder? 9 | 10 | @objc 11 | private func mdl_findFirstResponder() { 12 | Self._discoveredCurrentFirstResponder = self 13 | } 14 | 15 | /// Get the current first responder. 16 | static var currentFirstResponder: UIResponder? { 17 | // Ensure the last discovered first responder isn't returned. 18 | _discoveredCurrentFirstResponder = nil 19 | // Send `findFirstResponder` to `nil` (which sends it to the first responder) so that the first responder 20 | // stores itself in `_discoveredCurrentFirstResponder`. 21 | UIApplication.shared.sendAction(#selector(mdl_findFirstResponder), to: nil, from: nil, for: nil) 22 | return _discoveredCurrentFirstResponder 23 | } 24 | 25 | /// Resigns the current first responder by sending a `resignFirstResponder` action to `nil`. 26 | /// (This behavior is documented in the API docs for `sendAction`). 27 | static func resignCurrentFirstResponder() { 28 | UIApplication.shared.sendAction(#selector(resignFirstResponder), to: nil, from: nil, for: nil) 29 | } 30 | } 31 | 32 | extension UIResponder { 33 | 34 | func isDescendant(of otherResponder: UIResponder) -> Bool { 35 | var nextResponder: UIResponder? = self 36 | 37 | while nextResponder != nil { 38 | if nextResponder === otherResponder { 39 | return true 40 | } else { 41 | nextResponder = nextResponder?.next 42 | } 43 | } 44 | 45 | return false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Modals/Sources/UIView+Modals.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | extension UIView { 5 | /// Avoids possible issues setting the frame and transform by first setting the transform to the 6 | /// identity, then setting the frame, then setting the provided transform. 7 | /// 8 | /// Setting the center, bounds, or frame may cause a synchronous layout. By setting the frame 9 | /// instead of center and bounds we can minimize the number of layouts. 10 | /// 11 | func set(frame: CGRect, transform: CGAffineTransform) { 12 | if self.transform != .identity { 13 | self.transform = .identity 14 | } 15 | 16 | if self.frame != frame { 17 | self.frame = frame 18 | } 19 | 20 | if self.transform != transform { 21 | self.transform = transform 22 | } 23 | } 24 | 25 | var presentationOrRealLayer: CALayer { 26 | layer.presentation() ?? layer 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Modals/Sources/UIViewAnimating+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewAnimating { 4 | 5 | /// Stops the animation if the animation is not in the `.stopped` state. 6 | /// 7 | /// This avoids a runtime crash when stopping a `.stopped` animation. 8 | /// 9 | /// See [UIViewAnimating.stopAnimation(Bool)](https://developer.apple.com/documentation/uikit/uiviewanimating/1649750-stopanimation). 10 | func stopAnimationIfNeeded(withoutFinishing: Bool) { 11 | guard state != .stopped else { 12 | return 13 | } 14 | 15 | stopAnimation(withoutFinishing) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Modals/Sources/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | /// Calls the appropriate combination of `beginAppearanceTransition` and 5 | /// `endAppearanceTransition` methods to transition this view controller from `start` to `end` 6 | /// visibility states. 7 | func callAppearanceTransitions( 8 | from start: Visibility, 9 | to end: Visibility, 10 | animated: Bool 11 | ) { 12 | func willAppear() { 13 | beginAppearanceTransition(true, animated: animated) 14 | } 15 | func didAppear() { 16 | endAppearanceTransition() 17 | } 18 | func willDisappear() { 19 | beginAppearanceTransition(false, animated: animated) 20 | } 21 | func didDisappear() { 22 | endAppearanceTransition() 23 | } 24 | 25 | switch (start, end) { 26 | case (.appearing, .appearing): 27 | break 28 | case (.appearing, .appeared): 29 | didAppear() 30 | case (.appearing, .disappearing): 31 | willDisappear() 32 | case (.appearing, .disappeared): 33 | willDisappear() 34 | didDisappear() 35 | case (.appeared, .appearing): 36 | willDisappear() 37 | willAppear() 38 | case (.appeared, .appeared): 39 | break 40 | case (.appeared, .disappearing): 41 | willDisappear() 42 | case (.appeared, .disappeared): 43 | willDisappear() 44 | didDisappear() 45 | case (.disappearing, .appeared): 46 | willAppear() 47 | didAppear() 48 | case (.disappearing, .appearing): 49 | willAppear() 50 | case (.disappearing, .disappearing): 51 | break 52 | case (.disappearing, .disappeared): 53 | didDisappear() 54 | case (.disappeared, .appearing): 55 | willAppear() 56 | case (.disappeared, .appeared): 57 | willAppear() 58 | didAppear() 59 | case (.disappeared, .disappearing): 60 | willAppear() 61 | willDisappear() 62 | case (.disappeared, .disappeared): 63 | break 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Modals/Sources/UIViewController+Orientation.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | extension UIViewController { 5 | 6 | /// Flags the view controller for needing a supported interface orientations update 7 | /// (`setNeedsUpdateOfSupportedInterfaceOrientations()`), and rotates to a supported interface orientation if the 8 | /// associated `view.window?.rootViewController?.supportedInterfaceOrientations` mask does not contain match the 9 | /// current `windowScene` orientation. 10 | public func setNeedsUpdateOfSupportedInterfaceOrientationsAndRotateIfNeeded() { 11 | // This approach is inspired by the solution found in the Flutter repository: 12 | // https://github.com/flutter/engine/blob/67440ccd58561a2b2f0336a3af695a07a6f9eff5/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm#L1697-L1744 13 | 14 | // We need to indidate that this VC's supported orientations changed, even in cases where the current 15 | // orientation is supported, so that the system can utilize these new values to determine if a device 16 | // rotation should trigger a VC rotation. 17 | // If you do not call this function, the supported orientations are never re-queried. 18 | setNeedsUpdateOfSupportedInterfaceOrientations() 19 | 20 | guard 21 | let view = viewIfLoaded, 22 | let supportedInterfaceOrientations = view.window?.rootViewController?.supportedInterfaceOrientations, 23 | let scene = view.window?.windowScene, 24 | let sceneOrientationMask = UIInterfaceOrientationMask(scene.interfaceOrientation), 25 | sceneOrientationMask.isDisjoint(with: supportedInterfaceOrientations) 26 | else { 27 | return 28 | } 29 | 30 | let deviceOrientation = UIInterfaceOrientation(UIDevice.current.orientation) 31 | 32 | let orientations: [UIInterfaceOrientation] = [ 33 | .portrait, 34 | .landscapeRight, 35 | .landscapeLeft, 36 | .portraitUpsideDown, 37 | ].sorted { lhs, _ in 38 | /// The current orientation should always be the first fallback. 39 | lhs == deviceOrientation 40 | } 41 | 42 | let newOrientation = orientations.first { orientation in 43 | UIInterfaceOrientationMask(orientation)?.isSubset(of: supportedInterfaceOrientations) == true 44 | } 45 | 46 | if newOrientation != nil { 47 | scene.requestGeometryUpdate(.iOS(interfaceOrientations: supportedInterfaceOrientations)) { error in 48 | print("Failed to request gemoetry update: \(error)") 49 | } 50 | } 51 | } 52 | } 53 | 54 | extension UIInterfaceOrientationMask { 55 | 56 | fileprivate init?(_ orientation: UIInterfaceOrientation) { 57 | switch orientation { 58 | case .portrait: 59 | self = .portrait 60 | 61 | case .portraitUpsideDown: 62 | self = .portraitUpsideDown 63 | 64 | case .landscapeLeft: 65 | self = .landscapeLeft 66 | 67 | case .landscapeRight: 68 | self = .landscapeRight 69 | 70 | case .unknown: 71 | return nil 72 | 73 | @unknown default: 74 | return nil 75 | } 76 | } 77 | } 78 | 79 | 80 | extension UIInterfaceOrientation { 81 | 82 | fileprivate init?(_ orientation: UIDeviceOrientation) { 83 | switch orientation { 84 | case .portrait: 85 | self = .portrait 86 | 87 | case .portraitUpsideDown: 88 | self = .portraitUpsideDown 89 | 90 | // The reason Left is mapped to Right and vice versa according to Apple's documentation on 91 | // `UIInterfaceOrientation`: 92 | // > Notice that UIDeviceOrientation.landscapeRight is assigned to UIInterfaceOrientation.landscapeLeft and 93 | // > UIDeviceOrientation.landscapeLeft is assigned to UIInterfaceOrientation.landscapeRight. The reason for this 94 | // > is that rotating the device requires rotating the content in the opposite direction. 95 | case .landscapeLeft: 96 | self = .landscapeRight 97 | 98 | case .landscapeRight: 99 | self = .landscapeLeft 100 | 101 | case .unknown, 102 | .faceUp, 103 | .faceDown: 104 | return nil 105 | 106 | @unknown default: 107 | return nil 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Modals/Sources/Visibility.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Represents the visibility of a view controller, in relation to the "appear" and "disappear" 4 | /// lifecycle events. 5 | /// 6 | /// This diagram shows the lifecycle of a view controller, and the events that occur between 7 | /// each state: 8 | /// 9 | /// ``` 10 | /// ┌──────────────┐ 11 | /// viewWillAppear──────────▶│ Appearing │────────────viewDidAppear 12 | /// │ └──────────────┘ │ 13 | /// │ ▲ │ │ 14 | /// │ │ │ ▼ 15 | /// ┌──────────────┐ │ viewWillDisappear ┌──────────────┐ 16 | /// │ Disappeared │ │ │ │ Appeared │ 17 | /// └──────────────┘ viewWillAppear │ └──────────────┘ 18 | /// ▲ │ │ │ 19 | /// │ │ ▼ │ 20 | /// │ ┌──────────────┐ │ 21 | /// viewDidDisappear──────────│ Disappearing │◀───────viewWillDisappear 22 | /// └──────────────┘ 23 | /// ``` 24 | /// 25 | /// Implements `Comparable` in order of visibility, from the least visible to the most visible. 26 | /// 27 | enum Visibility: Comparable, Equatable { 28 | case disappeared 29 | case disappearing 30 | case appearing 31 | case appeared 32 | 33 | /// Resolves a view controller's effective visibility when nested in another view 34 | /// controller. 35 | /// 36 | /// The resulting visibility is always the "least visible" of the two states. For example, a 37 | /// view controller that is `disappeared` will never be considered more visible by its 38 | /// container, nor will a container that is `disappeared` allow a child to appear more 39 | /// visible. 40 | func within(containerState: Visibility) -> Visibility { 41 | min(self, containerState) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Modals/Tests/AccessibilityProxyTests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | 4 | @testable import Modals 5 | 6 | class AccessibilityProxyTests: XCTestCase { 7 | 8 | 9 | func test_view() { 10 | class InteractiveElement: UIView { 11 | init(frame: CGRect, activate: @escaping () -> Bool) { 12 | self.activate = activate 13 | super.init(frame: frame) 14 | } 15 | 16 | required init?(coder: NSCoder) { 17 | fatalError("init(coder:) has not been implemented") 18 | } 19 | 20 | var activate: () -> Bool 21 | override func accessibilityActivate() -> Bool { 22 | activate() 23 | } 24 | } 25 | 26 | let container = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) 27 | 28 | var interactiveActivated = false 29 | let interactive = InteractiveElement(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) { 30 | interactiveActivated = true 31 | return true 32 | } 33 | 34 | interactive.accessibilityLabel = "interactive" 35 | interactive.isAccessibilityElement = true 36 | container.addSubview(interactive) 37 | 38 | let element = UIView(frame: CGRect(x: 0, y: 0, width: 90, height: 90)) 39 | element.isAccessibilityElement = true 40 | element.accessibilityLabel = "element" 41 | container.insertSubview(element, at: 0) 42 | 43 | let passhtough = AccessibilityProxyView(frame: .zero) 44 | passhtough.source = container 45 | passhtough.configureProxies() 46 | 47 | let subviews = passhtough.subviews 48 | XCTAssertEqual(subviews.count, 2) 49 | 50 | XCTAssertEqual(subviews.first?.accessibilityLabel, "element") 51 | XCTAssertEqual(subviews.last?.accessibilityLabel, "interactive") 52 | 53 | subviews.last?.accessibilityActivate() 54 | XCTAssertTrue(interactiveActivated) 55 | } 56 | 57 | func test_proxy_weakReference() { 58 | var element: UIView? = UIView(frame: .zero) 59 | element?.isAccessibilityElement = true 60 | element?.accessibilityLabel = "label" 61 | 62 | let proxy = AccessibilityProxyView.Proxy(element: element, frame: .zero) 63 | 64 | XCTAssertEqual(proxy.accessibilityLabel, "label") 65 | XCTAssertTrue(proxy.isAccessibilityElement) 66 | 67 | element = nil 68 | 69 | XCTAssertFalse(proxy.isAccessibilityElement) 70 | XCTAssertNil(proxy.accessibilityLabel) 71 | 72 | } 73 | 74 | func test_proxy_customContent() { 75 | 76 | class TestItem: NSObject, AXCustomContentProvider { 77 | var accessibilityCustomContent: [AXCustomContent]! = [ 78 | AXCustomContent(label: "label", value: "value"), 79 | ] 80 | } 81 | 82 | let item = TestItem() 83 | let proxy = AccessibilityProxyView.Proxy(element: item, frame: .zero) 84 | let content = proxy.accessibilityCustomContent 85 | XCTAssertEqual(content?.count, 1) 86 | XCTAssertEqual(content, item.accessibilityCustomContent) 87 | 88 | if #available(iOS 17.0, *) { 89 | // accessibilityCustomContentBlock is preferred by voiceover if implemented, so we should return the content even if the proxied item doesn't implement it. 90 | let blockContent = proxy.accessibilityCustomContentBlock?() 91 | XCTAssertEqual(blockContent, content) 92 | } 93 | } 94 | 95 | @available(iOS 17.0, *) 96 | func test_proxy_customContentBlock() { 97 | class BlockTestItem: NSObject, AXCustomContentProvider { 98 | var accessibilityCustomContent: [AXCustomContent]! = [ 99 | AXCustomContent(label: "varLabel", value: "varValue"), 100 | ] 101 | var accessibilityCustomContentBlock: AXCustomContentReturnBlock? = { [ 102 | AXCustomContent(label: "blockLabel", value: "blockValue"), 103 | ] } 104 | } 105 | 106 | let item = BlockTestItem() 107 | let proxy = AccessibilityProxyView.Proxy(element: item, frame: .zero) 108 | let content = proxy.accessibilityCustomContentBlock?() 109 | // accessibilityCustomContentBlock is preferred by voiceover if implemented, so we should return the content from the block based API if possible. 110 | XCTAssertEqual(content?.first?.label, "blockLabel") 111 | XCTAssertEqual(content?.first?.value, "blockValue") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Modals/Tests/ConvertInsetsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Modals 3 | 4 | class ConvertInsetsTests: XCTestCase { 5 | func test_coordinateSpaceInsetsNoOverlap() { 6 | let view1 = UIView(frame: .init(origin: .zero, size: .init(width: 100, height: 100))) 7 | let view2 = UIView(frame: view1.bounds.insetBy(dx: 20, dy: 20)) 8 | 9 | view1.addSubview(view2) 10 | let insets = view1.convert(UIEdgeInsets(uniform: 10), to: view2) 11 | XCTAssertEqual(insets, .zero) 12 | } 13 | 14 | func test_coordinateSpaceInsetsPartialOverlap() { 15 | let view1 = UIView(frame: .init(origin: .zero, size: .init(width: 100, height: 100))) 16 | let view2 = UIView(frame: view1.bounds.insetBy(dx: 5, dy: 5)) 17 | 18 | view1.addSubview(view2) 19 | let insets = view1.convert(UIEdgeInsets(uniform: 10), to: view2) 20 | XCTAssertEqual(insets, .init(uniform: 5)) 21 | } 22 | 23 | func test_coordinateSpaceInsetsFullOverlap() { 24 | let view1 = UIView(frame: .init(origin: .zero, size: .init(width: 100, height: 100))) 25 | let view2 = UIView(frame: view1.bounds) 26 | 27 | view1.addSubview(view2) 28 | let insets = view1.convert(UIEdgeInsets(uniform: 10), to: view2) 29 | XCTAssertEqual(insets, .init(uniform: 10)) 30 | } 31 | 32 | func test_coordinateSpaceInsetsOutsetOverlap() { 33 | let view1 = UIView(frame: .init(origin: .zero, size: .init(width: 100, height: 100))) 34 | let view2 = UIView(frame: view1.bounds.insetBy(dx: -20, dy: -20)) 35 | 36 | view1.addSubview(view2) 37 | let insets = view1.convert(UIEdgeInsets(uniform: 10), to: view2) 38 | XCTAssertEqual(insets, .init(uniform: 10)) 39 | } 40 | 41 | } 42 | 43 | extension UIEdgeInsets { 44 | init(uniform inset: CGFloat) { 45 | self.init(top: inset, left: inset, bottom: inset, right: inset) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Modals/Tests/ModalListTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Modals 4 | 5 | 6 | class ModalListTests: XCTestCase { 7 | 8 | func test_addition() { 9 | let a = ModalList(modals: [.a], toasts: [.a], toastSafeAreaAnchors: [.a]) 10 | let b = ModalList(modals: [.b], toasts: [.b], toastSafeAreaAnchors: [.b]) 11 | 12 | let expected = ModalList( 13 | modals: [.a, .b], 14 | toasts: [.a, .b], 15 | toastSafeAreaAnchors: [.a, .b] 16 | ) 17 | XCTAssertEqual(a + b, expected) 18 | } 19 | 20 | func test_appending() { 21 | let a = ModalList(modals: [.a], toasts: [.a], toastSafeAreaAnchors: [.a]) 22 | 23 | let result = a.appending( 24 | modals: [.b], 25 | toasts: [.b], 26 | toastSafeAreaAnchors: [.b] 27 | ) 28 | let expected = ModalList( 29 | modals: [.a, .b], 30 | toasts: [.a, .b], 31 | toastSafeAreaAnchors: [.a, .b] 32 | ) 33 | XCTAssertEqual(result, expected) 34 | } 35 | } 36 | 37 | extension ModalList { 38 | public override func isEqual(_ object: Any?) -> Bool { 39 | guard let other = object as? ModalList else { 40 | return false 41 | } 42 | 43 | return modals.count == other.modals.count 44 | && zip(modals, other.modals).allSatisfy { $0 === $1 } 45 | && toasts.count == other.toasts.count 46 | && zip(toasts, other.toasts).allSatisfy { $0 === $1 } 47 | && toastSafeAreaAnchors.count == other.toastSafeAreaAnchors.count 48 | && zip(toastSafeAreaAnchors, other.toastSafeAreaAnchors).allSatisfy { $0 === $1 } 49 | } 50 | } 51 | 52 | extension PresentableModal { 53 | fileprivate static let a = PresentableModal( 54 | viewController: UIViewController(), 55 | presentationStyle: TestFullScreenStyle(), 56 | info: .empty(), 57 | onDidPresent: nil 58 | ) 59 | 60 | fileprivate static let b = PresentableModal( 61 | viewController: UIViewController(), 62 | presentationStyle: TestFullScreenStyle(), 63 | info: .empty(), 64 | onDidPresent: nil 65 | ) 66 | } 67 | 68 | 69 | extension PresentableToast { 70 | fileprivate static let a = PresentableToast( 71 | viewController: UIViewController(), 72 | presentationStyle: TestToastPresentationStyle(), 73 | accessibilityAnnouncement: "a" 74 | ) 75 | 76 | fileprivate static let b = PresentableToast( 77 | viewController: UIViewController(), 78 | presentationStyle: TestToastPresentationStyle(), 79 | accessibilityAnnouncement: "b" 80 | ) 81 | } 82 | 83 | extension ToastSafeAreaAnchor { 84 | fileprivate static let a = ToastSafeAreaAnchor(onChange: {}) 85 | 86 | fileprivate static let b = ToastSafeAreaAnchor(onChange: {}) 87 | } 88 | -------------------------------------------------------------------------------- /Modals/Tests/ModalsLoggingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Modals 3 | 4 | class ModalsLoggingTests: XCTestCase { 5 | 6 | func test_default_label() { 7 | XCTAssertEqual(ModalsLogging.defaultLoggerLabel, "com.squareup.modals") 8 | 9 | let defaultLogger = ModalsLogging.logger 10 | XCTAssertEqual(defaultLogger.label, "com.squareup.modals") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Modals/Tests/TestFullScreenStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import ViewEnvironment 3 | 4 | struct TestFullScreenStyle: ModalPresentationStyle { 5 | 6 | var identifier: String? = nil 7 | var viewControllerContainmentPreferences: ModalBehaviorPreferences.ViewControllerContainmentPreferences = .default 8 | var environmentCustomization: (inout ViewEnvironment) -> Void = { _ in } 9 | 10 | func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences { 11 | ModalBehaviorPreferences( 12 | usesPreferredContentSize: false, 13 | viewControllerContainmentPreferences: viewControllerContainmentPreferences 14 | ) 15 | } 16 | 17 | func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues { 18 | ModalDisplayValues(frame: context.containerCoordinateSpace.bounds) 19 | } 20 | 21 | func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 22 | ModalTransitionValues(frame: context.containerCoordinateSpace.bounds) 23 | } 24 | 25 | func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 26 | ModalTransitionValues(frame: context.containerCoordinateSpace.bounds) 27 | } 28 | 29 | func customize(environment: inout ViewEnvironment) { 30 | environmentCustomization(&environment) 31 | } 32 | } 33 | 34 | 35 | extension ModalPresentationStyleProvider { 36 | 37 | static func testFull( 38 | viewControllerContainmentPreferences: ModalBehaviorPreferences.ViewControllerContainmentPreferences = .default, 39 | environmentCustomization: @escaping (inout ViewEnvironment) -> Void = { _ in } 40 | ) -> Self { 41 | .init { environment in 42 | TestFullScreenStyle( 43 | viewControllerContainmentPreferences: viewControllerContainmentPreferences, 44 | environmentCustomization: environmentCustomization 45 | ) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Modals/Tests/TestLogHandler.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | 3 | final class TestLogHandler: LogHandler { 4 | 5 | struct LogPayload { 6 | let level: Logger.Level 7 | let message: Logger.Message 8 | let metadata: Logger.Metadata? 9 | let source: String 10 | let file: String 11 | let function: String 12 | let line: UInt 13 | } 14 | 15 | var logs: [LogPayload] = [] 16 | 17 | subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { 18 | get { 19 | metadata[key] 20 | } 21 | set(newValue) { 22 | metadata[key] = newValue 23 | } 24 | } 25 | 26 | var metadata: Logging.Logger.Metadata = Logger.Metadata() 27 | 28 | var logLevel: Logging.Logger.Level = .info 29 | 30 | func log( 31 | level: Logger.Level, 32 | message: Logger.Message, 33 | metadata: Logger.Metadata?, 34 | source: String, 35 | file: String, 36 | function: String, 37 | line: UInt 38 | ) { 39 | let payload = LogPayload( 40 | level: level, 41 | message: message, 42 | metadata: metadata, 43 | source: source, 44 | file: file, 45 | function: function, 46 | line: line 47 | ) 48 | logs.append(payload) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Modals/Tests/TestSupportedInterfaceOrientationsViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | final class TestSupportedInterfaceOrientationsViewController: UIViewController { 5 | 6 | private let supportedOrientations: UIInterfaceOrientationMask 7 | 8 | init(supportedOrientations: UIInterfaceOrientationMask) { 9 | self.supportedOrientations = supportedOrientations 10 | 11 | super.init(nibName: nil, bundle: nil) 12 | } 13 | 14 | override var supportedInterfaceOrientations: UIInterfaceOrientationMask { supportedOrientations } 15 | 16 | required init?(coder: NSCoder) { fatalError() } 17 | } 18 | -------------------------------------------------------------------------------- /Modals/Tests/TestToastPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | struct TestToastPresentationStyle: ToastPresentationStyle { 5 | var identifier: String? = nil 6 | var haptic: UINotificationFeedbackGenerator.FeedbackType = .warning 7 | 8 | func behaviorPreferences(for context: ToastBehaviorContext) -> ToastBehaviorPreferences { 9 | ToastBehaviorPreferences( 10 | presentationHaptic: haptic, 11 | timedDismiss: .after(duration: 1.2, onDismiss: {}), 12 | interactiveDismiss: .disabled 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Modals/Tests/TextFieldViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class TextFieldViewController: UIViewController { 4 | let textField = TestTextField() 5 | 6 | var didBecomeFirstResponder: (() -> Void)? { 7 | get { textField.didBecomeFirstResponder } 8 | set { textField.didBecomeFirstResponder = newValue } 9 | } 10 | 11 | var didResignFirstResponder: (() -> Void)? { 12 | get { textField.didResignFirstResponder } 13 | set { textField.didResignFirstResponder = newValue } 14 | } 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | view.addSubview(textField) 19 | } 20 | 21 | override func viewDidLayoutSubviews() { 22 | super.viewDidLayoutSubviews() 23 | textField.frame = view.bounds 24 | } 25 | } 26 | 27 | class TestTextField: UITextField { 28 | var didBecomeFirstResponder: (() -> Void)? = nil 29 | var didResignFirstResponder: (() -> Void)? = nil 30 | 31 | @discardableResult 32 | override func becomeFirstResponder() -> Bool { 33 | let became = super.becomeFirstResponder() 34 | if became { 35 | didBecomeFirstResponder?() 36 | } 37 | return became 38 | } 39 | 40 | @discardableResult 41 | override func resignFirstResponder() -> Bool { 42 | let resigned = super.resignFirstResponder() 43 | if resigned { 44 | didResignFirstResponder?() 45 | } 46 | return resigned 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Modals/Tests/UIResponderTests.swift: -------------------------------------------------------------------------------- 1 | import TestingSupport 2 | import XCTest 3 | @testable import Modals 4 | 5 | class UIResponderTests: XCTestCase { 6 | 7 | func test_current_first_responder() { 8 | let viewController = TextFieldViewController() 9 | show(vc: viewController) { viewController in 10 | viewController.textField.becomeFirstResponder() 11 | XCTAssertEqual(viewController.textField, UIResponder.currentFirstResponder) 12 | viewController.textField.resignFirstResponder() 13 | XCTAssertNil(UIResponder.currentFirstResponder) 14 | } 15 | } 16 | 17 | func test_resign_current_first_responder() { 18 | let viewController = TextFieldViewController() 19 | show(vc: viewController) { viewController in 20 | viewController.textField.becomeFirstResponder() 21 | XCTAssertTrue(viewController.textField.isFirstResponder) 22 | UIResponder.resignCurrentFirstResponder() 23 | XCTAssertFalse(viewController.textField.isFirstResponder) 24 | } 25 | } 26 | 27 | func test_is_descendant() { 28 | let viewController = TextFieldViewController() 29 | show(vc: viewController) { viewController in 30 | XCTAssertTrue(viewController.textField.isDescendant(of: viewController)) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Modals/Tests/UIViewControllerModalPresenterTests.swift: -------------------------------------------------------------------------------- 1 | import ViewEnvironment 2 | @_spi(ViewEnvironmentWiring) import ViewEnvironmentUI 3 | import XCTest 4 | 5 | @testable import Modals 6 | 7 | 8 | final class UIViewControllerModalPresenterTests: XCTestCase { 9 | 10 | func test_modal_content_environment_customization() throws { 11 | let root = UIViewController() 12 | 13 | // Embed in modal host just to avoid hitting missing host assertions. 14 | let host = ModalHostContainerViewController(content: root) 15 | 16 | var environmentUpdates: [ViewEnvironment] = [] 17 | let content = EnvironmentObservingViewController( 18 | onEnvironmentDidChange: { environmentUpdates.append($0) } 19 | ) 20 | let lifetime = root.modalPresenter.present( 21 | content, 22 | style: .testFull(environmentCustomization: { 23 | $0[TestKey.self] = true 24 | }) 25 | ) 26 | 27 | do { 28 | let modalList = root.aggregateModals() 29 | XCTAssertEqual(modalList.modals.count, 1) 30 | let modal = try XCTUnwrap(modalList.modals.first) 31 | XCTAssertEqual(modal.viewController, content) 32 | 33 | XCTAssertEqual(environmentUpdates.count, 1) 34 | let environment = try XCTUnwrap(environmentUpdates.first) 35 | 36 | XCTAssertTrue(environment[TestKey.self]) 37 | XCTAssertTrue(content.environment[TestKey.self]) 38 | } 39 | 40 | withExtendedLifetime(lifetime) {} 41 | withExtendedLifetime(host) {} 42 | } 43 | } 44 | 45 | extension UIViewControllerModalPresenterTests { 46 | 47 | fileprivate final class EnvironmentObservingViewController: UIViewController, ViewEnvironmentObserving { 48 | 49 | let onEnvironmentDidChange: (ViewEnvironment) -> Void 50 | 51 | init( 52 | onEnvironmentDidChange: @escaping (ViewEnvironment) -> Void = { _ in } 53 | ) { 54 | self.onEnvironmentDidChange = onEnvironmentDidChange 55 | super.init(nibName: nil, bundle: nil) 56 | } 57 | 58 | required init?(coder: NSCoder) { fatalError() } 59 | 60 | func environmentDidChange() { 61 | onEnvironmentDidChange(environment) 62 | } 63 | } 64 | 65 | fileprivate struct TestKey: ViewEnvironmentKey { 66 | 67 | static var defaultValue = false 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Modals", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .macCatalyst(.v16), 10 | .iOS(.v16), 11 | ], 12 | products: [ 13 | .library( 14 | name: "Modals", 15 | targets: ["Modals"] 16 | ), 17 | .library( 18 | name: "WorkflowModals", 19 | targets: ["WorkflowModals"] 20 | ), 21 | ], 22 | dependencies: [ 23 | .package(url: "https://github.com/apple/swift-log", from: "1.4.4"), 24 | .package(url: "https://github.com/square/workflow-swift", from: "3.14.0"), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "Modals", 29 | dependencies: [ 30 | .product(name: "ViewEnvironmentUI", package: "workflow-swift"), 31 | .product(name: "Logging", package: "swift-log"), 32 | ], 33 | path: "Modals", 34 | exclude: ["Tests"], 35 | sources: ["Sources"], 36 | resources: [.process("Resources")] 37 | ), 38 | .target( 39 | name: "WorkflowModals", 40 | dependencies: [ 41 | .target(name: "Modals"), 42 | .product(name: "WorkflowUI", package: "workflow-swift"), 43 | ], 44 | path: "WorkflowModals", 45 | exclude: ["Tests"], 46 | sources: ["Sources"] 47 | ), 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modals 2 | 3 | [![Validations](https://github.com/square/swift-modals/actions/workflows/validations.yaml/badge.svg)](https://github.com/square/swift-modals/actions/workflows/validations.yaml) 4 | 5 | A framework for presenting modal content deterministically in an iOS application. 6 | 7 | Modals supports both true modals as well as similar content overlay presentations which are not technically modal in nature, such as toasts. 8 | 9 | ## Design 10 | 11 | The Modals framework is designed to solve a couple of problems in large scale applications. 12 | 13 | - Determinism. Modal lifetime is explicitly managed, and modal ordering is based on the shape of the view controller hierarchy. Unlike vanilla UIKit, there can be no surprises about what is top-most or what will be removed when calling `dismiss`. 14 | 15 | - A declarative model. Modals works well with Workflow, to represent all currently visible modals in a declarative way, and could be adapted to other declarative interfaces as well. 16 | 17 | Modals presents from a _modal host_ view controller installed near the root of your application. The modal host traverses the view controller hierarchy to aggregate a list of modals that need to be displayed. Each descendent view controller may present modals, and those presented modals may also present modals. When multiple modals are present at once, the shape of the view hierarchy will determine the ordering. To dismiss a modal, it should simply be removed from the presenting view controller, and the next time the host aggregates modals, it will be dismissed. 18 | 19 | The modal host presents using view controller containment from the modal host view controller. 20 | 21 | ## Getting Started 22 | 23 | ### Swift Package Manager 24 | 25 | [![SwiftPM compatible](https://img.shields.io/badge/SwiftPM-compatible-orange.svg)](#swift-package-manager) 26 | 27 | If you are developing your own package, be sure that Modals is included in `dependencies` 28 | in `Package.swift`: 29 | 30 | ```swift 31 | dependencies: [ 32 | .package(url: "https://github.com/square/swift-modals", from: "1.0.0") 33 | ] 34 | ``` 35 | 36 | In Xcode 11+, add Workflow directly as a dependency to your project with 37 | `File` > `Swift Packages` > `Add Package Dependency...`. Provide the git URL when prompted: `git@github.com:square/swift-modals.git`. 38 | 39 | ## Documentation 40 | 41 | - Usage from [vanilla UIKit](Documentation/uikit-usage.md) 42 | - Usage from [Workflow](Documentation/workflow-usage.md) 43 | - [General usage tips](Documentation/tips.md) 44 | - API docs (TODO) 45 | 46 | Some sample code is available in the [Samples](Samples) directory. To build the sample code, use the local development instructions below. 47 | 48 | ## Local Development 49 | 50 | This project uses [Mise](https://mise.jdx.dev/) and [Tuist](https://tuist.io/) to generate a project for local development. Follow the steps below for the recommended setup for zsh. 51 | 52 | ```sh 53 | # install mise 54 | brew install mise 55 | # add mise activation line to your zshrc 56 | echo 'eval "$(mise activate zsh)"' >> ~/.zshrc 57 | # load mise into your shell 58 | source ~/.zshrc 59 | # tell mise to trust this repo's config file 60 | mise trust 61 | # install dependencies 62 | mise install 63 | 64 | # only necessary for first setup or after changing dependencies 65 | tuist install --path Samples 66 | # generates and opens the Xcode project 67 | tuist generate --path Samples 68 | ``` 69 | 70 | ## Credits 71 | 72 | `swift-modals` was written by [@watt](https://github.com/watt) with help from [@kylebshr](https://github.com/kylebshr), [@robmaceachern](https://github.com/robmaceachern), [@kyleve](https://github.com/kyleve), [@n8chur](https://github.com/n8chur), [@nononoah](https://github.com/nononoah), [@bencochran](https://github.com/bencochran) and others. Thank you to all contributors! 73 | 74 | ## License 75 | 76 | Copyright 2025 Square, Inc. 77 | 78 | Licensed under the Apache License, Version 2.0 (the "License"); 79 | you may not use this file except in compliance with the License. 80 | You may obtain a copy of the License at 81 | 82 | http://www.apache.org/licenses/LICENSE-2.0 83 | 84 | Unless required by applicable law or agreed to in writing, software 85 | distributed under the License is distributed on an "AS IS" BASIS, 86 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 87 | See the License for the specific language governing permissions and 88 | limitations under the License. 89 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/README.md: -------------------------------------------------------------------------------- 1 | # Example Styles 2 | 3 | These styles demonstrate some common use cases and features. 4 | 5 | ## Full Modal 6 | 7 | This is a simple full screen modal. 8 | 9 | ## Card Modal 10 | 11 | This modal is sized to fit its content, and centered in its container. 12 | 13 | ## Popover Modal 14 | 15 | This style demonstrates how to "anchor" a modal relative to some content. The `UICoordinateSpace` is passed into the style, and used to resolve a frame relative to the container. You can tap on the overlay outside the popover's bounds to dismiss it. 16 | 17 | ## Sheet Modal 18 | 19 | This style supports interactive dismissal by swiping, using the `reverseTransitionValues` API. You can also tap on the overlay to dismiss. 20 | 21 | ## Styling 22 | 23 | Each of these modals uses style values from a `ModalStylesheet`. You don't pass it in explicitly; the stylesheet lives on the `ViewEnvironment`, and is resolved at presentation time. Look at `ModalPresentationStyleProvider+Examples.swift` to see how static factory methods are created for each of these preset modal styles. 24 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/CGSize+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGSize { 4 | /// Initialize a size with an offset. The height and width of the size reflect the offsets vertical and horizontal 5 | /// values. 6 | init(_ offset: UIOffset) { 7 | self.init(width: offset.horizontal, height: offset.vertical) 8 | } 9 | 10 | /// Initialize a square size. 11 | public init(uniform length: CGFloat) { 12 | self.init(width: length, height: length) 13 | } 14 | 15 | /// Decrease the size by a given inset. 16 | public func subtracting(insets: UIEdgeInsets) -> CGSize { 17 | CGSize( 18 | width: width - insets.left - insets.right, 19 | height: height - insets.top - insets.bottom 20 | ) 21 | } 22 | 23 | /// Decrease the height of the size by a given value. 24 | public func subtracting(height amount: CGFloat) -> CGSize { 25 | CGSize(width: width, height: height - amount) 26 | } 27 | 28 | /// Decrease the width of the size by a given value. 29 | public func subtracting(width amount: CGFloat) -> CGSize { 30 | CGSize(width: width - amount, height: height) 31 | } 32 | 33 | /// Clamp the size to a maximum. 34 | public func upperBounded(by size: CGSize) -> CGSize { 35 | CGSize( 36 | width: width.upperBounded(by: size.width), 37 | height: height.upperBounded(by: size.height) 38 | ) 39 | } 40 | 41 | /// Clamp the size's width to a maximum. 42 | public func upperBounded(byWidth maxWidth: CGFloat?) -> CGSize { 43 | guard let maxWidth else { 44 | return self 45 | } 46 | 47 | return CGSize(width: width.upperBounded(by: maxWidth), height: height) 48 | } 49 | 50 | /// Clamp the size's height to a maximum. 51 | public func upperBounded(byHeight maxHeight: CGFloat?) -> CGSize { 52 | guard let maxHeight else { 53 | return self 54 | } 55 | 56 | return CGSize(width: width, height: height.upperBounded(by: maxHeight)) 57 | } 58 | 59 | /// Clamp the size to a minimum. 60 | public func lowerBounded(by size: CGSize) -> CGSize { 61 | CGSize( 62 | width: width.lowerBounded(by: size.width), 63 | height: height.lowerBounded(by: size.height) 64 | ) 65 | } 66 | 67 | /// Clamp the size's width to a minimum. 68 | public func lowerBounded(byWidth minWidth: CGFloat?) -> CGSize { 69 | 70 | guard let minWidth else { 71 | return self 72 | } 73 | 74 | return CGSize(width: width.lowerBounded(by: minWidth), height: height) 75 | } 76 | 77 | /// Clamp the size's height to a minimum. 78 | public func lowerBounded(byHeight minHeight: CGFloat?) -> CGSize { 79 | 80 | guard let minHeight else { 81 | return self 82 | } 83 | 84 | return CGSize(width: width, height: height.lowerBounded(by: minHeight)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/Comparable+Bounding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Comparable { 4 | /// Returns this value or `value`, whichever is lesser 5 | public func upperBounded(by value: Self) -> Self { 6 | min(self, value) 7 | } 8 | 9 | /// Returns this value or `value`, whichever is greater 10 | public func lowerBounded(by value: Self) -> Self { 11 | max(self, value) 12 | } 13 | 14 | /// Returns this value clamped between `a` and `b`. The order of the parameters does not 15 | /// matter. 16 | public func clampedBetween(_ a: Self, _ b: Self) -> Self { 17 | let lowerBound = min(a, b) 18 | let upperBound = max(a, b) 19 | return min(max(self, lowerBound), upperBound) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ExampleStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ExampleStyle: CaseIterable { 4 | case full 5 | case card 6 | case popover 7 | case sheet 8 | } 9 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalDecoration+SheetHandle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | extension ModalDecoration { 5 | /// Describes a handle which is typically attached to the top of a sheet to hint 6 | /// at interactive dismissal. 7 | /// 8 | /// - Parameters: 9 | /// - frame: The frame of the handle. 10 | /// - color: The color of the handle. 11 | /// - size: The size of the handle. 12 | /// - offset: The distance from the top of the modal to the bottom of the handle. 13 | /// - Returns: A `ModalDecoration` representing a handle. 14 | static func handle( 15 | in frame: CGRect, 16 | color: UIColor, 17 | size: CGSize, 18 | offset: CGFloat 19 | ) -> ModalDecoration { 20 | let frame = CGRect( 21 | origin: CGPoint( 22 | x: (frame.width - size.width) / 2, 23 | y: -offset - size.height 24 | ), 25 | size: size 26 | ) 27 | let corners = ModalRoundedCorners(radius: size.height / 2) 28 | 29 | return ModalDecoration( 30 | frame: frame, 31 | build: UIView.init, 32 | update: { handle in 33 | handle.backgroundColor = color 34 | corners.apply(toView: handle) 35 | } 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalPresentationStyleProvider+Examples.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | import ViewEnvironment 4 | 5 | extension ModalPresentationStyleProvider { 6 | public static let full = ModalPresentationStyleProvider { viewEnvironment in 7 | FullModalStyle(stylesheet: viewEnvironment.modalStylesheet) 8 | } 9 | 10 | public static let card = ModalPresentationStyleProvider { viewEnvironment in 11 | CardModalStyle(stylesheet: viewEnvironment.modalStylesheet) 12 | } 13 | 14 | public static func sheet(onDismiss: @escaping () -> Void) -> ModalPresentationStyleProvider { 15 | ModalPresentationStyleProvider { viewEnvironment in 16 | SheetModalStyle( 17 | stylesheet: viewEnvironment.modalStylesheet, 18 | onDismiss: onDismiss 19 | ) 20 | } 21 | } 22 | 23 | public static func popover( 24 | anchor: UICoordinateSpace, 25 | onDismiss: @escaping () -> Void 26 | ) -> ModalPresentationStyleProvider { 27 | ModalPresentationStyleProvider { viewEnvironment in 28 | PopoverModalStyle( 29 | stylesheet: viewEnvironment.modalStylesheet, 30 | anchor: anchor, 31 | onDismiss: onDismiss 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalStyles/CardModalStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | public struct CardModalStyle: ModalPresentationStyle { 5 | 6 | public var stylesheet: ModalStylesheet 7 | 8 | /// The sizing behavior for the height of the modal. 9 | public var sizing: ModalHeightSizing 10 | 11 | 12 | public init( 13 | stylesheet: ModalStylesheet, 14 | sizing: ModalHeightSizing = .content 15 | ) { 16 | self.stylesheet = stylesheet 17 | self.sizing = sizing 18 | } 19 | 20 | public func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences { 21 | ModalBehaviorPreferences(usesPreferredContentSize: sizing.usesPreferredContentSize) 22 | } 23 | 24 | public func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues { 25 | ModalDisplayValues( 26 | frame: frame(for: context), 27 | roundedCorners: roundedCorners(), 28 | overlayOpacity: stylesheet.overlayOpacity 29 | ) 30 | } 31 | 32 | public func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 33 | ModalTransitionValues( 34 | frame: frame(for: context), 35 | alpha: 0, 36 | transform: CGAffineTransform(scaleX: stylesheet.enterScale, y: stylesheet.enterScale), 37 | roundedCorners: roundedCorners(), 38 | animation: stylesheet.scaleInAnimation 39 | ) 40 | } 41 | 42 | public func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 43 | ModalTransitionValues( 44 | frame: frame(for: context), 45 | alpha: 0, 46 | transform: CGAffineTransform(scaleX: stylesheet.exitScale, y: stylesheet.exitScale), 47 | roundedCorners: roundedCorners(), 48 | animation: stylesheet.scaleOutAnimation 49 | ) 50 | } 51 | 52 | private func roundedCorners() -> ModalRoundedCorners { 53 | ModalRoundedCorners(radius: stylesheet.cornerRadius, corners: .all, curve: .continuous) 54 | } 55 | 56 | private func frame(for context: ModalPresentationContext) -> CGRect { 57 | // inset by the safe area insets 58 | let availableSize = context.containerSize 59 | .subtracting(insets: context.containerSafeAreaInsets) 60 | 61 | // Ensure our width is narrow enough for our insets; 62 | // stretch to the maximum width if there's enough room. 63 | let modalWidth = min( 64 | stylesheet.cardMaximumWidth, 65 | availableSize.width - stylesheet.horizontalInsets * 2 66 | ) 67 | 68 | let maximumHeight = availableSize.height - stylesheet.verticalInsets * 2 69 | let modalHeight = sizing.height(for: context.preferredContentSize, maximumHeight: maximumHeight) 70 | 71 | // Center position. 72 | let origin = CGPoint( 73 | x: (context.containerSize.width - modalWidth) / 2, 74 | y: (context.containerSize.height - modalHeight) / 2 75 | ) 76 | 77 | let size = CGSize(width: modalWidth, height: modalHeight) 78 | return CGRect(origin: origin, size: size) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalStyles/FullModalStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | public struct FullModalStyle: ModalPresentationStyle { 5 | 6 | public var stylesheet: ModalStylesheet 7 | 8 | public init(stylesheet: ModalStylesheet) { 9 | self.stylesheet = stylesheet 10 | } 11 | 12 | public func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences { 13 | ModalBehaviorPreferences(usesPreferredContentSize: false) 14 | } 15 | 16 | public func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues { 17 | ModalDisplayValues( 18 | frame: context.containerCoordinateSpace.bounds, 19 | overlayOpacity: stylesheet.overlayOpacity 20 | ) 21 | } 22 | 23 | public func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 24 | ModalTransitionValues( 25 | frame: CGRect( 26 | x: 0, 27 | y: context.containerSize.height, 28 | width: context.containerSize.width, 29 | height: context.containerSize.height 30 | ) 31 | ) 32 | } 33 | 34 | public func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 35 | enterTransitionValues(for: context) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalStyles/SheetModalStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | public struct SheetModalStyle: ModalPresentationStyle { 5 | 6 | public var stylesheet: ModalStylesheet 7 | public var onDismiss: () -> Void 8 | 9 | public init( 10 | stylesheet: ModalStylesheet, 11 | onDismiss: @escaping () -> Void 12 | ) { 13 | self.stylesheet = stylesheet 14 | self.onDismiss = onDismiss 15 | } 16 | 17 | public func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences { 18 | ModalBehaviorPreferences( 19 | overlayTap: .dismiss(onDismiss: onDismiss), 20 | interactiveDismiss: .swipeDown(onDismiss: onDismiss), 21 | usesPreferredContentSize: true 22 | ) 23 | } 24 | 25 | public func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues { 26 | let frame = frame(for: context) 27 | 28 | return ModalDisplayValues( 29 | frame: frame, 30 | roundedCorners: roundedCorners(), 31 | overlayOpacity: stylesheet.overlayOpacity, 32 | decorations: [ 33 | .handle( 34 | in: frame, 35 | color: stylesheet.handleColor, 36 | size: stylesheet.handleSize, 37 | offset: stylesheet.handleOffset 38 | ), 39 | ] 40 | ) 41 | } 42 | 43 | public func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 44 | ModalTransitionValues( 45 | frame: enterExitFrame(for: context), 46 | roundedCorners: roundedCorners() 47 | ) 48 | } 49 | 50 | public func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 51 | ModalTransitionValues( 52 | frame: enterExitFrame(for: context), 53 | roundedCorners: roundedCorners() 54 | ) 55 | } 56 | 57 | public func reverseTransitionValues(for context: ModalPresentationContext) -> ModalReverseTransitionValues? { 58 | // Move the whole frame up for the reverse transition, since our content isn't stretchy. 59 | var frame = frame(for: context) 60 | frame.origin.y -= stylesheet.reverseTransitionInset 61 | return ModalReverseTransitionValues(frame: frame) 62 | } 63 | 64 | private func enterExitFrame(for context: ModalPresentationContext) -> CGRect { 65 | var frame = frame(for: context) 66 | frame.origin.y = context.containerSize.height 67 | return frame 68 | } 69 | 70 | private func roundedCorners() -> ModalRoundedCorners { 71 | ModalRoundedCorners(radius: stylesheet.cornerRadius, corners: .all, curve: .continuous) 72 | } 73 | 74 | private func frame(for context: ModalPresentationContext) -> CGRect { 75 | // Sheet modal is inset by the safe area insets 76 | let availableSize = context.containerSize 77 | .subtracting(insets: context.containerSafeAreaInsets) 78 | .subtracting(width: stylesheet.horizontalInsets) 79 | .subtracting(height: stylesheet.verticalInsets) 80 | 81 | let maximumHeight = availableSize.height 82 | 83 | // This style should only be used if the viewport is > maximumWidth, 84 | // but ensure we don't overflow just in case. 85 | let modalWidth = availableSize.width.upperBounded(by: stylesheet.cardMaximumWidth) 86 | 87 | let modalHeight = context.preferredContentSize.value?.height 88 | .upperBounded(by: maximumHeight) ?? maximumHeight 89 | 90 | // Center in the x-axis, and position relative to the safe area in the y-axis. 91 | let origin = CGPoint( 92 | x: (context.containerSize.width - modalWidth) / 2, 93 | y: context.containerSize.height 94 | - context.containerSafeAreaInsets.bottom 95 | - stylesheet.verticalInsets 96 | - modalHeight 97 | ) 98 | 99 | let size = CGSize(width: modalWidth, height: modalHeight) 100 | return CGRect(origin: origin, size: size) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ModalStylesheet.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | import ViewEnvironment 4 | 5 | public struct ModalStylesheet { 6 | public var overlayOpacity: CGFloat = 0.75 7 | 8 | public var minimumWidth: CGFloat = 200 9 | public var cardMaximumWidth: CGFloat = 500 10 | public var maximumHeight: CGFloat = 500 11 | 12 | public var horizontalInsets: CGFloat = 8 13 | public var verticalInsets: CGFloat = 8 14 | 15 | public var animationDuration: TimeInterval = 0.3 16 | 17 | /// Modals that scale in will animate from this scale to full size on the transition in. 18 | public var enterScale: CGFloat = 0.75 19 | /// Modals that scale in will animate from full size to this scale on the transition out. 20 | public var exitScale: CGFloat = 0.75 21 | 22 | public var cornerRadius: CGFloat = 6 23 | 24 | public var handleColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.4) 25 | public var handleSize = CGSize(width: 56, height: 6) 26 | public var handleOffset: CGFloat = 8 27 | 28 | public var horizontalAnchorSpacing: CGFloat = 8 29 | public var verticalAnchorSpacing: CGFloat = 8 30 | 31 | public var reverseTransitionInset: CGFloat = 32 32 | 33 | public var shadow: ModalShadow = ModalShadow( 34 | radius: 9, 35 | opacity: 0.2, 36 | offset: UIOffset(horizontal: 0, vertical: 4), 37 | color: .black 38 | ) 39 | 40 | public var scaleInAnimation: ModalAnimation { 41 | .curve(.easeOut, duration: animationDuration) 42 | } 43 | 44 | public var scaleOutAnimation: ModalAnimation { 45 | .curve(.easeIn, duration: animationDuration) 46 | } 47 | 48 | public init() {} 49 | } 50 | 51 | enum ModalStylesheetKey: ViewEnvironmentKey { 52 | static let defaultValue = ModalStylesheet() 53 | } 54 | 55 | extension ViewEnvironment { 56 | public var modalStylesheet: ModalStylesheet { 57 | get { self[ModalStylesheetKey.self] } 58 | set { self[ModalStylesheetKey.self] = newValue } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ToastContainerPresentationStyleProvider+Example.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | 3 | extension ToastContainerPresentationStyleProvider { 4 | public static let example = ToastContainerPresentationStyleProvider( 5 | ExampleToastContainerPresentationStyle() 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /Samples/ExampleStyles/Sources/ToastStyles/ExampleToastPresentationStyle.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | /// A simple toast presentation style. 5 | /// 6 | /// - Note: This style primarily describes the toast's behavior (e.g. auto-dismiss, interactive dismissal, etc.). See 7 | /// ``ExampleToastContainerPresentationStyle`` for appearance 8 | /// preferences. 9 | /// 10 | public struct ExampleToastPresentationStyle: ToastPresentationStyle { 11 | 12 | public var onDismiss: () -> Void 13 | 14 | public init(onDismiss: @escaping () -> Void) { 15 | self.onDismiss = onDismiss 16 | } 17 | 18 | public func behaviorPreferences(for context: ToastBehaviorContext) -> ToastBehaviorPreferences { 19 | ToastBehaviorPreferences( 20 | presentationHaptic: .warning, 21 | timedDismiss: .after( 22 | duration: 5, 23 | onDismiss: onDismiss 24 | ), 25 | interactiveDismiss: .swipeDown(onDismiss: onDismiss) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Samples/Project.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjectDescription 3 | import ProjectDescriptionHelpers 4 | 5 | let project = Project( 6 | name: "ModalsDevelopment", 7 | settings: .settings(base: ["ENABLE_MODULE_VERIFIER": "YES"]), 8 | targets: [ 9 | 10 | .target( 11 | name: "ExampleStyles", 12 | sources: "ExampleStyles/Sources/**", 13 | dependencies: [ 14 | .external(name: "Modals"), 15 | ] 16 | ), 17 | 18 | .app( 19 | name: "UIKitApp", 20 | sources: "UIKitApp/Sources/**", 21 | dependencies: [ 22 | .target(name: "ExampleStyles"), 23 | .external(name: "Modals"), 24 | ] 25 | ), 26 | 27 | .app( 28 | name: "WorkflowApp", 29 | sources: "WorkflowApp/Sources/**", 30 | dependencies: [ 31 | .target(name: "ExampleStyles"), 32 | .external(name: "WorkflowModals"), 33 | ] 34 | ), 35 | 36 | .target( 37 | name: "TestingSupport", 38 | sources: "../TestingSupport/Sources/**", 39 | dependencies: [ 40 | .external(name: "Modals"), 41 | .xctest, 42 | ] 43 | ), 44 | .app( 45 | name: "TestAppHost", 46 | sources: "../TestingSupport/AppHost/Sources/**", 47 | dependencies: [ 48 | .external(name: "Modals"), 49 | .external(name: "Logging"), 50 | ] 51 | ), 52 | 53 | .unitTest( 54 | for: "Modals", 55 | dependencies: [ 56 | .target(name: "TestingSupport"), 57 | .target(name: "TestAppHost"), 58 | ] 59 | ), 60 | .unitTest( 61 | for: "WorkflowModals", 62 | dependencies: [ 63 | .external(name: "WorkflowCombine"), 64 | .target(name: "TestingSupport"), 65 | .target(name: "TestAppHost"), 66 | ] 67 | ), 68 | ], 69 | schemes: [ 70 | .scheme( 71 | name: "UnitTests", 72 | testAction: .targets( 73 | [ 74 | "Modals-Tests", 75 | "WorkflowModals-Tests", 76 | ] 77 | ) 78 | ), 79 | .scheme( 80 | name: "Samples", 81 | buildAction: .buildAction( 82 | targets: [ 83 | "WorkflowApp", 84 | ] 85 | ) 86 | ), 87 | ] 88 | ) 89 | -------------------------------------------------------------------------------- /Samples/Tuist/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "reactiveswift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", 7 | "state" : { 8 | "revision" : "c5eecb5374ac342e22d46abd333e4c8c698c93cc", 9 | "version" : "7.2.0" 10 | } 11 | }, 12 | { 13 | "identity" : "rxswift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/ReactiveX/RxSwift.git", 16 | "state" : { 17 | "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", 18 | "version" : "6.9.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", 27 | "version" : "1.6.1" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 36 | "version" : "1.1.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-custom-dump", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 43 | "state" : { 44 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 45 | "version" : "1.3.3" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-identified-collections", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 52 | "state" : { 53 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", 54 | "version" : "1.1.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-log", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-log", 61 | "state" : { 62 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", 63 | "version" : "1.6.2" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-perception", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-perception", 70 | "state" : { 71 | "revision" : "671fa54b279fd73933b4a8b34782ebf6c8869145", 72 | "version" : "1.5.1" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-syntax", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/swiftlang/swift-syntax", 79 | "state" : { 80 | "revision" : "0687f71944021d616d34d922343dcef086855920", 81 | "version" : "600.0.1" 82 | } 83 | }, 84 | { 85 | "identity" : "workflow-swift", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/square/workflow-swift", 88 | "state" : { 89 | "revision" : "4b9f961c1501b53a48c4b46ce885159e897fbd95", 90 | "version" : "3.14.1" 91 | } 92 | }, 93 | { 94 | "identity" : "xctest-dynamic-overlay", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 97 | "state" : { 98 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 99 | "version" : "1.5.2" 100 | } 101 | } 102 | ], 103 | "version" : 2 104 | } 105 | -------------------------------------------------------------------------------- /Samples/Tuist/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | #if TUIST 6 | import ProjectDescription 7 | 8 | let packageSettings = PackageSettings( 9 | productTypes: [ 10 | "Logging": .framework, 11 | "Modals": .framework, 12 | "ViewEnvironment": .framework, 13 | "ViewEnvironmentUI": .framework, 14 | "Workflow": .framework, 15 | "WorkflowModals": .framework, 16 | "WorkflowUI": .framework, 17 | "ReactiveSwift": .framework, 18 | ] 19 | ) 20 | #endif 21 | 22 | let package = Package( 23 | name: "Development", 24 | dependencies: [ 25 | .package(path: "../../"), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Samples/Tuist/ProjectDescriptionHelpers/Project+Modals.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjectDescription 3 | 4 | public let modalsBundleIdPrefix = "com.squareup.modals" 5 | public let modalsDestinations: ProjectDescription.Destinations = .iOS 6 | public let modalsDeploymentTargets: DeploymentTargets = .iOS("16.0") 7 | 8 | extension Target { 9 | public static func app( 10 | name: String, 11 | sources: ProjectDescription.SourceFilesList, 12 | resources: ProjectDescription.ResourceFileElements? = nil, 13 | dependencies: [TargetDependency] = [] 14 | ) -> Self { 15 | .target( 16 | name: name, 17 | destinations: modalsDestinations, 18 | product: .app, 19 | bundleId: "\(modalsBundleIdPrefix).\(name)", 20 | deploymentTargets: modalsDeploymentTargets, 21 | infoPlist: .extendingDefault( 22 | with: [ 23 | "UILaunchScreen": ["UIColorName": ""], 24 | ] 25 | ), 26 | sources: sources, 27 | resources: resources, 28 | dependencies: dependencies 29 | ) 30 | } 31 | 32 | public static func target( 33 | name: String, 34 | sources: ProjectDescription.SourceFilesList? = nil, 35 | resources: ProjectDescription.ResourceFileElements? = nil, 36 | dependencies: [TargetDependency] = [] 37 | ) -> Self { 38 | .target( 39 | name: name, 40 | destinations: modalsDestinations, 41 | product: .framework, 42 | bundleId: "\(modalsBundleIdPrefix).\(name)", 43 | deploymentTargets: modalsDeploymentTargets, 44 | sources: sources ?? "\(name)/Sources/**", 45 | resources: resources, 46 | dependencies: dependencies 47 | ) 48 | } 49 | 50 | public static func unitTest( 51 | for moduleUnderTest: String, 52 | testName: String = "Tests", 53 | sources: ProjectDescription.SourceFilesList? = nil, 54 | dependencies: [TargetDependency] = [], 55 | environmentVariables: [String: EnvironmentVariable] = [:] 56 | ) -> Self { 57 | let name = "\(moduleUnderTest)-\(testName)" 58 | return .target( 59 | name: name, 60 | destinations: modalsDestinations, 61 | product: .unitTests, 62 | bundleId: "\(modalsBundleIdPrefix).\(name)", 63 | deploymentTargets: modalsDeploymentTargets, 64 | sources: sources ?? "../\(moduleUnderTest)/\(testName)/**", 65 | dependencies: [.external(name: moduleUnderTest)] + dependencies, 66 | environmentVariables: environmentVariables 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Samples/UIKitApp/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import ExampleStyles 2 | import Modals 3 | import UIKit 4 | 5 | @main 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | var window: UIWindow? 8 | 9 | func application( 10 | _ application: UIApplication, 11 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 12 | ) -> Bool { 13 | let root = ModalHostContainerViewController( 14 | content: ExampleViewController(), 15 | toastContainerStyle: .example 16 | ) 17 | root.view.backgroundColor = .systemBackground 18 | 19 | window = UIWindow(frame: UIScreen.main.bounds) 20 | window?.rootViewController = root 21 | window?.makeKeyAndVisible() 22 | 23 | return true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Samples/UIKitApp/Sources/ExampleViewController.swift: -------------------------------------------------------------------------------- 1 | import ExampleStyles 2 | import Modals 3 | import UIKit 4 | 5 | final class ExampleViewController: UIViewController { 6 | 7 | var onDismissTapped: (() -> Void)? 8 | 9 | private let stackView = UIStackView() 10 | 11 | // Lifetime of the modal presented by this view controller. Must be retained to keep the modal presented. Deallocating the lifetime or calling `dismiss` on it will dismiss the modal. 12 | private var modalLifetime: ModalLifetime? 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | 17 | view.backgroundColor = .systemBackground 18 | 19 | let buttons = ExampleStyle.allCases.map { exampleStyle in 20 | let button = UIButton(type: .system) 21 | button.setTitle("Present \(exampleStyle)", for: .normal) 22 | let action = UIAction(title: "Present \(exampleStyle)") { [weak self] action in 23 | self?.presentTapped(button: button, exampleStyle: exampleStyle) 24 | } 25 | button.addAction(action, for: .touchUpInside) 26 | return button 27 | } 28 | 29 | for button in buttons { 30 | stackView.addArrangedSubview(button) 31 | } 32 | 33 | if onDismissTapped != nil { 34 | stackView.addArrangedSubview( 35 | UIButton( 36 | type: .system, 37 | primaryAction: UIAction(title: "Dismiss") { [weak self] _ in 38 | self?.dismissTapped() 39 | } 40 | ) 41 | ) 42 | } 43 | 44 | stackView.axis = .vertical 45 | stackView.distribution = .equalSpacing 46 | stackView.alignment = .center 47 | 48 | view.addSubview(stackView) 49 | } 50 | 51 | override func viewDidLayoutSubviews() { 52 | super.viewDidLayoutSubviews() 53 | 54 | let size = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) 55 | stackView.frame = CGRect( 56 | x: 0, 57 | y: (view.bounds.height - size.height) / 2, 58 | width: view.bounds.width, 59 | height: size.height 60 | ) 61 | 62 | if size != preferredContentSize { 63 | preferredContentSize = size 64 | } 65 | } 66 | 67 | func presentTapped(button: UIButton, exampleStyle: ExampleStyle) { 68 | // Present a new instance of the same view controller type. 69 | // This demonstrates how you can recursively present modals from modals. 70 | let viewControllerToPresent = ExampleViewController() 71 | 72 | // This hook is used to dismiss the modal we're about to present. We'll pass it to the 73 | // view controller itself (for a dismiss button), and to styles that support an intrinsic 74 | // dismissal method like swiping a sheet or tapping on the scrim of a popover. 75 | let onDismiss: (() -> Void) = { [weak self] in 76 | // Releasing the lifetime will dismiss the modal. You can also call `dismiss` on it. 77 | self?.modalLifetime = nil 78 | } 79 | viewControllerToPresent.onDismissTapped = onDismiss 80 | 81 | let style: ModalPresentationStyleProvider = switch exampleStyle { 82 | case .full: 83 | .full 84 | case .card: 85 | .card 86 | case .popover: 87 | .popover(anchor: button, onDismiss: onDismiss) 88 | case .sheet: 89 | .sheet(onDismiss: onDismiss) 90 | } 91 | 92 | // Present the view controller and retain its lifetime token. 93 | modalLifetime = modalPresenter.present(viewControllerToPresent, style: style) 94 | } 95 | 96 | func dismissTapped() { 97 | onDismissTapped?() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Samples/WorkflowApp/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Workflow 3 | import WorkflowModals 4 | import WorkflowUI 5 | 6 | @main 7 | class AppDelegate: UIResponder, UIApplicationDelegate { 8 | var window: UIWindow? 9 | 10 | func application( 11 | _ application: UIApplication, 12 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 13 | ) -> Bool { 14 | let root = WorkflowHostingController( 15 | workflow: ExampleWorkflow(isRootWorkflow: true).mapRendering { content in 16 | ModalHostContainer(content: content, toastContainerStyle: .example) 17 | } 18 | ) 19 | root.view.backgroundColor = .systemBackground 20 | 21 | window = UIWindow(frame: UIScreen.main.bounds) 22 | window?.rootViewController = root 23 | window?.makeKeyAndVisible() 24 | 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Samples/WorkflowApp/Sources/ExampleScreen.swift: -------------------------------------------------------------------------------- 1 | import ExampleStyles 2 | import UIKit 3 | import Workflow 4 | import WorkflowModals 5 | import WorkflowUI 6 | 7 | struct ExampleScreen: Screen { 8 | var onPresent: (ExampleStyle, UICoordinateSpace) -> Void 9 | var onDismiss: (() -> Void)? 10 | 11 | func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { 12 | ViewController.description(for: self, environment: environment) 13 | } 14 | } 15 | 16 | extension ExampleScreen { 17 | final class ViewController: ScreenViewController { 18 | 19 | @MainActor required init(screen: ExampleScreen, environment: ViewEnvironment) { 20 | super.init(screen: screen, environment: environment) 21 | } 22 | 23 | private var onDismiss: (() -> Void)? = nil 24 | private var onPresent: (ExampleStyle, UICoordinateSpace) -> Void = { _, _ in } 25 | 26 | private let stackView = UIStackView() 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | view.backgroundColor = .systemBackground 32 | 33 | let buttons = ExampleStyle.allCases.map { exampleStyle in 34 | let button = UIButton(type: .system) 35 | button.setTitle("Present \(exampleStyle)", for: .normal) 36 | let action = UIAction(title: "Present \(exampleStyle)") { [weak self] action in 37 | self?.onPresent(exampleStyle, button) 38 | } 39 | button.addAction(action, for: .touchUpInside) 40 | return button 41 | } 42 | 43 | for button in buttons { 44 | stackView.addArrangedSubview(button) 45 | } 46 | 47 | stackView.addArrangedSubview( 48 | UIButton( 49 | type: .system, 50 | primaryAction: UIAction(title: "Dismiss") { [weak self] _ in 51 | self?.onDismiss?() 52 | } 53 | ) 54 | ) 55 | 56 | stackView.axis = .vertical 57 | stackView.distribution = .equalSpacing 58 | stackView.alignment = .center 59 | 60 | view.addSubview(stackView) 61 | } 62 | 63 | override func viewDidLayoutSubviews() { 64 | super.viewDidLayoutSubviews() 65 | 66 | let size = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) 67 | stackView.frame = CGRect( 68 | x: 0, 69 | y: (view.bounds.height - size.height) / 2, 70 | width: view.bounds.width, 71 | height: size.height 72 | ) 73 | 74 | if size != preferredContentSize { 75 | preferredContentSize = size 76 | } 77 | } 78 | 79 | override func screenDidChange(from previousScreen: ExampleScreen, previousEnvironment: ViewEnvironment) { 80 | super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) 81 | 82 | onPresent = screen.onPresent 83 | onDismiss = screen.onDismiss 84 | 85 | let shouldShowDismissButton = onDismiss != nil 86 | stackView.arrangedSubviews.last?.isHidden = !shouldShowDismissButton 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Samples/WorkflowApp/Sources/ExampleWorkflow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Workflow 3 | import WorkflowModals 4 | import WorkflowUI 5 | 6 | struct ExampleWorkflow: Workflow { 7 | typealias Rendering = ModalContainer 8 | 9 | enum Action: WorkflowAction { 10 | typealias WorkflowType = ExampleWorkflow 11 | 12 | case present(ExamplePresentation) 13 | case dismiss 14 | 15 | func apply(toState state: inout ExampleWorkflow.State) -> ExampleWorkflow.Output? { 16 | switch self { 17 | case .present(let presentation): 18 | state.presentation = presentation 19 | case .dismiss: 20 | state.presentation = nil 21 | } 22 | return nil 23 | } 24 | } 25 | 26 | enum Output { 27 | case dismissed 28 | } 29 | 30 | struct State { 31 | var presentation: ExamplePresentation? 32 | } 33 | 34 | // If true, don't show the dismiss button 35 | var isRootWorkflow = false 36 | 37 | func makeInitialState() -> State { 38 | State() 39 | } 40 | 41 | func render(state: State, context: RenderContext) -> Rendering { 42 | let sink = context.makeSink(of: Action.self) 43 | let outputSink = context.makeOutputSink() 44 | 45 | // If we are in a presenting state, render a child workflow and wrap it in a modal. 46 | // We must erase to AnyScreen because we're recursively rendering the same workflow. 47 | var childScreen: AnyScreen { 48 | ExampleWorkflow().rendered( 49 | in: context, 50 | outputMap: { output in 51 | switch output { 52 | case .dismissed: 53 | Action.dismiss 54 | } 55 | } 56 | ) 57 | .asAnyScreen() 58 | } 59 | 60 | return ModalContainer { 61 | ExampleScreen( 62 | onPresent: { exampleStyle, coordinateSpace in 63 | switch exampleStyle { 64 | case .full: 65 | sink.send(.present(.full)) 66 | case .card: 67 | sink.send(.present(.card)) 68 | case .popover: 69 | sink.send(.present(.popover(anchor: coordinateSpace))) 70 | case .sheet: 71 | sink.send(.present(.sheet)) 72 | } 73 | }, 74 | onDismiss: isRootWorkflow 75 | ? nil 76 | : { outputSink.send(.dismissed) } 77 | ) 78 | } modals: { 79 | switch state.presentation { 80 | case .full: 81 | Modal( 82 | key: "full-modal", 83 | style: .full, 84 | screen: childScreen 85 | ) 86 | 87 | case .card: 88 | Modal( 89 | key: "card-modal", 90 | style: .card, 91 | screen: childScreen 92 | ) 93 | 94 | case .popover(let anchor): 95 | Modal( 96 | key: "popover", 97 | style: .popover(anchor: anchor, onDismiss: { sink.send(.dismiss) }), 98 | content: childScreen 99 | ) 100 | 101 | case .sheet: 102 | Modal( 103 | key: "sheet", 104 | style: .sheet(onDismiss: { sink.send(.dismiss) }), 105 | content: childScreen 106 | ) 107 | 108 | case .none: 109 | [] 110 | } 111 | } 112 | } 113 | } 114 | 115 | enum ExamplePresentation { 116 | case full 117 | case card 118 | case popover(anchor: UICoordinateSpace) 119 | case sheet 120 | } 121 | -------------------------------------------------------------------------------- /Samples/Workspace.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let workspace = Workspace( 5 | name: "ModalsDevelopment", 6 | projects: ["."], 7 | schemes: [ 8 | // Generate a scheme for each target in Package.swift for convenience 9 | .modals("Modals"), 10 | .modals("WorkflowModals"), 11 | .scheme( 12 | name: "Documentation", 13 | buildAction: .buildAction( 14 | targets: [ 15 | .project(path: "..", target: "Modals"), 16 | .project(path: "..", target: "WorkflowModals"), 17 | ] 18 | ) 19 | ), 20 | ] 21 | ) 22 | 23 | extension Scheme { 24 | public static func modals(_ target: String) -> Self { 25 | .scheme( 26 | name: target, 27 | buildAction: .buildAction(targets: [.project(path: "..", target: target)]) 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Scripts/generate_docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BUILD_PATH=docs_build 4 | MERGED_PATH=generated_docs 5 | 6 | xcodebuild docbuild \ 7 | -scheme Documentation \ 8 | -derivedDataPath "$BUILD_PATH" \ 9 | -workspace Samples/ModalsDevelopment.xcworkspace \ 10 | -destination generic/platform=iOS \ 11 | DOCC_HOSTING_BASE_PATH='swift-modals' \ 12 | | xcpretty 13 | 14 | find_archive() { 15 | find "$BUILD_PATH" -type d -name "$1.doccarchive" -print -quit 16 | } 17 | 18 | xcrun docc merge \ 19 | $(find_archive Modals) \ 20 | $(find_archive WorkflowModals) \ 21 | --output-path "$MERGED_PATH" \ 22 | --synthesized-landing-page-name "swift-modals" 23 | -------------------------------------------------------------------------------- /TestingSupport/AppHost/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | import Modals 3 | import UIKit 4 | 5 | @main 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | 8 | var window: UIWindow? 9 | 10 | func application( 11 | _ application: UIApplication, 12 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 13 | ) -> Bool { 14 | LoggingSystem.bootstrap { _ in 15 | SwiftLogNoOpLogHandler() 16 | } 17 | 18 | ToastPresentationViewController.configure(with: application) 19 | 20 | window = UIWindow(frame: UIScreen.main.bounds) 21 | window?.rootViewController = UIViewController() 22 | 23 | window?.makeKeyAndVisible() 24 | 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TestingSupport/Sources/FullScreenModalStyle.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import Modals 3 | import ViewEnvironment 4 | 5 | 6 | public struct FullScreenModalStyle: ModalPresentationStyle { 7 | public var key: String 8 | public var environmentCustomization: (inout ViewEnvironment) -> Void 9 | 10 | public init( 11 | key: String = "", 12 | environmentCustomization: @escaping (inout ViewEnvironment) -> Void = { _ in } 13 | ) { 14 | self.key = key 15 | self.environmentCustomization = environmentCustomization 16 | } 17 | 18 | public func behaviorPreferences(for context: ModalBehaviorContext) -> ModalBehaviorPreferences { 19 | ModalBehaviorPreferences(usesPreferredContentSize: false) 20 | } 21 | 22 | public func displayValues(for context: ModalPresentationContext) -> ModalDisplayValues { 23 | ModalDisplayValues( 24 | frame: context.containerCoordinateSpace.bounds, 25 | overlayOpacity: 0.6 26 | ) 27 | } 28 | 29 | public func enterTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 30 | ModalTransitionValues( 31 | frame: CGRect( 32 | x: 0, 33 | y: context.containerSize.height, 34 | width: context.containerSize.width, 35 | height: context.containerSize.height 36 | ) 37 | ) 38 | } 39 | 40 | public func exitTransitionValues(for context: ModalPresentationContext) -> ModalTransitionValues { 41 | ModalTransitionValues( 42 | frame: CGRect( 43 | x: 0, 44 | y: context.containerSize.height, 45 | width: context.containerSize.width, 46 | height: context.containerSize.height 47 | ) 48 | ) 49 | } 50 | 51 | public func customize(environment: inout ViewEnvironment) { 52 | environmentCustomization(&environment) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /TestingSupport/Sources/ModalHostContainerViewController+Testing.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | extension ModalHostContainerViewController { 5 | 6 | public convenience init( 7 | content: UIViewController, 8 | shouldPassthroughToasts: Bool = true 9 | ) { 10 | self.init( 11 | content: content, 12 | toastContainerStyle: .fixture, 13 | shouldPassthroughToasts: shouldPassthroughToasts 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /TestingSupport/Sources/ToastContainerPresentationStyleFixture.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | 3 | struct ToastContainerPresentationStyleFixture: ToastContainerPresentationStyle { 4 | func displayValues(for context: ToastDisplayContext) -> ToastDisplayValues { 5 | .init( 6 | presentedValues: context.preheatValues.map { 7 | .init( 8 | frame: .init( 9 | origin: .zero, 10 | size: $0.preferredContentSize 11 | ) 12 | ) 13 | } 14 | ) 15 | } 16 | 17 | func enterTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues { 18 | .init(frame: context.displayFrame) 19 | } 20 | 21 | func exitTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues { 22 | .init(frame: context.displayFrame) 23 | } 24 | 25 | func interactiveExitTransitionValues(for context: ToastInteractiveExitContext) -> ToastTransitionValues { 26 | .init(frame: context.presentedFrame) 27 | } 28 | 29 | func reverseTransitionValues(for context: ToastTransitionContext) -> ToastTransitionValues { 30 | .init(frame: context.displayFrame) 31 | } 32 | 33 | func preheatValues(for context: ToastPreheatContext) -> ToastPreheatValues { 34 | .init(size: context.containerSize) 35 | } 36 | 37 | func isEqual(to other: ToastContainerPresentationStyle) -> Bool { 38 | true 39 | } 40 | } 41 | 42 | extension ToastContainerPresentationStyleProvider { 43 | 44 | public static let fixture: Self = .init(ToastContainerPresentationStyleFixture()) 45 | } 46 | -------------------------------------------------------------------------------- /TestingSupport/Sources/ToastPresentationStyleFixture.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | 4 | 5 | public struct ToastPresentationStyleFixture: ToastPresentationStyle { 6 | public var key: String 7 | public var haptic: UINotificationFeedbackGenerator.FeedbackType 8 | 9 | public init(key: String = "", haptic: UINotificationFeedbackGenerator.FeedbackType = .warning) { 10 | self.key = key 11 | self.haptic = haptic 12 | } 13 | 14 | public func behaviorPreferences(for context: ToastBehaviorContext) -> ToastBehaviorPreferences { 15 | .init( 16 | presentationHaptic: haptic, 17 | timedDismiss: .disabled, 18 | interactiveDismiss: .disabled 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /TestingSupport/Sources/XCTestCase+AppHost.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | 4 | 5 | extension XCTestCase { 6 | 7 | /// 8 | /// Call this method to show a view controller in the test host application 9 | /// during a unit test. The view controller will be the size of host application's device. 10 | /// 11 | /// After the test runs, the view controller will be removed from the view hierarchy. 12 | /// 13 | /// A test failure will occur if the host application does not exist, or does not have a root view controller. 14 | /// 15 | public func show( 16 | vc viewController: ViewController, 17 | test: (ViewController) throws -> Void 18 | ) rethrows { 19 | 20 | guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else { 21 | XCTFail("Cannot present a view controller in a test host that does not have a root window.") 22 | return 23 | } 24 | 25 | rootVC.addChild(viewController) 26 | viewController.didMove(toParent: rootVC) 27 | 28 | viewController.view.frame = rootVC.view.bounds 29 | viewController.view.layoutIfNeeded() 30 | 31 | rootVC.beginAppearanceTransition(true, animated: false) 32 | rootVC.view.addSubview(viewController.view) 33 | rootVC.endAppearanceTransition() 34 | 35 | defer { 36 | viewController.beginAppearanceTransition(false, animated: false) 37 | viewController.view.removeFromSuperview() 38 | viewController.endAppearanceTransition() 39 | 40 | viewController.willMove(toParent: nil) 41 | viewController.removeFromParent() 42 | } 43 | 44 | try autoreleasepool { 45 | try test(viewController) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/Builder.swift: -------------------------------------------------------------------------------- 1 | /// Generic result builder for converting blocks of `Child...` into `[Child]`. 2 | @resultBuilder 3 | public struct Builder { 4 | public typealias Children = [Child] 5 | 6 | public static func buildBlock(_ children: Children...) -> Children { 7 | children.flatMap { $0 } 8 | } 9 | 10 | public static func buildOptional(_ children: Children?) -> Children { 11 | children ?? [] 12 | } 13 | 14 | public static func buildEither(first: Children) -> Children { 15 | first 16 | } 17 | 18 | public static func buildEither(second: Children) -> Children { 19 | second 20 | } 21 | 22 | public static func buildExpression(_ child: Child) -> Children { 23 | [child] 24 | } 25 | 26 | @_disfavoredOverload 27 | public static func buildExpression(_ child: Child?) -> Children { 28 | guard let child else { return [] } 29 | return [child] 30 | } 31 | 32 | public static func buildArray(_ components: [Children]) -> Children { 33 | components.flatMap { $0 } 34 | } 35 | 36 | public static func buildArray(_ components: Children) -> Children { 37 | components 38 | } 39 | 40 | public static func buildLimitedAvailability(_ component: Children) -> Children { 41 | component 42 | } 43 | 44 | /// Allow for an array of `Child` to be flattened into the overall result. 45 | public static func buildExpression(_ children: [Child]) -> Children { 46 | children 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/ModalContainer.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import UIKit 3 | import WorkflowUI 4 | 5 | 6 | /// A `ModalContainer` which has `AnyScreen` for both its base, and modals. 7 | public typealias AnyModalContainer = ModalContainer 8 | 9 | /// Use a `ModalContainer` to render a base screen and an array of modals on top of it. 10 | /// 11 | /// When modals are added or removed from a container, the screen will animate the changes based on 12 | /// the modals keys. If new modals are added, a presentation animation will occur, and if modals are 13 | /// removed, a dismissal animation will occur. Existing modals will have their screens updated. 14 | /// 15 | /// This container uses the modal system to trampoline modals to a 16 | /// [ModalHost](x-source-tag://ModalHost), which must be installed somewhere in your hierarchy. 17 | /// In a pure workflow application, you should install a 18 | /// [ModalHostContainer](x-source-tag://ModalHostContainer) at the root of your app. 19 | /// In a hybrid application, you should install a 20 | /// [ModalHostContainerViewController](x-source-tag://ModalHostContainerViewController) instead, 21 | /// in which case you do not need to use a `ModalHostContainer`. 22 | /// 23 | /// Note that modals are updated asynchronously, so this container won't present or dismiss modals 24 | /// synchronously during the screen update of a workflow render. 25 | /// 26 | /// - Tag: ModalContainer 27 | /// 28 | public struct ModalContainer { 29 | 30 | /// The screen to render behind the modals. If there are no modals in the `modal` array, this 31 | /// screen will be rendered by the container. 32 | public var base: BaseContent 33 | 34 | /// An array of modals to present over the base. 35 | /// 36 | /// Screens must be wrapped in the `ModalContent` type, which tells the container how to style 37 | /// the presented modals. Ensure that each modal has a unique key as well. 38 | public var modals: [Modal] 39 | 40 | /// Create a modal container with a base screen and array of modals. 41 | public init( 42 | base: BaseContent, 43 | modals: [Modal] = [] 44 | ) { 45 | self.base = base 46 | self.modals = modals 47 | } 48 | 49 | /// Create a modal container with a base screen and the additional provided modals. 50 | /// 51 | /// ``` 52 | /// ModalContainer { 53 | /// MyRootScreen() 54 | /// } modals: { 55 | /// if self.showingModal { 56 | /// self.partialModal(...) 57 | /// } 58 | /// } 59 | /// ``` 60 | public init( 61 | base: () -> BaseContent, 62 | @Builder> modals: () -> [Modal] = { [] } 63 | ) { 64 | self.base = base() 65 | self.modals = modals() 66 | } 67 | } 68 | 69 | 70 | extension Screen { 71 | 72 | /// Create a modal container with the screen as the base screen and the additional provided modals. 73 | /// 74 | /// ``` 75 | /// myScreen.presentingModals { 76 | /// if self.showingModal { 77 | /// self.partialModal(...) 78 | /// } 79 | /// } 80 | /// ``` 81 | public func presentingModals( 82 | @Builder> _ modals: () -> [Modal] 83 | ) -> ModalContainer { 84 | ModalContainer(base: self, modals: modals()) 85 | } 86 | } 87 | 88 | 89 | extension ModalContainer: Screen where BaseContent: Screen, ModalContent: Screen { 90 | public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { 91 | AnyModalToastContainerViewController.description( 92 | for: asAnyModalToastContainer(), 93 | environment: environment, 94 | performInitialUpdate: false 95 | ) 96 | } 97 | 98 | /// Type erases the model content for display in `AnyModalContainerViewController`. 99 | func asAnyModalToastContainer() -> AnyModalToastContainer { 100 | AnyModalToastContainer( 101 | base: base.asAnyScreen(), 102 | modals: modals.map { $0.asAnyScreenModal() }, 103 | toasts: [] 104 | ) 105 | } 106 | } 107 | 108 | extension ModalContainer: SingleScreenContaining where BaseContent: Screen, ModalContent: Screen { 109 | 110 | public var primaryScreen: Screen { 111 | modals.last?.content ?? base 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/ModalListObserver+Workflow.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import Workflow 3 | import WorkflowUI 4 | 5 | 6 | extension ModalListObserver { 7 | 8 | /// Observes a ``Workflow`` that renders a list of modals and toasts, and presents for the duration of the returned 9 | /// lifetime token. 10 | /// 11 | /// This method internally creates a `WorkflowHost` to render the workflow, and then uses the same mechanisms as a 12 | /// `ModalContainer` to realize each modal screen's view controllers, and to bridge the owning view controller's 13 | /// `ViewEnvironment` to the Workflow's`ViewEnvironment`. 14 | /// 15 | /// The rendered modals will be added to the list of modals on the view controller that owns this observer. 16 | /// 17 | /// - Note: When possible, refactoring your view controller into a workflow and rendering your modals with a 18 | /// `ModalContainer` directly is a preferable approach to the bridging provided here. 19 | /// 20 | /// - Parameters: 21 | /// - workflow: The `Workflow` to observe. 22 | /// - onOutput: The action to perform when output is sent by the `workflow`. 23 | /// 24 | /// - Returns: A token that must be kept to continue observing and presenting modals. 25 | /// 26 | public func observe( 27 | _ workflow: WorkflowType, 28 | onOutput: @escaping (WorkflowType.Output) -> Void 29 | ) -> ModalListObservationLifetime where 30 | WorkflowType: AnyWorkflowConvertible, 31 | ModalContent: Screen, 32 | ToastContent: Screen, 33 | WorkflowType.Rendering == ModalsRendering 34 | { 35 | observe(WorkflowModalListProvider( 36 | workflow: workflow, 37 | onOutput: onOutput 38 | )) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/ModalsRendering.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | 3 | 4 | /// A `Workflow` rendering that contains a list of modals and toasts. 5 | /// 6 | /// You do not need to use this rendering type with modals, except in conjunction with the 7 | /// `UIViewController.modalListObserver` bridging facility. 8 | /// 9 | /// See ``ModalListObserver`` for more information. 10 | /// 11 | public struct ModalsRendering { 12 | 13 | /// The list of modals. 14 | /// 15 | public var modals: [Modal] 16 | 17 | /// The list of toasts. 18 | /// 19 | public var toasts: [Toast] 20 | 21 | /// Initializes a new `ModalsRendering`. 22 | /// 23 | /// - Parameters: 24 | /// - modals: The list of modals. 25 | /// - toasts: The list of toasts. 26 | /// 27 | public init( 28 | modals: [Modal], 29 | toasts: [Toast] 30 | ) { 31 | self.modals = modals 32 | self.toasts = toasts 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/ToastContainer.swift: -------------------------------------------------------------------------------- 1 | import WorkflowUI 2 | 3 | 4 | /// A `ToastContainer` which has `AnyScreen` for both its base, and modals. 5 | public typealias AnyToastContainer = ToastContainer 6 | 7 | 8 | /// Use a `ToastContainer` to render a base screen and an array of toasts on top of it. 9 | /// 10 | /// When toasts are added or removed from a container, the screen will animate the changes based on the toasts keys. If 11 | /// new toasts are added, a presentation animation will occur, and if toasts are removed, a dismissal animation will 12 | /// occur. Existing toasts will have their screens updated. 13 | /// 14 | /// This container uses the modal system to trampoline toasts to a [ModalHost](x-source-tag://ModalHost), 15 | /// which must be installed somewhere in your hierarchy. In a pure workflow application, you should install a 16 | /// [ModalHostContainer](x-source-tag://ModalHostContainer) at the root of your app. In a hybrid application, you should 17 | /// install a [ModalHostContainerViewController](x-source-tag://ModalHostContainerViewController) instead, in which case 18 | /// you do not need to use a `ModalHostContainer`. 19 | /// 20 | /// Note that toasts are updated asynchronously, so this container won't present or dismiss toasts synchronously during 21 | /// the screen update of a workflow render. 22 | /// 23 | /// - Tag: ToastContainer 24 | /// 25 | public struct ToastContainer { 26 | 27 | /// The screen to render behind the toasts. If there are no toasts in the `modal` array, this screen will be 28 | /// rendered by the container. 29 | /// 30 | public var base: BaseContent 31 | 32 | public var toasts: [Toast] 33 | 34 | /// Create a toast container with a base screen and the additional provided toasts. 35 | /// 36 | /// ``` 37 | /// ToastContainer( 38 | /// base: MyRootScreen(), 39 | /// toasts: [ 40 | /// state.shouldShowToast 41 | /// ? self.toast(...) 42 | /// : nil 43 | /// ].compactMap { $0 } 44 | /// ) 45 | /// ``` 46 | /// 47 | public init( 48 | base: BaseContent, 49 | toasts: [Toast] 50 | ) { 51 | self.base = base 52 | self.toasts = toasts 53 | } 54 | 55 | /// Create a toast container with a base screen and array of toasts. 56 | /// 57 | public init( 58 | base: () -> BaseContent, 59 | @Builder> toasts: () -> [Toast] = { [] } 60 | ) { 61 | self.base = base() 62 | self.toasts = toasts() 63 | } 64 | } 65 | 66 | 67 | extension Screen { 68 | 69 | /// Create a toast container with the screen as the base screen and the additional provided toasts. 70 | /// 71 | /// ``` 72 | /// myScreen.presentingToasts { 73 | /// if self.showingToast { 74 | /// self.toast(...) 75 | /// } 76 | /// } 77 | /// ``` 78 | /// 79 | public func presentingToasts( 80 | @Builder> _ toasts: () -> [Toast] 81 | ) -> ToastContainer { 82 | ToastContainer(base: self, toasts: toasts()) 83 | } 84 | } 85 | 86 | 87 | extension ToastContainer: Screen where BaseContent: Screen, ToastContent: Screen { 88 | 89 | public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { 90 | AnyModalToastContainerViewController.description( 91 | for: asAnyModalToastContainer(), 92 | environment: environment, 93 | performInitialUpdate: false 94 | ) 95 | } 96 | 97 | /// Type erases the toast content for display in `AnyModalContainerViewController`. 98 | /// 99 | func asAnyModalToastContainer() -> AnyModalToastContainer { 100 | AnyModalToastContainer( 101 | base: base.asAnyScreen(), 102 | modals: [], 103 | toasts: toasts.map { $0.asAnyScreenToast() } 104 | ) 105 | } 106 | } 107 | 108 | extension ToastContainer: SingleScreenContaining where BaseContent: Screen { 109 | 110 | public var primaryScreen: Screen { 111 | // toasts do not represent a new "screen" semantically so we always return the base 112 | base 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/UIViewController+LoggingAttribution.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | 5 | /// Returns a contained view controller if this is a container type. Should be overriden by container VCs. 6 | /// This pattern is used in register to traverse container view controllers 7 | @objc open var wrappedContentViewController: UIViewController? { 8 | nil 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /WorkflowModals/Sources/WorkflowModalListProvider.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Modals 3 | import ReactiveSwift 4 | import ViewEnvironment 5 | import Workflow 6 | import WorkflowUI 7 | 8 | 9 | /// A `ModalListProvider` that observes a `Workflow` which renders ``Modal``s and/or ``Toast`` without a base screen, 10 | /// and converts them to a `ModalList` for aggregation in vanilla UIKit contexts. 11 | /// 12 | final class WorkflowModalListProvider: ModalListProvider where 13 | WorkflowType: AnyWorkflowConvertible, 14 | ModalContent: Screen, 15 | ToastContent: Screen, 16 | WorkflowType.Rendering == ModalsRendering 17 | { 18 | 19 | private typealias Manager = PresentedModalsManager 20 | 21 | private let workflowHost: WorkflowHost> 22 | 23 | private let (lifetime, token) = Lifetime.make() 24 | 25 | private let manager = Manager() 26 | 27 | private let _modalListDidChange = PassthroughSubject() 28 | 29 | private var contents: Manager.Contents { 30 | didSet { update() } 31 | } 32 | 33 | private var environment: ViewEnvironment? { 34 | didSet { update() } 35 | } 36 | 37 | init( 38 | workflow: WorkflowType, 39 | onOutput: @escaping (WorkflowType.Output) -> Void 40 | ) { 41 | workflowHost = .init(workflow: RootWorkflow(workflow)) 42 | 43 | workflowHost.output 44 | .signal 45 | .take(during: lifetime) 46 | .observeValues(onOutput) 47 | 48 | contents = .init( 49 | modals: workflowHost.rendering.value.modals, 50 | toasts: workflowHost.rendering.value.toasts 51 | ) 52 | workflowHost 53 | .rendering 54 | .signal 55 | .take(during: lifetime) 56 | .observeValues { [weak self] value in 57 | guard let self else { return } 58 | 59 | contents = .init( 60 | modals: value.modals, 61 | toasts: value.toasts 62 | ) 63 | } 64 | } 65 | 66 | func update(environment: ViewEnvironment) { 67 | self.environment = environment 68 | } 69 | 70 | func aggregateModalList() -> ModalList { 71 | let presentedModalsAndAggregates = manager.presentedModals.map { modal in 72 | (modal.modal, modal.viewController.aggregateModals()) 73 | } 74 | 75 | return ModalList( 76 | modals: presentedModalsAndAggregates.flatMap { [$0] + $1.modals }, 77 | toasts: manager.presentedToasts.map { $0.toast } 78 | + presentedModalsAndAggregates.flatMap { $1.toasts } 79 | ) 80 | } 81 | 82 | var modalListDidChange: AnyPublisher { 83 | AnyPublisher(_modalListDidChange) 84 | } 85 | 86 | private func update() { 87 | guard let environment else { return } 88 | 89 | manager.update( 90 | contents: .init( 91 | modals: contents.modals, 92 | toasts: contents.toasts 93 | ), 94 | environment: environment 95 | ) 96 | 97 | _modalListDidChange.send(()) 98 | } 99 | } 100 | 101 | 102 | extension WorkflowModalListProvider { 103 | 104 | /// Wrapper around an AnyWorkflow that allows us to have a concrete `WorkflowHost` (which is generic over a 105 | /// `Workflow`) while still supporting `AnyWorkflow`/`AnyWorkflowConvertible` (which does not conform to 106 | /// `Workflow`). 107 | fileprivate struct RootWorkflow: Workflow { 108 | typealias State = Void 109 | typealias Output = Output 110 | typealias Rendering = Rendering 111 | 112 | var wrapped: AnyWorkflow 113 | 114 | init(_ wrapped: W) where W.Rendering == Rendering, W.Output == Output { 115 | self.wrapped = wrapped.asAnyWorkflow() 116 | } 117 | 118 | func render(state: State, context: RenderContext) -> Rendering { 119 | wrapped 120 | .mapOutput { AnyWorkflowAction(sendingOutput: $0) } 121 | .rendered(in: context) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /WorkflowModals/Tests/EmptyScreen.swift: -------------------------------------------------------------------------------- 1 | import WorkflowUI 2 | 3 | struct EmptyScreen: Screen { 4 | func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { 5 | EmptyViewController.description(for: self, environment: environment) 6 | } 7 | } 8 | 9 | final class EmptyViewController: ScreenViewController {} 10 | -------------------------------------------------------------------------------- /WorkflowModals/Tests/ToastContainerTests.swift: -------------------------------------------------------------------------------- 1 | import Modals 2 | import TestingSupport 3 | import WorkflowUI 4 | import XCTest 5 | 6 | @_spi(WorkflowModalsImplementation) import WorkflowModals 7 | 8 | 9 | class ToastContainerTests: XCTestCase { 10 | 11 | func test_toast_updates() throws { 12 | 13 | let toastScreen = ToastContainer( 14 | base: EmptyScreen(), 15 | toasts: [ 16 | Toast( 17 | key: "first-toast", 18 | style: ToastPresentationStyleFixture(), 19 | content: EmptyScreen(), 20 | accessibilityAnnouncement: "Presented toast." 21 | ), 22 | ] 23 | ) 24 | 25 | let description = toastScreen.viewControllerDescription(environment: .empty) 26 | let viewController = try XCTUnwrap(description.buildViewController() as? AnyModalToastContainerViewController) 27 | 28 | XCTAssertFalse(viewController.isViewLoaded) 29 | 30 | viewController.view.layoutIfNeeded() 31 | 32 | do { 33 | // The initial toast should be aggregated 34 | XCTAssertEqual(viewController.aggregateModals().toasts.count, 1) 35 | } 36 | 37 | do { 38 | // Adding a new toast should add a new toast to the list 39 | let newScreen = ToastContainer( 40 | base: EmptyScreen(), 41 | toasts: [ 42 | Toast( 43 | key: "first-toast", 44 | style: ToastPresentationStyleFixture(), 45 | content: EmptyScreen(), 46 | accessibilityAnnouncement: "Presented toast." 47 | ), 48 | Toast( 49 | key: "second-toast", 50 | style: ToastPresentationStyleFixture(), 51 | content: EmptyScreen(), 52 | accessibilityAnnouncement: "Presented toast." 53 | ), 54 | ] 55 | ) 56 | 57 | newScreen.viewControllerDescription(environment: .empty) 58 | .update(viewController: viewController) 59 | 60 | XCTAssertEqual(viewController.aggregateModals().toasts.count, 2) 61 | } 62 | 63 | do { 64 | // Updating a toast with the same key should reuse the existing view controller; 65 | // changing the key should result in a new view controller 66 | let newScreen = ToastContainer( 67 | base: EmptyScreen(), 68 | toasts: [ 69 | Toast( 70 | key: "first-toast", 71 | style: ToastPresentationStyleFixture(), 72 | content: EmptyScreen(), 73 | accessibilityAnnouncement: "Presented toast." 74 | ), 75 | Toast( 76 | key: "new-second-toast", 77 | style: ToastPresentationStyleFixture(), 78 | content: EmptyScreen(), 79 | accessibilityAnnouncement: "Presented toast." 80 | ), 81 | ] 82 | ) 83 | 84 | let existingFirstViewController = viewController.aggregateModals().toasts[0].viewController 85 | let existingSecondViewController = viewController.aggregateModals().toasts[1].viewController 86 | 87 | newScreen.viewControllerDescription(environment: .empty) 88 | .update(viewController: viewController) 89 | 90 | XCTAssertEqual( 91 | viewController.aggregateModals().toasts[0].viewController, 92 | existingFirstViewController 93 | ) 94 | 95 | XCTAssertNotEqual( 96 | viewController.aggregateModals().toasts[1].viewController, 97 | existingSecondViewController 98 | ) 99 | } 100 | 101 | do { 102 | // Removing toasts should remove them from the array 103 | let newScreen = ToastContainer( 104 | base: EmptyScreen(), 105 | toasts: [] 106 | ) 107 | 108 | newScreen.viewControllerDescription(environment: .empty) 109 | .update(viewController: viewController) 110 | 111 | XCTAssertTrue(viewController.aggregateModals().toasts.isEmpty) 112 | } 113 | } 114 | } 115 | 116 | extension ScreenViewController { 117 | private var viewEnvironment: ViewEnvironment { environment } 118 | } 119 | --------------------------------------------------------------------------------