├── .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 |
--------------------------------------------------------------------------------