├── .github └── workflows │ └── documentation.yml ├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── UIViewControllerPresenting.xcscheme ├── Examples ├── MailComposeSheet.swift └── ShareSheet.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── SafariWebView │ └── SafariWebView.swift └── UIViewControllerPresenting │ ├── Documentation.docc │ ├── GettingStarted.md │ └── UIViewControllerPresenting.md │ ├── UIViewControllerPresenting.swift │ └── ViewPresenting.swift └── Tests └── UIViewControllerPresentingTests └── UIViewControllerPresentingTests.swift /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | # Build and deploy DocC to GitHub pages. Based off of @karwa's work here: 2 | # https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml 3 | name: Documentation 4 | 5 | on: 6 | release: 7 | types: 8 | - published 9 | push: 10 | branches: 11 | - main 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | runs-on: macos-12 17 | steps: 18 | - name: Checkout Package 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Checkout gh-pages Branch 24 | uses: actions/checkout@v2 25 | with: 26 | ref: gh-pages 27 | path: docs-out 28 | 29 | - name: Build documentation 30 | run: > 31 | rm -rf docs-out/.git; 32 | rm -rf docs-out/main; 33 | 34 | for tag in $(echo "main"; git tag); 35 | do 36 | echo "⏳ Generating documentation for "$tag" release."; 37 | 38 | if [ -d "docs-out/$tag" ] 39 | then 40 | echo "✅ Documentation for "$tag" already exists."; 41 | else 42 | git checkout "$tag"; 43 | export DOCS_OUTPUT_PATH=docs-out/"$tag"; 44 | export HOSTING_BASE_PATH=/swiftui-uikit-presenting/"$tag" 45 | mkdir -p $DOCS_OUTPUT_PATH; 46 | 47 | xcodebuild docbuild \ 48 | -scheme UIViewControllerPresenting \ 49 | -destination generic/platform=iOS \ 50 | OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path $HOSTING_BASE_PATH --output-path $DOCS_OUTPUT_PATH" 51 | fi; 52 | done 53 | 54 | - name: Fix permissions 55 | run: 'sudo chown -R $USER docs-out' 56 | - name: Publish documentation to GitHub Pages 57 | uses: JamesIves/github-pages-deploy-action@4.1.7 58 | with: 59 | branch: gh-pages 60 | folder: docs-out 61 | single-commit: true 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | docs 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/UIViewControllerPresenting.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Examples/MailComposeSheet.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | public extension View { 3 | /// Presents a native mail compose interface using MFMailComposeViewController. 4 | /// 5 | /// If mail composing is not available on this device, it will try to automatically 6 | /// fall back to opening a mailto: URL using the first recipient and subject. 7 | /// 8 | /// This example demonstrates how you can use a `ViewModifier` to enhance the 9 | /// functionality of the wrapped view controller with extra state or the SwiftUI environment. 10 | /// 11 | /// It also demonstrates how to work with delegate-based callbacks when a controller is dismissed. 12 | /// 13 | /// - Parameters: 14 | /// - recipients: A list of email addresses that the email should be addressed to. 15 | /// - subject: A default subject for the email. 16 | /// - body: A default body for the email. 17 | /// - isHTML: Indicates if the email body contains HTML or is plain text. 18 | /// - isPresented: A binding to drive the appearance of the sheet. 19 | /// 20 | func mailComposeSheet( 21 | recipients: [String] = [], 22 | subject: String = "", 23 | body: String = "", 24 | isHTML: Bool = false, 25 | isPresented: Binding 26 | ) -> some View { 27 | modifier( 28 | MailComposeViewModifier( 29 | template: .init( 30 | recipients: recipients, 31 | subject: subject, 32 | body: body, 33 | isHTML: isHTML 34 | ), 35 | isPresented: isPresented 36 | ) 37 | ) 38 | } 39 | 40 | /// Presents a native mail compose interface using MFMailComposeViewController. 41 | /// 42 | /// If mail composing is not available on this device, it will try to automatically 43 | /// fall back to opening a mailto URL using the first recipient and subject. 44 | /// 45 | /// - Parameters: 46 | /// - template: The template to use for the mail compose view. 47 | /// - isPresented: A binding to drive the appearance of the sheet. 48 | /// 49 | func mailComposeSheet( 50 | template: MailComposeTemplate, 51 | isPresented: Binding 52 | ) -> some View { 53 | modifier( 54 | MailComposeViewModifier( 55 | template: template, 56 | isPresented: isPresented 57 | ) 58 | ) 59 | } 60 | } 61 | 62 | private struct MailComposeViewModifier: ViewModifier { 63 | let template: MailComposeTemplate 64 | let isPresented: Binding 65 | 66 | @Environment(\.openURL) 67 | private var openURL: OpenURLAction 68 | 69 | func body(content: Content) -> some View { 70 | content.background( 71 | UIViewControllerPresenting( 72 | isPresented: .init( 73 | get: { 74 | // We only want to present a mail compose view controller 75 | // if mail is available - if not we should override the 76 | // binding to always return false. 77 | guard MFMailComposeViewController.canSendMail() else { 78 | return false 79 | } 80 | return isPresented.wrappedValue 81 | }, 82 | set: { isPresented.wrappedValue = $0 } 83 | ), 84 | makeUIViewController: { context, _ in 85 | let mailComposer = MFMailComposeViewController() 86 | mailComposer.setSubject(template.subject) 87 | mailComposer.setToRecipients(template.recipients) 88 | mailComposer.setMessageBody(template.body, isHTML: template.isHTML) 89 | mailComposer.mailComposeDelegate = context.coordinator 90 | return mailComposer 91 | }, 92 | makeCoordinator: { _ in 93 | MailComposeViewCoordinator { isPresented.wrappedValue = false } 94 | } 95 | ) 96 | .onChange(of: isPresented.wrappedValue) { shouldPresent in 97 | if shouldPresent && !MFMailComposeViewController.canSendMail() { 98 | // We need to set this immediately back to false again because 99 | // we aren't going to present anything and need to reset the state. 100 | isPresented.wrappedValue = false 101 | // If we can construct a mailto: link from the template we can 102 | // fall back to opening that instead. 103 | if let mailto = template.mailToLink { 104 | openURL(mailto) 105 | } 106 | } 107 | } 108 | ) 109 | } 110 | } 111 | 112 | /// Defines a preset email template for use with `.mailComposeSheet`. 113 | public struct MailComposeTemplate { 114 | let recipients: [String] 115 | let subject: String 116 | let body: String 117 | let isHTML: Bool 118 | 119 | public init( 120 | recipients: [String] = [], 121 | subject: String = "", 122 | body: String = "", 123 | isHTML: Bool = false 124 | ) { 125 | self.recipients = recipients 126 | self.subject = subject 127 | self.body = body 128 | self.isHTML = isHTML 129 | } 130 | 131 | fileprivate var mailToLink: URL? { 132 | guard 133 | let recipient = recipients.first, 134 | let url = URL.mailTo(recipient: recipient, subject: subject) 135 | else { return nil } 136 | return url 137 | } 138 | } 139 | 140 | private class MailComposeViewCoordinator: NSObject, MFMailComposeViewControllerDelegate { 141 | var dismiss: () -> Void 142 | 143 | init(dismiss: @escaping () -> Void) { 144 | self.dismiss = dismiss 145 | } 146 | 147 | func mailComposeController( 148 | _ controller: MFMailComposeViewController, 149 | didFinishWith result: MFMailComposeResult, 150 | error: Error? 151 | ) { 152 | // TODO: Maybe add some error handling? 153 | dismiss() 154 | } 155 | } 156 | #endif 157 | -------------------------------------------------------------------------------- /Examples/ShareSheet.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import LinkPresentation 3 | import SwiftUI 4 | import UniformTypeIdentifiers 5 | import UIKit 6 | 7 | public extension View { 8 | /// An example of how to present a share sheet (UIActivityViewController) from SwiftUI. 9 | /// 10 | /// It is possible to simply wrap UIActivityViewController in a `UIViewControllerRepresentable` view and present that 11 | /// using the native SwiftUI share APIs however this causes the share sheet to appear as a full screen modal, rather than the three 12 | /// quarter height presentation that it usually appears with when presented using UIKit modal presentation. 13 | /// 14 | /// This is not an exhaustive example and you would normally build something like this that is very specific to your app and what 15 | /// kind of items you want to share. 16 | /// 17 | /// - Parameters: 18 | /// - activityItems: The items you want to share. 19 | /// - isPresented: A binding that indicates if this sheet should be visible - will be set back to `false` when the sheet is dismissed. 20 | /// - onCompletion: Called when the user shares the items or dismisses the share sheet without sharing. 21 | /// 22 | func shareSheet( 23 | activityItems: [Any], 24 | isPresented: Binding, 25 | onCompletion: UIActivityViewController.CompletionWithItemsHandler? = nil 26 | ) -> some View { 27 | background( 28 | UIViewControllerPresenting.shareSheet( 29 | activityItems: activityItems, 30 | isPresented: isPresented, 31 | completionHandler: onCompletion 32 | ) 33 | ) 34 | } 35 | } 36 | 37 | private extension UIViewControllerPresenting where Controller == UIActivityViewController { 38 | static func shareSheet( 39 | activityItems: [Any], 40 | isPresented: Binding, 41 | completionHandler: UIActivityViewController.CompletionWithItemsHandler? = nil 42 | ) -> Self { 43 | .init( 44 | isPresented: isPresented, 45 | makeUIViewController: { context, dismissHandler in 46 | let activityController = UIActivityViewController( 47 | activityItems: activityItems, 48 | applicationActivities: nil 49 | ) 50 | activityController.completionWithItemsHandler = { 51 | completionHandler?($0, $1, $2, $3) 52 | dismissHandler() 53 | } 54 | return activityController 55 | } 56 | ) 57 | } 58 | } 59 | 60 | struct PhoneNumberShareSheet_Previews: PreviewProvider { 61 | struct PreviewView: View { 62 | @State var isShowingShareSheet = false 63 | 64 | var body: some View { 65 | Button("Open Share Sheet") { 66 | isShowingShareSheet = true 67 | } 68 | .phoneNumberShareSheet( 69 | activityItems: ["Hello World"], 70 | isPresented: $isShowingShareSheet 71 | ) 72 | } 73 | } 74 | 75 | static var previews: some View { 76 | PreviewView() 77 | } 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2021 Community.com, Inc. 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "swiftui-uikit-presenting", 6 | platforms: [ 7 | .iOS(.v14) 8 | ], 9 | products: [ 10 | .library( 11 | name: "UIViewControllerPresenting", 12 | targets: ["UIViewControllerPresenting"] 13 | ), 14 | .library( 15 | name: "SafariWebView", 16 | targets: ["SafariWebView"] 17 | ) 18 | ], 19 | dependencies: [], 20 | targets: [ 21 | .target( 22 | name: "UIViewControllerPresenting", 23 | dependencies: [] 24 | ), 25 | .testTarget( 26 | name: "UIViewControllerPresentingTests", 27 | dependencies: ["UIViewControllerPresenting"] 28 | ), 29 | 30 | // MARK: - Example Libraries 31 | 32 | .target( 33 | name: "SafariWebView", 34 | dependencies: ["UIViewControllerPresenting"] 35 | ) 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UIViewControllerPresenting 2 | 3 | This package provides a custom SwiftUI view modifier and component that can be used to present UIKit views from a SwiftUI view using UIKit presentation APIs. It can also be used to present other SwiftUI views by wrapping them in a `UIHostingController` giving you access to presentation APIs that are not available in SwiftUI prior to iOS 16, such as sheet detents. 4 | 5 | ## SafariWebView 6 | 7 | This package also contains a standalone library built on top of `UIViewControllerPresenting` which allows you to present a Safari web view controller from a SwiftUI view using a binding-based interface. 8 | 9 | ## Examples 10 | 11 | For further examples of how this library can be used, please see the Examples directory. 12 | 13 | ## Documentation 14 | 15 | * [API Documentation (main branch)](https://shimmur.github.io/swiftui-uikit-presenting/main/documentation/uiviewcontrollerpresenting/) 16 | 17 | ## Copyright and License 18 | 19 | This library was developed out of the work on our app here at [Community.com](http://community.com) and is made available under the [Apache 2.0 license](LICENSE). 20 | 21 | ``` 22 | Copyright 2022 Community.com, Inc. 23 | 24 | Licensed under the Apache License, Version 2.0 (the "License"); 25 | you may not use this file except in compliance with the License. 26 | You may obtain a copy of the License at 27 | 28 | http://www.apache.org/licenses/LICENSE-2.0 29 | 30 | Unless required by applicable law or agreed to in writing, software 31 | distributed under the License is distributed on an "AS IS" BASIS, 32 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 33 | See the License for the specific language governing permissions and 34 | limitations under the License. 35 | ``` 36 | -------------------------------------------------------------------------------- /Sources/SafariWebView/SafariWebView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import SafariServices 3 | import SwiftUI 4 | import UIViewControllerPresenting 5 | 6 | public extension View { 7 | /// Opens the specified URL, using `SFSafariViewController`. 8 | /// 9 | /// You can use this modifier in a SwiftUI view to attach Safari browsing functionality to 10 | /// a view and control the presentation with a binding. 11 | /// 12 | /// - Parameters: 13 | /// - url: The URL to open in the Safari view. 14 | /// - configuration: An optional configuration for the Safari view controller. 15 | /// - isPresented: A binding to the state used to drive presentation of this view. 16 | /// - animated: Controls whether or not the presentation is animated. 17 | /// 18 | func safariWebView( 19 | url: URL, 20 | configuration: SFSafariViewController.Configuration? = nil, 21 | isPresented: Binding, 22 | modalPresentationStyle: UIModalPresentationStyle = .fullScreen, 23 | animated: Bool = true 24 | ) -> some View { 25 | background( 26 | UIViewControllerPresenting.safariViewController( 27 | url: url, 28 | configuration: configuration, 29 | isPresented: isPresented, 30 | animated: animated 31 | ) 32 | ) 33 | } 34 | 35 | /// Opens a URL using `SFSafariViewController`. 36 | /// 37 | /// You can use this modifier in a SwiftUI view to attach Safari browsing functionality to 38 | /// a view and control the presentation with a binding. This method takes a binding to the 39 | /// URL that you want to present and the URL will be opened when the binding becomes 40 | /// non-nil. 41 | /// 42 | /// - Parameters: 43 | /// - url: A binding to the URL to be opened. 44 | /// - configuration: An optional configuration for the Safari view controller. 45 | /// - animated: Controls whether or not the presentation is animated. 46 | /// 47 | func safariWebView( 48 | url: Binding, 49 | configuration: SFSafariViewController.Configuration? = nil, 50 | modalPresentationStyle: UIModalPresentationStyle = .fullScreen, 51 | animated: Bool = true 52 | ) -> some View { 53 | background( 54 | UIViewControllerPresenting.safariViewController( 55 | url: url, 56 | configuration: configuration, 57 | animated: animated 58 | ) 59 | ) 60 | } 61 | } 62 | 63 | extension UIViewControllerPresenting where Controller == SFSafariViewController, Coordinator == SafariWebViewCoordinator { 64 | static func safariViewController( 65 | url: URL, 66 | configuration: SFSafariViewController.Configuration?, 67 | isPresented: Binding, 68 | modalPresentationStyle: UIModalPresentationStyle = .fullScreen, 69 | animated: Bool = true 70 | ) -> Self { 71 | .init( 72 | isPresented: isPresented, 73 | makeUIViewController: { context, _ in 74 | makeSafariViewController( 75 | url: url, 76 | configuration: configuration, 77 | delegate: context.coordinator, 78 | modalPresentationStyle: modalPresentationStyle 79 | ) 80 | }, 81 | makeCoordinator: { dismissHandler in 82 | Coordinator(dismissHandler: dismissHandler) 83 | }, 84 | animated: animated 85 | ) 86 | } 87 | 88 | static func safariViewController( 89 | url: Binding, 90 | configuration: SFSafariViewController.Configuration?, 91 | modalPresentationStyle: UIModalPresentationStyle = .fullScreen, 92 | animated: Bool = true 93 | ) -> Self { 94 | .init( 95 | isPresented: url.isPresent(), 96 | makeUIViewController: { context, _ in 97 | // Because we only present when the binding has a 98 | // value it should be safe to force unwrap here. 99 | makeSafariViewController( 100 | url: url.wrappedValue!, 101 | configuration: configuration, 102 | delegate: context.coordinator, 103 | modalPresentationStyle: modalPresentationStyle 104 | ) 105 | }, 106 | makeCoordinator: { dismissHandler in 107 | Coordinator(dismissHandler: dismissHandler) 108 | }, 109 | animated: animated 110 | ) 111 | } 112 | 113 | private static func makeSafariViewController( 114 | url: URL, 115 | configuration: SFSafariViewController.Configuration?, 116 | delegate: SFSafariViewControllerDelegate, 117 | modalPresentationStyle: UIModalPresentationStyle 118 | ) -> SFSafariViewController { 119 | let safariViewController: SFSafariViewController 120 | if let configuration = configuration { 121 | safariViewController = .init(url: url, configuration: configuration) 122 | } else { 123 | safariViewController = .init(url: url) 124 | } 125 | safariViewController.delegate = delegate 126 | safariViewController.modalPresentationStyle = modalPresentationStyle 127 | return safariViewController 128 | } 129 | } 130 | 131 | private class SafariWebViewCoordinator: NSObject, SFSafariViewControllerDelegate { 132 | let dismissHandler: UIViewControllerPresenting.DismissHandler 133 | 134 | init(dismissHandler: @escaping UIViewControllerPresenting.DismissHandler) { 135 | self.dismissHandler = dismissHandler 136 | } 137 | 138 | func safariViewControllerDidFinish(_ controller: SFSafariViewController) { 139 | dismissHandler() 140 | } 141 | } 142 | 143 | // Taken from https://raw.githubusercontent.com/pointfreeco/swiftui-navigation/main/Sources/SwiftUINavigation/Binding.swift 144 | // Copyright (c) 2021 Point-Free, Inc. 145 | // License: MIT 146 | // https://github.com/pointfreeco/swiftui-navigation/blob/main/LICENSE 147 | extension Binding { 148 | public func isPresent() -> Binding 149 | where Value == Wrapped? { 150 | .init( 151 | get: { self.wrappedValue != nil }, 152 | set: { isPresent, transaction in 153 | if !isPresent { 154 | self.transaction(transaction).wrappedValue = nil 155 | } 156 | } 157 | ) 158 | } 159 | } 160 | #endif 161 | -------------------------------------------------------------------------------- /Sources/UIViewControllerPresenting/Documentation.docc/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Presenting UIKit view controllers from SwiftUI 2 | 3 | This article provides a basic overview for wrapping your own UIKit controller - for more examples see some of the examples provided as part of the Swift package. 4 | 5 | ## Overview 6 | 7 | `UIViewControllerPresenting` acts as a building block for creating APIs - usually in the form of SwiftUI view modifiers - for presenting either built-in or your own custom UIKit view controllers. 8 | 9 | ## Wrapping the view controller 10 | 11 | In this example, we have a custom `WidgetViewController` that is intended to be used as a reusable component throughout our app. Most of our app is written in SwiftUI and so we need an easy way of presenting the widget view from anywhere within our SwiftUI view hierarchy. 12 | 13 | Additionally, the view is intended to be presented as a half sheet which we cannot accomplish in SwiftUI prior to iOS 16 without using a third-party library. 14 | 15 | Using `UIViewControllerPresenting` we can create a binding-based SwiftUI view modifier that will display our widget view automatically. This is the final API we intend to build: 16 | 17 | ```swift 18 | struct ContentView: View { 19 | @State var showingWidgetView: Bool = false 20 | 21 | var body: some View { 22 | Text("Hello World") 23 | .widgetView(isPresented: $showingWidgetView) 24 | } 25 | } 26 | ``` 27 | 28 | First, we need to be able to create an instance of `UIViewControllerPresenting` that will display our custom `WidgetViewController` - a good place to define this is in a static function in a constrained extension: 29 | 30 | ```swift 31 | extension UIViewControllerPresenting where Controller == WidgetViewController { 32 | static func widgetViewController(isPresented: Binding) -> Self { 33 | ... 34 | } 35 | } 36 | ``` 37 | 38 | Because this is not the public API that we intend to expose to users, it is fine to leave this defined as `internal` or even `private`. 39 | 40 | Next, we need to initialize and return a `UIViewControllerPresenting` instance - the `makeUIViewController` parameter takes a closure that should return an instance of the UIViewController that we want to present: 41 | 42 | ```swift 43 | extension UIViewControllerPresenting where Controller == WidgetViewController { 44 | static func widgetViewController(isPresented: Binding) { 45 | .init( 46 | isPresented: isPresented, 47 | makeUIViewController: { context, dismissHandler in 48 | let viewController = WidgetViewController() 49 | // Ensure the view is presented as a half sheet 50 | if let sheet = viewController.sheetPresentationController { 51 | sheet.detents = [.medium()] 52 | } 53 | return viewController 54 | } 55 | ) 56 | } 57 | } 58 | ``` 59 | 60 | Our custom controller is very simple, but it does have a completion handler API that will get called when a particular action happens and it is important for us to call the `dismissHandler` provided to the `makeUIViewController` closure whenever a view controller can be dismissed programatically. For delegate-based APIs, you will need to use a coordinator however as our controller uses a callback API we can handle it right here: 61 | 62 | ```swift 63 | extension UIViewControllerPresenting where Controller == WidgetViewController { 64 | static func widgetViewController(isPresented: Binding) { 65 | .init( 66 | isPresented: isPresented, 67 | makeUIViewController: { context, dismissHandler in 68 | let viewController = WidgetViewController() 69 | ... 70 | viewController.onCompletion = { dismissHandler() } 71 | return viewController 72 | } 73 | ) 74 | } 75 | } 76 | ``` 77 | 78 | Finally, in order to actually use this in a SwiftUI view, we need to embed it in the background. We will wrap this boilerplate up in an extension method on `View` and this will provide our public API. Alternatively, you could create a view modifier. 79 | 80 | ```swift 81 | public extension View { 82 | func widgetView(isPresented: Binding) -> some View { 83 | background( 84 | UIViewControllerPresenting.widgetViewController(isPresented: isPresented) 85 | ) 86 | } 87 | } 88 | ``` 89 | 90 | Now, whenever we call this on a SwiftUI view and pass it a binding to some boolean state, the view will be automatically presented whenever the state becomes true: 91 | 92 | 93 | ```swift 94 | struct ContentView: View { 95 | @State var showingWidgetView: Bool = false 96 | 97 | var body: some View { 98 | Button { 99 | showingWidgetView = true 100 | } label: { 101 | Text("Open Widget View") 102 | } 103 | .widgetView(isPresented: $showingWidgetView) 104 | } 105 | } 106 | ``` 107 | -------------------------------------------------------------------------------- /Sources/UIViewControllerPresenting/Documentation.docc/UIViewControllerPresenting.md: -------------------------------------------------------------------------------- 1 | # ``UIViewControllerPresenting`` 2 | 3 | A library that allows you to present UIKit or SwiftUI views from an existing SwiftUI view using UIKit presentation APIs. 4 | 5 | ## Overview 6 | 7 | `UIViewControllerPresenting` provides a framework for wrapping existing UIKit views and presenting them from a SwiftUI view using a similar API to the native SwiftUI `.sheet` APIs. It is ideal for presenting built-in UIKit view controllers but can also be used for presenting your own custom UIKit controllers or even presenting another SwiftUI view using UIKit presentation features such as sheet detents. 8 | 9 | ## Topics 10 | 11 | ### Essentials 12 | 13 | - 14 | - ``UIViewControllerPresenting/UIViewControllerPresenting`` 15 | 16 | ### Customising Sheet Presentation 17 | 18 | - ``SheetConfiguration`` 19 | -------------------------------------------------------------------------------- /Sources/UIViewControllerPresenting/UIViewControllerPresenting.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import SwiftUI 3 | 4 | /// A SwiftUI view that can be used to present an arbitrary UIKit view controller. 5 | /// 6 | /// This view takes care of all the presentation logic, allowing you to focus on the creation of the 7 | /// UIViewController you want to present, along with any coordinator you need. 8 | /// 9 | /// This also uses the UIKit presentation mechanism - the representable view controller is actually 10 | /// just a basic view controller which is used to access the UIKit presentation APIs - the custom 11 | /// view controller will be created and presented using the `.present` API. 12 | /// 13 | /// As per the documentation for `.present`, this means that the presentation style can depend on the 14 | /// context - if you embed one of these views inside a leaf view, UIKit will actually go up the view 15 | /// hierarchy to find the nearest full screen view controller and ask that to do the actual presentation. 16 | /// If that is a `UINavigationController` (or a `NavigationView`) then it will present 17 | /// the view controller as a push. Otherwise, it will present the view controller modally using the 18 | /// `modalPresentationStyle` that you set on the view controller (or the default). 19 | /// 20 | /// To use one of these views, you should simply insert them into the background of an existing 21 | /// view and pass in a `Binding` to control the presentation. 22 | /// 23 | /// - Important: If the presented view controller can be automatically dismissed, e.g. 24 | /// on completion of some activity, then you must implement any callback API (such as a 25 | /// completion handler or a delegate call) and call the provided dismiss handler - the dismiss 26 | /// handler is provided to both the `makeUIViewController` and `makeCoordinator` 27 | /// closures for this purpose. 28 | /// 29 | public struct UIViewControllerPresenting: UIViewControllerRepresentable { 30 | /// A callback that should be called by the presented view controller if it dismisses itself. 31 | public typealias DismissHandler = () -> Void 32 | 33 | /// A builder closure that should return the UIViewController to be presented. 34 | /// 35 | /// If the view controller you are calling has it's own completion handler callback that is invoked 36 | /// when the controller is dismissed, you must set that completion handler to call the provided 37 | /// dismiss handler to ensure the internal presentation state is updated correctly. 38 | /// 39 | /// If the UIViewController uses a delegate-based API for handling dismissal or completion, then 40 | /// you should instead implement a Coordinator object and call the DismissHandler from the 41 | /// coordinator instead. 42 | /// 43 | /// - Parameters: 44 | /// - Context: The context value, if one is present (otherwise Void). 45 | /// - DismissHandler: A callback closure that can be called to indicate the controller was dismissed. 46 | /// 47 | private let _makeUIViewController: (Context, @escaping DismissHandler) -> Controller 48 | 49 | /// A closure that should return a coordinator for this view, if one is needed. 50 | /// 51 | /// The dismiss handler callback is provided in case your coordinator needs to hold on to a reference 52 | /// to it in order to call it at a later time, e.g. if the view controller's delegate indicates completion or 53 | /// dismissal. 54 | /// 55 | /// - Parameters: 56 | /// - DismissHandler: The dismiss handler - you should keep a reference to this in your 57 | /// coordinator object if it needs to call it later, .e.g. in a delegate callback. 58 | /// 59 | private let _makeCoordinator: (@escaping DismissHandler) -> Coordinator 60 | 61 | /// Controls whether the presentation should be animated or not. 62 | private var animated: Bool = true 63 | 64 | /// The state used to drive the presentation and disappearance of the UIViewController. 65 | @Binding 66 | private var isPresented: Bool 67 | 68 | /// Used to track the actual presentation state of the presented view controller. 69 | @StateObject 70 | private var presentationState = PresentationState() 71 | 72 | private var presentationDelegate: PresentationDelegate? 73 | 74 | public init( 75 | isPresented: Binding, 76 | makeUIViewController: @escaping (Context, @escaping DismissHandler) -> Controller, 77 | makeCoordinator: @escaping (@escaping DismissHandler) -> Coordinator, 78 | animated: Bool = true 79 | ) { 80 | self._isPresented = isPresented 81 | self._makeUIViewController = makeUIViewController 82 | self._makeCoordinator = makeCoordinator 83 | self.animated = animated 84 | self.presentationDelegate = .init(handleDismiss: handleDismiss) 85 | } 86 | 87 | public func makeCoordinator() -> Coordinator { 88 | _makeCoordinator(handleDismiss) 89 | } 90 | 91 | public func makeUIViewController(context: Context) -> UIViewController { 92 | // We just need a plain view controller to hook into the presentation APIs. 93 | let presentingViewController = UIViewController() 94 | 95 | if isPresented { 96 | // If the binding is `true` already, we should present immediately. 97 | presentViewController(from: presentingViewController, context: context) 98 | } 99 | return presentingViewController 100 | } 101 | 102 | public func updateUIViewController(_ presentingViewController: UIViewController, context: Context) { 103 | switch (isPresented, presentationState.isActuallyPresented) { 104 | case (true, false): 105 | presentViewController(from: presentingViewController, context: context) 106 | case (false, true): 107 | presentingViewController.dismiss(animated: true) { 108 | handleDismiss() 109 | } 110 | default: 111 | break 112 | } 113 | } 114 | 115 | private func handleDismiss() { 116 | presentationState.isActuallyPresented = false 117 | isPresented = false 118 | } 119 | 120 | private func presentViewController(from presentingViewController: UIViewController, context: Context) { 121 | let viewController = _makeUIViewController(context, handleDismiss) 122 | 123 | // Setting this prevents a crash on iPad 124 | viewController.popoverPresentationController?.sourceView = presentingViewController.view 125 | 126 | // Assign the delegate so we can keep track of its presentation. 127 | viewController.presentationController?.delegate = presentationDelegate 128 | 129 | presentingViewController.present(viewController, animated: animated) { 130 | presentationState.isActuallyPresented = true 131 | } 132 | } 133 | 134 | private class PresentationState: ObservableObject { 135 | @Published var isActuallyPresented: Bool = false 136 | } 137 | 138 | private class PresentationDelegate: NSObject, UIAdaptivePresentationControllerDelegate { 139 | private let handleDismiss: () -> Void 140 | 141 | init(handleDismiss: @escaping () -> Void) { 142 | self.handleDismiss = handleDismiss 143 | } 144 | 145 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { 146 | self.handleDismiss() 147 | } 148 | } 149 | } 150 | 151 | // MARK: - Initialising a presenting view without a coordinator 152 | 153 | extension UIViewControllerPresenting where Coordinator == Void { 154 | public init( 155 | isPresented: Binding, 156 | makeUIViewController: @escaping (Context, @escaping DismissHandler) -> Controller, 157 | animated: Bool = true 158 | ) { 159 | self.init( 160 | isPresented: isPresented, 161 | makeUIViewController: makeUIViewController, 162 | makeCoordinator: { _ in () }, 163 | animated: animated 164 | ) 165 | } 166 | } 167 | #endif 168 | -------------------------------------------------------------------------------- /Sources/UIViewControllerPresenting/ViewPresenting.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import SwiftUI 3 | import UIKit 4 | 5 | // MARK: - Presenting SwiftUI views using UIKit presentation. 6 | 7 | public extension View { 8 | /// Presents SwiftUI content using UIKit sheet presentation mechanics. 9 | /// 10 | /// This view modifier allows you to use a binding to trigger a sheet presentation 11 | /// of the given SwiftUI content, which is automatically wrapped in a `UIHostingController`. 12 | /// 13 | /// Because this is built on top of UIKit presentation, it gives access to sheet presentation APIs 14 | /// that are only available in UIKit, such as detents for custom height sheets. To customise the 15 | /// sheet presentation, supply a sheet configuration that configures the sheet presentation 16 | /// controller to your requirements. 17 | /// 18 | /// ``` 19 | /// if #available(iOS 15, *) { 20 | /// Button { showsHalfSheet = true } label: { 21 | /// Text("Show Half Sheet") 22 | /// } 23 | /// .buttonStyle(.plain) 24 | /// .presenting( 25 | /// isPresented: $showsHalfSheet, 26 | /// sheetConfiguration: .halfSheet 27 | /// ) { 28 | /// VStack(spacing: 10) { 29 | /// Text("This is a sheet shown by UIKit presentation.") 30 | /// Button { showsHalfSheet = false } label: { 31 | /// Text("Dismiss") 32 | /// } 33 | /// .buttonStyle(.borderless) 34 | /// } 35 | /// } 36 | /// } 37 | /// ``` 38 | /// 39 | /// - Parameters: 40 | /// - isPresented: A binding that determines when the sheet should be presented. 41 | /// - animated: Whether or not the sheet should be presented with animation. 42 | /// - modalPresentationStyle: The modal presentation style for the sheet. 43 | /// - sheetConfiguration: Used to configure the hosting controller's sheet presentation controller. 44 | /// - content: A SwiftUI view builder that returns the content to be presented in the sheet. 45 | /// 46 | @available(iOS 15.0, *) 47 | func presenting( 48 | isPresented: Binding, 49 | animated: Bool = true, 50 | modalPresentationStyle: UIModalPresentationStyle = .automatic, 51 | sheetConfiguration: SheetConfiguration = .default, 52 | @ViewBuilder content: () -> Content 53 | ) -> some View { 54 | background( 55 | UIViewControllerPresenting.content( 56 | content(), 57 | isPresented: isPresented, 58 | sheetConfiguration: sheetConfiguration, 59 | animated: animated 60 | ) 61 | ) 62 | } 63 | 64 | /// Presents SwiftUI content using UIKit sheet presentation mechanics. 65 | /// 66 | /// This view modifier allows you to use a binding to trigger a sheet presentation of a 67 | /// of the given SwiftUI content, which is automatically wrapped in a `UIHostingController`. 68 | /// 69 | /// This overload does not support advanced sheet configuration and is made available for 70 | /// backwards compatibility with iOS 14. 71 | /// 72 | /// - Parameters: 73 | /// - isPresented: A binding that determines when the sheet should be presented. 74 | /// - animated: Whether or not the sheet should be presented with animation. 75 | /// - modalPresentationStyle: The modal presentation style for the sheet. 76 | /// - content: A SwiftUI view builder that returns the content to be presented in the sheet. 77 | /// 78 | func presenting( 79 | isPresented: Binding, 80 | animated: Bool = true, 81 | modalPresentationStyle: UIModalPresentationStyle = .automatic, 82 | @ViewBuilder content: () -> Content 83 | ) -> some View { 84 | background( 85 | UIViewControllerPresenting.content( 86 | content(), 87 | isPresented: isPresented, 88 | animated: animated 89 | ) 90 | ) 91 | } 92 | } 93 | 94 | fileprivate extension UIViewControllerPresenting where Coordinator == Void { 95 | @available(iOS 15.0, *) 96 | static func content( 97 | _ content: Content, 98 | isPresented: Binding, 99 | modalPresentationStyle: UIModalPresentationStyle = .automatic, 100 | sheetConfiguration: SheetConfiguration = .default, 101 | animated: Bool = true 102 | ) -> Self where Controller == UIHostingController { 103 | .init(isPresented: isPresented) { _, _ in 104 | let hostingController = UIHostingController(rootView: content) 105 | hostingController.modalPresentationStyle = modalPresentationStyle 106 | if let sheet = hostingController.sheetPresentationController { 107 | sheetConfiguration.configure(sheet) 108 | } 109 | return hostingController 110 | } 111 | } 112 | 113 | static func content( 114 | _ content: Content, 115 | isPresented: Binding, 116 | modalPresentationStyle: UIModalPresentationStyle = .automatic, 117 | animated: Bool = true 118 | ) -> Self where Controller == UIHostingController { 119 | .init(isPresented: isPresented) { _, _ in 120 | let hostingController = UIHostingController(rootView: content) 121 | hostingController.modalPresentationStyle = modalPresentationStyle 122 | return hostingController 123 | } 124 | } 125 | } 126 | 127 | /// A type that configures a sheet presentation controller. 128 | @available(iOS 15.0, *) 129 | public struct SheetConfiguration { 130 | /// A closure that will be called to configure the sheet presentation controller before presentation. 131 | var configure: (UISheetPresentationController) -> Void 132 | 133 | public init(configure: @escaping (UISheetPresentationController) -> Void) { 134 | self.configure = configure 135 | } 136 | } 137 | 138 | @available(iOS 15.0, *) 139 | public extension SheetConfiguration { 140 | /// The default sheet configuration, which does not make any changes to the sheet presentation controller. 141 | static let `default` = SheetConfiguration(configure: { _ in }) 142 | 143 | /// A sheet configuration that presents a sheet at a fixed half screen height with no grabber visible. 144 | static let halfSheet = SheetConfiguration { 145 | $0.detents = [.medium()] 146 | $0.prefersGrabberVisible = false 147 | } 148 | 149 | /// A sheet configuration that presents a sheet at half screen height, but expandable to full screen. 150 | /// 151 | /// - Parameters: 152 | /// - prefersGrabberVisible: Determines if the grabber should be visible. 153 | static func expandableHalfSheet(prefersGrabberVisible: Bool = true) -> Self { 154 | SheetConfiguration { 155 | $0.detents = [.medium(), .large()] 156 | $0.prefersGrabberVisible = prefersGrabberVisible 157 | } 158 | } 159 | } 160 | 161 | #endif 162 | -------------------------------------------------------------------------------- /Tests/UIViewControllerPresentingTests/UIViewControllerPresentingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import UIViewControllerPresenting 3 | 4 | final class UIViewControllerPresentingTests: XCTestCase { 5 | func testExample() throws { 6 | // TODO 7 | } 8 | } 9 | --------------------------------------------------------------------------------