Dismiss Gesture for SwiftUI Modals

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.

6 thoughts on “Dismiss Gesture for SwiftUI Modals”

  1. 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
    }
    `

    Reply
    • I can’t change the previous post, just add

      `func makeUIViewController(context: Context) -> UIViewControllerType {`
      `func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {`

      Reply
      • 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.

        Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close