├── .gitignore ├── .swift-version ├── .swiftformat ├── Brewfile ├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── ModalPresentationView │ ├── EnvironmentKeys.swift │ ├── ModalDismissButton.swift │ ├── ModalPresentationButton.swift │ └── ModalPresentationView.swift └── demo.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata/ 3 | /.build 4 | /Packages 5 | /*.xcodeproj 6 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.1 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --disable redundantNilInit 2 | --indent 2 3 | --self init-only 4 | --stripunusedargs closure-only 5 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | # vim: ft=ruby 2 | brew "swiftformat" 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. 4 | By participating in this project, 5 | you agree to abide by the thoughtbot [code of conduct][]. 6 | 7 | We expect everyone to follow the code of conduct 8 | anywhere in thoughtbot's project codebases, 9 | issue trackers, chatrooms, and mailing lists. 10 | 11 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 12 | 13 | ## Contributing Code 14 | 15 | Fork the repo. 16 | 17 | Make your change. 18 | 19 | Push to your fork. Write a [good commit message][commit]. Submit a pull request. 20 | 21 | Others will give constructive feedback. 22 | This is a time for discussion and improvements, 23 | and making the necessary changes will be required before we can 24 | merge the contribution. 25 | 26 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Adam Sharp and thoughtbot, inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ModalPresentationView", 7 | platforms: [ 8 | .iOS(.v13), 9 | ], 10 | products: [ 11 | .library(name: "ModalPresentationView", targets: ["ModalPresentationView"]), 12 | ], 13 | targets: [ 14 | .target(name: "ModalPresentationView"), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModalPresentationView 2 | 3 | Takes the boilerplate out of managing modal presentations in SwiftUI. 4 | 5 | ## Background 6 | 7 | SwiftUI currently provides two different ways to manage modal presentations: 8 | 9 | * The simplest, [`PresentationButton`][presentation-button], accepts a 10 | `Destination` parameter and presents it over the current context. It doesn't 11 | provide a control for manual dismissal, instead appearing to rely on the 12 | interactive dismissal gesture that's part of the new default `.pageSheet` 13 | modal presentation style. 14 | 15 | * Alternatively, the more powerful [`presentation(_:)` modifier][presentation-modifier] 16 | allows you to take control of the presentation state yourself, by accepting an 17 | optional parameter of type `Modal`. 18 | 19 | `ModalPresentationView` sits in the middle, providing a 20 | `ModalPresentationButton` control with the same API as `PresentationButton`, 21 | but additionally: 22 | 23 | * Unlike the `presentation(_:)` modifier, automatically manages the state of 24 | the modal via a new `ModalNavigationView`. If you're familiar with 25 | [`NavigationView`][navigation-view], then you already know how to use it. 26 | 27 | * Provides for manual dismissal via `ModalDismissButton`, in addition to the 28 | existing interactive dismiss gesture. 29 | 30 | [navigation-view]: https://developer.apple.com/documentation/swiftui/navigationview "Navigation view - Apple Developer Documentation" 31 | [presentation-button]: https://developer.apple.com/documentation/swiftui/presentationbutton "Presentation button - Apple Developer Documentation" 32 | [presentation-modifier]: https://developer.apple.com/documentation/swiftui/view/3278634-presentation "Presentation modifier - Apple Developer Documentation" 33 | 34 | ## Demo 35 | 36 | ![GIF showing a demo of modal presentation and dismissal](demo.gif) 37 | 38 | ## Example code 39 | 40 | ```swift 41 | import ModalPresentationView 42 | import SwiftUI 43 | 44 | struct App: View { 45 | var body: some View { 46 | ModalPresentationView { 47 | ModalPresentationButton(destination: DetailScreen()) { 48 | Text("Present") 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct DetailScreen: View { 55 | var body: some View { 56 | ModalDismissButton { 57 | Text("Dismiss") 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | ## Installation 64 | 65 | 1. In Xcode, open your project and navigate to File > Swift Packages > Add Package Dependency... 66 | 2. Paste the repository URL and follow the prompts to add the library to your project. 67 | 68 | ## How does it work? 69 | 70 | Internally, `ModalPresentationView` defines two custom environment keys, which 71 | it then uses to communicate between the container and the buttons, regardless 72 | of where they're placed in your view hierarchy. 73 | 74 | * Firstly, `ModalPresentationView` injects a `present(_:)` closure into the 75 | environment using the `.modalPresentAction` key. 76 | 77 | * When tapped, `ModalPresentationButton` reads the closure from the environment 78 | and calls it to notify `ModalPresentationView` to trigger a presentation. The 79 | presentation button passes the destination view into the closure by first 80 | wrapping it in an `AnyView`. 81 | 82 | * `ModalPresentationView` updates its internal state to pass the destination 83 | view into the `presentation(_:)` view modifier. It also sets a `dismiss()` 84 | closure on the environment of the presented view using the 85 | `.modalDismissAction` environment key. 86 | 87 | * Finally, `ModalDismissButton` reads the `dismiss()` closure from the 88 | environment and calls it when tapped, causing `ModalPresentationView` to 89 | update its internal state again and remove the presented modal. 90 | 91 | ## Contributing 92 | 93 | See the [CONTRIBUTING][] document for details about how you can contribute. 94 | 95 | [CONTRIBUTING]: CONTRIBUTING.md 96 | 97 | ## License 98 | 99 | ModalPresentationView is Copyright (c) 2019 thoughtbot, inc. 100 | It contains free software that may be redistributed 101 | under the terms specified in the [LICENSE][] file. 102 | 103 | [LICENSE]: LICENSE 104 | 105 | ## About 106 | 107 | ![thoughtbot](http://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg) 108 | 109 | ModalPresentationView is maintained by Adam Sharp and funded by thoughtbot, inc. 110 | The names and logos for thoughtbot are trademarks of thoughtbot, inc. 111 | 112 | We love open source software! 113 | See [our other projects][community] 114 | or [hire us][hire] to help build your product. 115 | 116 | [community]: https://thoughtbot.com/community?utm_source=github 117 | [hire]: https://thoughtbot.com/hire-us?utm_source=github 118 | -------------------------------------------------------------------------------- /Sources/ModalPresentationView/EnvironmentKeys.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum ModalDismissActionKey: EnvironmentKey { 4 | static var defaultValue: () -> Void { 5 | { 6 | assertionFailure("ModalDismissButton action triggered outside a modal presentation") 7 | } 8 | } 9 | } 10 | 11 | enum ModalPresentationActionKey: EnvironmentKey { 12 | static var defaultValue: (AnyView) -> Void { 13 | { _ in 14 | assertionFailure("ModalPresentationButton action triggered outside a ModalPresentationView") 15 | } 16 | } 17 | } 18 | 19 | extension EnvironmentValues { 20 | var modalDismissAction: () -> Void { 21 | get { self[ModalDismissActionKey.self] } 22 | set { self[ModalDismissActionKey.self] = newValue } 23 | } 24 | 25 | var modalPresentationAction: (AnyView) -> Void { 26 | get { self[ModalPresentationActionKey.self] } 27 | set { self[ModalPresentationActionKey.self] = newValue } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ModalPresentationView/ModalDismissButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | typealias _ScaledImage = Image.Modified<_EnvironmentKeyWritingModifier> 4 | 5 | /// Use `ModalDismissButton` from within a modal presentation to provide a user 6 | /// interface for dismissing modal content. If using the default presentation 7 | /// style with interactive dismissal, `ModalDismissButton` isn't required in 8 | /// order to dismiss the modal content, but is still recommended. 9 | /// 10 | /// The default appearance of `ModalDismissButton` uses the `xmark` system image 11 | /// and is configured with an accessility label of "Dismiss". 12 | /// 13 | /// - Note: Interacting with a `ModalDismissButton` from outside a modal 14 | /// presentation triggers an assertion failure. 15 | public struct ModalDismissButton: View { 16 | @Environment(\.modalDismissAction) private var dismiss 17 | 18 | private let label: Label 19 | 20 | /// Create a `ModalDismissButton` with a custom label. 21 | public init(@ViewBuilder _ label: () -> Label) { 22 | self.label = label() 23 | } 24 | 25 | public var body: some View { 26 | Button(action: dismiss) { 27 | label 28 | } 29 | } 30 | } 31 | 32 | extension ModalDismissButton where Label == _ScaledImage.Modified { 33 | /// Create a `ModalDismissButton` with the default appearance and 34 | /// accessibility label. 35 | public init() { 36 | self.init { 37 | Image(systemName: "xmark") 38 | .imageScale(.large) 39 | .accessibility(label: Text("Dismiss")) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ModalPresentationView/ModalPresentationButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Use a `ModalPresentationButton` to display a control for presenting content 4 | /// full-screen. Like `NavigationButton`, the modal presentation button also 5 | /// defines the destination content to be presented. 6 | public struct ModalPresentationButton: View { 7 | @Environment(\.modalPresentationAction) private var _present 8 | 9 | private let destination: Destination 10 | private let label: Label 11 | 12 | /// Create a `ModalPresentationButton` by providing its `destination` and 13 | /// `label`. 14 | /// 15 | /// - parameters: 16 | /// - destination: The destination content to be presented full screen. 17 | /// - label: The content for the button's label. 18 | public init(destination: Destination, @ViewBuilder _ label: () -> Label) { 19 | self.destination = destination 20 | self.label = label() 21 | } 22 | 23 | public var body: some View { 24 | Button(action: present) { 25 | label 26 | } 27 | } 28 | 29 | private func present() { 30 | _present(AnyView(destination)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ModalPresentationView/ModalPresentationView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view for presenting full screen content. After wrapping your content in 4 | /// a `ModalPresentationView`, add a `ModalPresentationButton` to present your 5 | /// modal content. Use `ModalDismissButton` from inside the modal presentation 6 | /// in order to dismiss the full screen content. 7 | public struct ModalPresentationView: View { 8 | @State private var presentedView: AnyView? = nil 9 | 10 | private let content: Content 11 | 12 | /// Create a `ModalPresentationView` that defines the context for presenting 13 | /// content full-screen. 14 | public init(@ViewBuilder _ content: () -> Content) { 15 | self.content = content() 16 | } 17 | 18 | public var body: some View { 19 | content 20 | .environment(\.modalPresentationAction, present) 21 | .presentation(modal) 22 | } 23 | 24 | private var modal: Modal? { 25 | guard let presentedView = presentedView else { return nil } 26 | return Modal( 27 | presentedView.environment(\.modalDismissAction, dismiss), 28 | onDismiss: dismiss 29 | ) 30 | } 31 | 32 | private func present(_ view: AnyView) { 33 | presentedView = view 34 | } 35 | 36 | private func dismiss() { 37 | presentedView = nil 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/ModalPresentationView/c1e1ca1fe990a667ca71c5e3cebeafaef038fcf6/demo.gif --------------------------------------------------------------------------------