I’ll say it loud and clear. What you are about to read, is a hack. As such, it comes with no warranty. Apple may change the way modals are presented in SwiftUI, which may in turn render this article completely useless. But this blog is all about experimenting and learning… so the content of this post, is indeed, relevant.
In my opinion, if Apple ever breaks this hack, it will probably be next year, when the next big release of SwiftUI is out. By then, we will probably have a way of achieving the same result natively.
We Need a Dismiss Gesture Hook
For UIKit view controllers, Apple introduced a new delegate: UIAdaptivePresentationControllerDelegate. There are several interesting methods there. They all aim to change the default behavior of the new dismiss gesture. We’ll use a couple of its methods, but more can be implemented if needed.
As with everything with SwiftUI, these fancy features are not supported yet. So how can we have such a delegate, on a SwiftUI modal? In most cases, SwiftUI wraps some sort of UIKit view or view controller. After researching it a bit, .sheet() modals, are in fact, backed by a real UIViewController. The challenge is tapping into the backing UIViewController of our SwiftUI modal. This article, will be all about it.
It may seem too much code to accomplish something so simple. Nevertheless, most of the code is encapsulated and can be reused across your project with a few lines.
Our Challenge
For our challenge, we are going to have a form, inside a modal view. This modal should be dismissible with the gesture, if no changes were made to the form. If there are unsaved changes, the modal should refuse dismissal.
When refusing the gesture, we’ll implement two options: One that simply refuses, without any feedback. And another option, that will show an alert, indicating there are unsaved changes.
Out project will look like this:
The full code for this article can be downloaded from: https://gist.github.com/swiftui-lab/c4bfff215eabcf9080c5211075702d5a
This is how the modal is in invoked:
struct MyView: View {
@State private var modal1 = false
@State private var modal2 = false
@ObservedObject var model = MyModel()
var body: some View {
DismissGuardian(preventDismissal: $model.preventDismissal, attempted: $model.attempted) {
VStack {
Text("Dismiss Guardian").font(.title)
Button("Modal Without Feedback") {
self.modal1 = true
}.padding(20)
.sheet(isPresented: self.$modal1, content: { MyModal().environmentObject(self.model) })
Button("Modal With Feedback") {
self.modal2 = true
}
.sheet(isPresented: self.$modal2, content: { MyModalWithFeedback().environmentObject(self.model) })
}
}
}
}
Our Form Model
First, we are going to define our form’s model. It contains all the data for the form, and maintains a binding that defines if the modal is dismissible or not, depending on the form status (dirty, or clean).
class MyModel: ObservableObject {
@Published var attempted: Bool = false
@Published var firstname: String = "" {
didSet { updateDismissability() }
}
@Published var lastname: String = "" {
didSet { updateDismissability() }
}
@Published var preventDismissal: Bool = false
func updateDismissability() {
self.preventDismissal = lastname != "" || firstname != ""
}
func save() {
print("save data")
self.resetForm()
}
func resetForm() {
self.firstname = ""
self.lastname = ""
}
}
Tapping the View Controller
In order to get the view controller backing our modal, we are going to use a UIHostingController
, and we will override the following method:
present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil)
In there, we’ll set our <strong>UIAdaptivePresentationControllerDelegate</strong>
delegate on the <strong>presentationController</strong>
.
protocol DismissGuardianDelegate {
func attemptedUpdate(flag: Bool)
}
class DismissGuardianUIHostingController<Content> : UIHostingController<Content>, UIAdaptivePresentationControllerDelegate where Content : View {
var preventDismissal: Bool
var dismissGuardianDelegate: DismissGuardianDelegate?
init(rootView: Content, preventDismissal: Bool) {
self.preventDismissal = preventDismissal
super.init(rootView: rootView)
}
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
viewControllerToPresent.presentationController?.delegate = self
self.dismissGuardianDelegate?.attemptedUpdate(flag: false)
super.present(viewControllerToPresent, animated: flag, completion: completion)
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
self.dismissGuardianDelegate?.attemptedUpdate(flag: true)
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return !self.preventDismissal
}
}
Then, because UIHostingController is a UIKit view controller, we need to make it into something that can be inserted into our SwiftUI hierarchy. You guessed it! We’ll need to create a UIViewControllerRepresentable:
struct DismissGuardian<Content: View>: UIViewControllerRepresentable {
@Binding var preventDismissal: Bool
@Binding var attempted: Bool
var contentView: Content
init(preventDismissal: Binding<Bool>, attempted: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) {
self.contentView = content()
self._preventDismissal = preventDismissal
self._attempted = attempted
}
func makeUIViewController(context: UIViewControllerRepresentableContext<DismissGuardian>) -> UIViewController {
return DismissGuardianUIHostingController(rootView: contentView, preventDismissal: preventDismissal)
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<DismissGuardian>) {
(uiViewController as! DismissGuardianUIHostingController).rootView = contentView
(uiViewController as! DismissGuardianUIHostingController<Content>).preventDismissal = preventDismissal
(uiViewController as! DismissGuardianUIHostingController<Content>).dismissGuardianDelegate = context.coordinator
}
func makeCoordinator() -> DismissGuardian<Content>.Coordinator {
return Coordinator(attempted: $attempted)
}
class Coordinator: NSObject, DismissGuardianDelegate {
@Binding var attempted: Bool
init(attempted: Binding<Bool>) {
self._attempted = attempted
}
func attemptedUpdate(flag: Bool) {
self.attempted = flag
}
}
}
Using Our Dismiss Guardian
You may wonder, why can’t we use our already existing UIHostingController
(i.e., the one in <strong>SceneDelegate</strong>
). The short answer is: you can. The long answer is: you can, but not always. When <strong>.sheet()</strong>
presents a modal, it will use its closes UIKit ViewController. Normally, the UIHostingController
from SceneDelegate
will work, however, as soon as you start using NavigationController, the hack will break. That is why we need a UIHostingController
very close to our .sheet() call.
For example, this will work:
NavigationView {
DismissGuardian(preventDismissal: $preventDismissal, attempted: $attempted) {
Text("hi").sheet(isPresented: self.$modal1, content: { MyModal().environmentObject(self.model) })
}
}
But this will not work:
DismissGuardian(preventDismissal: $preventDismissal, attempted: $attempted) {
NavigationView {
Text("hi").sheet(isPresented: self.$modal1, content: { MyModal().environmentObject(self.model) })
}
}
Handling the Gesture
The example below, presents two modals. One that acts on the gesture, and another that simply refuses to dismiss:
struct MyView: View {
@State private var modal1 = false
@State private var modal2 = false
@ObservedObject var model = MyModel()
var body: some View {
DismissGuardian(preventDismissal: $model.preventDismissal, attempted: $model.attempted) {
VStack {
Text("Dismiss Guardian").font(.title)
Button("Modal Without Feedback") {
self.modal1 = true
}.padding(20)
.sheet(isPresented: self.$modal1, content: { MyModal().environmentObject(self.model) })
Button("Modal With Feedback") {
self.modal2 = true
}
.sheet(isPresented: self.$modal2, content: { MyModalWithFeedback().environmentObject(self.model) })
}
}
}
}
struct MyModal: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var model: MyModel
var body: some View {
NavigationView {
Form {
TextField("First name", text: $model.firstname)
TextField("Last name", text: $model.lastname)
}
.navigationBarTitle("Form (without feedback)", displayMode: .inline)
.navigationBarItems(trailing:
Button("Save") {
self.model.save()
self.presentationMode.wrappedValue.dismiss() }
)
}
.environment(\.horizontalSizeClass, .compact)
}
}
struct MyModalWithFeedback: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var model: MyModel
var body: some View {
NavigationView {
Form {
TextField("First name", text: $model.firstname)
TextField("Last name", text: $model.lastname)
}
.alert(isPresented: self.$model.attempted) {
Alert(title: Text("Unsaved Changes"),
message: Text("You have made changes to the form that have not been saved. If you continue, those changes will be lost."),
primaryButton: .destructive(Text("Delete Changes"), action: {
self.model.resetForm()
self.presentationMode.wrappedValue.dismiss()
}),
secondaryButton: .cancel(Text("Continue Editing")))
}
.navigationBarTitle("Form (with feedback)", displayMode: .inline)
.navigationBarItems(trailing:
Button("Save") {
self.model.save()
self.presentationMode.wrappedValue.dismiss() }
)
}
.environment(\.horizontalSizeClass, .compact)
}
}
In Summary
The hack from this article may break at anytime, but the knowledge of experimenting with it, will in any case add to the understanding on what goes behind the SwiftUI framework.
Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles are posted. Until next time.
Looks similar to what I tried to achieve with BetterSheet: https://github.com/egeniq/BetterSheet Apple should have included this functionality from the start…
thank you brother
The article is really gorgeous with a lot of useful info e.g. about UIHostingController
DismissGuardian I would have design a little differently to exclude forced conversion.
`
typealias UIViewControllerType = DismissGuardianUIHostingController
func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIViewControllerType {
DismissGuardianUIHostingController(rootView: contentView, preventDismissal: preventDismissal)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext) {
uiViewController.rootView = contentView
uiViewController.preventDismissal = preventDismissal
uiViewController.dismissGuardianDelegate = context.coordinator
}
`
I can’t change the previous post, just add
`func makeUIViewController(context: Context) -> UIViewControllerType {`
`func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {`
I couldn’t help but notice if a comment has greater and less characters then its will be cut off with a contents inside, therefore “Content” disappeared in my comments.
I have created a extension to prevent auto dismiss very easily, just call the way like:
.sheet(isPresented: $presenting) {
ModalContent()
.allowAutoDismiss { false }
}
The code
https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0