60

At WWDC 2019, Apple announced a new "card-style" look for modal presentations, which brought along with it built-in gestures for dismissing modal view controllers by swiping down on the card. They also introduced the new isModalInPresentation property on UIViewController so that you can disallow this dismissal behavior if you so choose.

So far, though, I have found no way to emulate this behavior in SwiftUI. Using the .presentation(_ modal: Modal?), does not, as far as I can tell, allow you to disable the dismissal gestures in the same way. I also attempted putting the modal view controller inside a UIViewControllerRepresentable View, but that didn't seem to help either:

struct MyViewControllerView: UIViewControllerRepresentable {
    func makeUIViewController(context: UIViewControllerRepresentableContext<MyViewControllerView>) -> UIHostingController<MyView> {
        return UIHostingController(rootView: MyView())
    }

    func updateUIViewController(_ uiViewController: UIHostingController<MyView>, context: UIViewControllerRepresentableContext<MyViewControllerView>) {
        uiViewController.isModalInPresentation = true
    }
}

Even after presenting with .presentation(Modal(MyViewControllerView())) I was able to swipe down to dismiss the view. Is there currently any way to do this with existing SwiftUI constructs?

Jumhyn
  • 6,687
  • 9
  • 48
  • 76

10 Answers10

87

Update for iOS 15

As per pawello2222's answer below, this is now supported by the new interactiveDismissDisabled(_:) API.

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

Pre-iOS-15 answer

I wanted to do this as well, but couldn't find the solution anywhere. The answer that hijacks the drag gesture kinda works, but not when it's dismissed by scrolling a scroll view or form. The approach in the question is less hacky also, so I investigated it further.

For my use case I have a form in a sheet which ideally could be dismissed when there's no content, but has to be confirmed through a alert when there is content.

My solution for this problem:

struct ModalSheetTest: View {
    @State private var showModally = false
    @State private var showSheet = false
    
    var body: some View {
        Form {
            Toggle(isOn: self.$showModally) {
                Text("Modal")
            }
            Button(action: { self.showSheet = true}) {
                Text("Show sheet")
            }
        }
        .sheet(isPresented: $showSheet) {
            Form {
                Button(action: { self.showSheet = false }) {
                    Text("Hide me")
                }
            }
            .presentation(isModal: self.showModally) {
                print("Attempted to dismiss")
            }
        }
    }
}

The state value showModally determines if it has to be showed modally. If so, dragging it down to dismiss will only trigger the closure which just prints "Attempted to dismiss" in the example, but can be used to show the alert to confirm dismissal.

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let isModal: Bool
    let onDismissalAttempt: (()->())?
    
    func makeUIViewController(context: Context) -> UIHostingController<T> {
        UIHostingController(rootView: view)
    }
    
    func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
        context.coordinator.modalView = self
        uiViewController.rootView = view
        uiViewController.parent?.presentationController?.delegate = context.coordinator
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        let modalView: ModalView
        
        init(_ modalView: ModalView) {
            self.modalView = modalView
        }
        
        func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
            !modalView.isModal
        }
        
        func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
            modalView.onDismissalAttempt?()
        }
    }
}

extension View {
    func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
        ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
    }
}

This is perfect for my use case, hope it helps you or someone else out as well.

Jumhyn
  • 6,687
  • 9
  • 48
  • 76
Guido Hendriks
  • 5,706
  • 3
  • 27
  • 37
  • 5
    This is THE way to do it. Nothing hacky and very elegant. Thanks Guido. – Junyi Wang May 09 '20 at 06:14
  • If you are using this in SwiftUI and state variables are not updating, try applying the .presentation modifier on an element in the sheet that does not require UI refresh (e.g. Spacer(), Divider()). – user1909186 May 31 '20 at 17:57
  • The BEST answer as of July 2020! – Paul D. Jul 14 '20 at 19:06
  • Thanks for following up on this old question @GuidoHendriks. I've updated the accepted answer to reflect the fact that this appears to be the "right" way to achieve this. I had tried dipping down into UIViewController representable, but hadn't messed around with the presentation controller. – Jumhyn Jul 19 '20 at 14:04
  • 9
    Suggestions from a grateful reader: `isModal` shouldn't be a Binding because it is read-only. Removing @Binding breaks this though because the Coordinator will only store the initial value of `isModal`. To fix this you can make `modalView` in the coordinator a `var` and then update it in `updateUIViewController` like `context.coordinator.modalView = self` so it will update properly if `isModal` changes. The comment about state variables not updating can be fixed by doing `uiViewController.rootView = view` in `updateUIViewController`, otherwise the view will not update properly. – Helam Jul 22 '20 at 23:51
  • Using `PresentationMode.dismiss` on the View I'm trying to present modally is no longer working. It seems the only way to dismiss is to swipe down (when `isModal == false`). Is anyone else having this issue? – jjatie Aug 02 '20 at 12:02
  • 3
    @jjatie I am not using the \.presentationMode but using a binding to the variable that is making the sheet visible in this case `.sheet(isPresented: $showSheet)` so put `@Binding var showSheet: Bool` and make `self.showSheet = false` to close instead of `self.presentationMode.wrappedValue.dismiss()` – LetsGoBrandon Aug 20 '20 at 11:45
  • 3
    For some weird reason, this doesn't work on a NavigationView or even simple Text. It works with Form, though. – Abdalrahman Shatou Jan 22 '21 at 21:52
  • @Helam- your fix was exactly what I needed. You have saved me SOOOO much effort. – spentag Jan 29 '21 at 16:10
  • This doesn't seem to be working for me on iPad. – SlimeBaron Feb 04 '21 at 22:46
  • 3
    In addition to the changes in @Helam's comment, I also had to use a subclass of `UIHostingController` to override `willMove(to: parent)` to set the parent's `presentationController`, because `updateUIViewController` was not happening before the first time I tried to dismiss the view controller. – vedosity Feb 07 '21 at 03:07
  • Is there a way to do this when the `View` is not embedded in `UIControllerRepresentable`? – ArielSD Feb 17 '21 at 17:49
  • @guido-hendriks would you like to update this answer to mention the new iOS 15 `interactiveDismissDisabled(_:)` API at the top, and leave the legacy answer for those who need to support iOS 14 and below (or need the `onDismissalAttempt` functionality)? – Jumhyn Jun 10 '21 at 15:38
  • 1
    Like @AbdalrahmanShatou mentions, on my iPad with iOS 14.5, this only works as shown. But if you remove the Form, or add anything but the Button inside the Form, the sheet can be dismissed once more. – Bart van Kuik Jun 25 '21 at 07:46
  • @vedosity I think I'm facing the same problem as you. What exactly do you mean by "set the parent's presentationController"? How does the implementation of `willMove(to: parent)` look like? Thanks – gpichler Nov 01 '21 at 17:44
  • Doesn't work at all... – Richard Topchii Feb 08 '22 at 05:31
16

By changing the gesture priority of any view you don't want to be dragged, you can prevent DragGesture on any view. For example for Modal it can be done as bellow:

Maybe it is not a best practice, but it works perfectly

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
        self.showModal.toggle()

    }) {
        Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
        ModalView()
    }
  }
}

struct ModalView : View {
@Environment(\.presentationMode) var presentationMode

let dg = DragGesture()

var body: some View {

    ZStack {
        Rectangle()
            .fill(Color.white)
            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
            .highPriorityGesture(dg)

        Button("Dismiss Modal") {
            self.presentationMode.wrappedValue.dismiss()
        }
    }
  }
}
FRIDDAY
  • 3,781
  • 1
  • 29
  • 43
  • 2
    For now, this is the best solution I have seen. – Jumhyn Jan 07 '20 at 21:59
  • 1
    there is a problem though dragging down the sheet with two figers will dismiss it, also this solution won't work if the body of the view is topped by another view (Loader i.e UIActivityIndicatorView). – Aleyam Apr 01 '20 at 19:33
  • @Aleyam Those you mentioned can be new questions ( dragging down the sheet with two fingers), and I'm sure there are solutions for that. Of course, this block of code wont work anywhere you paste. This is just to get an ideas. – FRIDDAY Apr 02 '20 at 05:59
  • 2
    yes i got your point (the idea behind this answer), the thing is the question is about "Prevent dismissal of modal view controller in SwiftUI " so if dragging with two fingers dismisses the sheet it seems like the answer is incomplete and also, implementing this logic to prevent dismissal becomes a nightmare for complex view. – Aleyam Apr 02 '20 at 07:24
  • Not only does it dismiss with two-finger drag, but also if you drag any other view inside the sheet. While this is the accepted answer, there has to be a better, less hacky way to do this – krummens May 05 '20 at 03:16
11

Note: This code has been edited for clarity and brevity.

Using a way to get the current window scene from here you can get the top view controller by this extension here from @Bobj-C

extension UIApplication {

    func visibleViewController() -> UIViewController? {
        guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
        guard let rootViewController = window.rootViewController else { return nil }
        return UIApplication.getVisibleViewControllerFrom(vc: rootViewController)
    }

    private static func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
        if let navigationController = vc as? UINavigationController,
            let visibleController = navigationController.visibleViewController  {
            return UIApplication.getVisibleViewControllerFrom( vc: visibleController )
        } else if let tabBarController = vc as? UITabBarController,
            let selectedTabController = tabBarController.selectedViewController {
            return UIApplication.getVisibleViewControllerFrom(vc: selectedTabController )
        } else {
            if let presentedViewController = vc.presentedViewController {
                return UIApplication.getVisibleViewControllerFrom(vc: presentedViewController)
            } else {
                return vc
            }
        }
    }
}

and turn it into a view modifier like this:

struct DisableModalDismiss: ViewModifier {
    let disabled: Bool
    func body(content: Content) -> some View {
        disableModalDismiss()
        return AnyView(content)
    }

    func disableModalDismiss() {
        guard let visibleController = UIApplication.shared.visibleViewController() else { return }
        visibleController.isModalInPresentation = disabled
    }
}

and use like:

struct ShowSheetView: View {
    @State private var showSheet = true
    var body: some View {
        Text("Hello, World!")
        .sheet(isPresented: $showSheet) {
            TestView()
                .modifier(DisableModalDismiss(disabled: true))
        }
    }
}
R. J.
  • 437
  • 5
  • 15
  • 1
    Unfortunately no effect here. I don't think I'm doing anything wrong since it's mostly copy pasting and extension and one if statement. – iMaddin Apr 08 '20 at 02:55
  • 1
    @iMaddin Does the edit using it as a view modifier make any difference for you? – R. J. Apr 09 '20 at 19:51
  • 1
    Copy-pasted your extension and ViewModifier, and used it to modify the content of my Sheet. It works beautifully! Looks great and works without issue. –  May 12 '21 at 20:36
9

For everyone who has problems with @Guido's solution and NavigationView. Just combine the solution of @Guido and @SlimeBaron

class ModalHostingController<Content: View>: UIHostingController<Content>, UIAdaptivePresentationControllerDelegate {
    var canDismissSheet = true
    var onDismissalAttempt: (() -> ())?

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)

        parent?.presentationController?.delegate = self
    }

    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
        canDismissSheet
    }

    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
        onDismissalAttempt?()
    }
}

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let canDismissSheet: Bool
    let onDismissalAttempt: (() -> ())?

    func makeUIViewController(context: Context) -> ModalHostingController<T> {
        let controller = ModalHostingController(rootView: view)

        controller.canDismissSheet = canDismissSheet
        controller.onDismissalAttempt = onDismissalAttempt

        return controller
    }

    func updateUIViewController(_ uiViewController: ModalHostingController<T>, context: Context) {
        uiViewController.rootView = view

        uiViewController.canDismissSheet = canDismissSheet
        uiViewController.onDismissalAttempt = onDismissalAttempt
    }
}

extension View {
    func interactiveDismiss(canDismissSheet: Bool, onDismissalAttempt: (() -> ())? = nil) -> some View {
        ModalView(
            view: self,
            canDismissSheet: canDismissSheet,
            onDismissalAttempt: onDismissalAttempt
        ).edgesIgnoringSafeArea(.all)
    }
}

Usage:

struct ContentView: View {
    @State var isPresented = false
    @State var canDismissSheet = false

    var body: some View {
        Button("Tap me") {
            isPresented = true
        }
        .sheet(
            isPresented: $isPresented,
            content: {
                NavigationView {
                    Text("Hello World")
                }
                .interactiveDismiss(canDismissSheet: canDismissSheet) {
                    print("attemptToDismissHandler")
                }
            }
        )
    }
}
Denwakeup
  • 256
  • 2
  • 4
  • 1
    thank you! This works for other SwiftUI containers as well, not only Form! – Bio-Matic Aug 01 '21 at 06:46
  • 1
    If you need the completion of `presentationControllerDidDismiss` it works perfectly with a solution above. Thank you! – nikolsky Oct 10 '21 at 01:21
  • 1
    If your variable for `canDismissSheet` is referenced by another view and it can animate, then the animation doesn't work while `interactiveDismissDisabled(_:)` does work. Probably it's because of `uiViewController.rootView = view`. – Manabu Nakazawa Jan 30 '22 at 22:26
6

iOS 15+

Starting from iOS 15 we can use interactiveDismissDisabled:

func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View

We just need to attach it to the sheet:

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

If needed, you can also pass a variable to control when the sheet can be disabled:

.interactiveDismissDisabled(!userAcceptedTermsOfUse)
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • Awesome, glad to see this included in the new update! Is there an API for executing an action when the user attempts to dismiss? – Jumhyn Jun 08 '21 at 22:33
  • @Jumhyn No, I couldn't find anything in the docs. Still, that's a step forward. – pawello2222 Jun 08 '21 at 22:44
5

As of iOS 14, you can use .fullScreenCover(isPresented:, content:) (Docs) instead of .sheet(isPresented:, content:) if you don't want the dismissal gestures.

struct FullScreenCoverPresenterView: View {
    @State private var isPresenting = false

    var body: some View {
        Button("Present Full-Screen Cover") {
            isPresenting.toggle()
        }
        .fullScreenCover(isPresented: $isPresenting) {
            Text("Tap to Dismiss")
                .onTapGesture {
                    isPresenting.toggle()
                }
        }
    }
}

Note: fullScreenCover is unavailable on macOS, but it works well on iPhone and iPad.

Note: This solution doesn't allow you to enable the dismissal gesture when a certain condition is met. To enable and disable the dismissal gesture with a condition, see my other answer.

SlimeBaron
  • 645
  • 8
  • 17
  • 1
    This also presents using a different UI—as the name suggests, this mimics the `fullScreen` modal presentation style and doesn't give you the "card style" modal from iOS 13. – Jumhyn Feb 12 '21 at 18:19
  • 1
    Yes, it results in a different appearance, one that visually indicates to the user that the modal cannot be dismissed using a drag gesture. I think that's probably desirable in most cases. In addition, this solution uses documented SwiftUI constructs to complete the task. – SlimeBaron Feb 12 '21 at 18:27
  • Sure, just wanted to make sure it was mentioned that this isn't a solution to the exact problem posted in the question so that anyone who uses this answer isn't surprised when they get a different appearance! :) – Jumhyn Feb 12 '21 at 18:30
3

You can use this method to pass the content of the modal view for reuse.

Use NavigationView with gesture priority to disable dragging.

import SwiftUI

struct ModalView<Content: View>: View
{
    @Environment(\.presentationMode) var presentationMode
    let content: Content
    let title: String
    let dg = DragGesture()
    
    init(title: String, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.title = title
    }
    
    var body: some View
    {
        NavigationView
        {
            ZStack (alignment: .top)
            {
                self.content
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar(content: {
                ToolbarItem(placement: .principal, content: {
                    Text(title)
                })
                
                ToolbarItem(placement: .navigationBarTrailing, content: {
                    Button("Done") {
                        self.presentationMode.wrappedValue.dismiss()
                    }
                })
            })
        }
        .highPriorityGesture(dg)
    }
}

In Content View:

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
       self.showModal.toggle()
    }) {
       Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
       ModalView (title: "Title") {
          Text("Prevent dismissal of modal view.")
       }
    }
  }
}

Result!

enter image description here

Thiên Quang
  • 368
  • 3
  • 9
1

We have created an extension to make controlling the modal dismission effortless, at https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0

/// Example:
struct ContentView: View {
    @State private var presenting = false
    
    var body: some View {
        VStack {
            Button {
                presenting = true
            } label: {
                Text("Present")
            }
        }
        .sheet(isPresented: $presenting) {
            ModalContent()
                .allowAutoDismiss { false }
                // or
                // .allowAutoDismiss(false)
        }
    }
}
swift code
  • 115
  • 1
  • 1
1

This solution worked for me on iPhone and iPad. It uses isModalInPresentation. From the docs:

The default value of this property is false. When you set it to true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.

Your attempt is close to what worked for me. The trick is setting isModalInPresentation on the hosting controller's parent in willMove(toParent:)

class MyHostingController<Content: View>: UIHostingController<Content> {
    var canDismissSheet = true

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)
        parent?.isModalInPresentation = !canDismissSheet
    }
}

struct MyViewControllerView<Content: View>: UIViewControllerRepresentable {
    let content: Content
    let canDismissSheet: Bool

    func makeUIViewController(context: Context) -> UIHostingController<Content> {
        let viewController = MyHostingController(rootView: content)
        viewController.canDismissSheet = canDismissSheet
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
        uiViewController.parent?.isModalInPresentation = !canDismissSheet
    }
}
SlimeBaron
  • 645
  • 8
  • 17
  • Thanks for testing out on iPad. This looks like a good solution, but I don't love that it seems to rely on private details about the UIKit hierarchy above the views in question. Still searching for the perfect answer :( – Jumhyn Feb 04 '21 at 23:26
  • Yes, it makes the same assumption as the accepted answer that it is the hosting controller's parent that manages the presentation of the view. I agree that this isn't perfect. – SlimeBaron Feb 04 '21 at 23:39
  • Ha! You know, I actually missed on the first go-around that that answer reached into `uiViewController.parent`. You're right that this is no worse in that regard. I'll give both of these a spin on iPad and accept your answer if it ends up working better :) – Jumhyn Feb 04 '21 at 23:42
  • The accepted answer is working for me on iPad as well as iPhone. What issue were you seeing? – Jumhyn Feb 05 '21 at 14:47
  • Hmm. I was able to dismiss the sheet on iPad. That may have been a fluke, though. I'll remove that comment from my answer. Thanks for checking that out. – SlimeBaron Feb 05 '21 at 16:52
  • If you're able to reproduce at any point I'd love to hear how. Could also be a bug in SwiftUI also... anyway, I like your solution because it avoids the need to define the `Coordinator` object, at the cost of requiring the custom `UIHostingController` subclass. I wonder if there's a way to trigger `updateUIViewController` immediately upon presentation to avoid the custom subclass... – Jumhyn Feb 05 '21 at 16:56
  • I think that could be done by dispatching onto the main queue and modifying some state. Even still, I think the `uiViewController` might not have its `parent` yet. – SlimeBaron Feb 05 '21 at 21:42
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/228326/discussion-between-jumhyn-and-slimebaron). – Jumhyn Feb 05 '21 at 21:53
0

it supports most of the iOS version no need of making wrappers just do this

extension UINavigationController {

open override func viewDidLoad() {
    super.viewDidLoad()
    interactivePopGestureRecognizer?.isEnabled = false
    interactivePopGestureRecognizer?.delegate = nil    
}}