Prevent dismissal of modal view controller in SwiftUI
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.
Disable the interactive dismissal of presented view controller
Option 1:
viewController.isModalInPresentation = true
(Disabled interactive .pageSheet
dismissal acts like this.)
- Since the iOS 13,
UIViewController
contains a new property calledisModalInPresentation
which must be set totrue
to prevent the interactive dismissal. - It basically ignores events outside the view controller's bounds. Bear that in mind if you are using not only the automatic style but also presentation styles like
.popover
etc. - This property is
false
by default.
From the official docs: If
true
, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.
Option 2:
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return false
}
- Since the iOS 13,
UIAdaptivePresentationControllerDelegate
contains a new method calledpresentationControllerShouldDismiss
. - This method is called only if the presented view controller is not dismissed programmatically and its
isModalInPresentation
property is set tofalse
.
Tip: Don't forget to assign
presentationController
's delegate. But be aware, it is known that even just accessing thepresentationController
can cause a memory leak.
Is it possible to make a modal non-dismissible in SwiftUI?
iOS 15 and later:
Use .interactiveDismissDisabled(true)
on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture
. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
@Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
Disable gesture to pull down form/page sheet modal presentation
In general, you shouldn't try to disable the swipe to dismiss functionality, as users expect all form/page sheets to behave the same across all apps. Instead, you may want to consider using a full-screen presentation style. If you do want to use a sheet that can't be dismissed via swipe, set isModalInPresentation = true
, but note this still allows the sheet to be pulled down vertically and it'll bounce back up upon releasing the touch. Check out the UIAdaptivePresentationControllerDelegate documentation to react when the user tries to dismiss it via swipe, among other actions.
If you have a scenario where your app's gesture or touch handling is impacted by the swipe to dismiss feature, I did receive some advice from an Apple engineer on how to fix that.
If you can prevent the system's pan gesture recognizer from beginning, this will prevent the gestural dismissal. A few ways to do this:
If your canvas drawing is done with a gesture recognizer, such as your own
UIGestureRecognizer
subclass, enter thebegan
phase before the sheet’s dismiss gesture does. If you recognize as quickly asUIPanGestureRecognizer
, you will win, and the sheet’s dismiss gesture will be subverted.If your canvas drawing is done with a gesture recognizer, setup a dynamic failure requirement with
-shouldBeRequiredToFailByGestureRecognizer:
(or the related delegate method), where you returnNO
if the passed in gesture recognizer is aUIPanGestureRecognizer
.If your canvas drawing is done with manual touch handling (e.g.
touchesBegan:
), override-gestureRecognizerShouldBegin
on your touch handling view, and returnNO
if the passed in gesture recognizer is aUIPanGestureRecognizer
.
With my setup #3 proved to work very well. This allows the user to swipe down anywhere outside of the drawing canvas to dismiss (like the nav bar), while allowing the user to draw without moving the sheet, just as one would expect.
I cannot recommend trying to find the gesture to disable it, as it seems to be rather dynamic and can reenable itself when switching between different size classes for example, and this could change in future releases.
Is there a way to have interactive modal dismissal for a .fullscreen modally presented view controller?
It can be done with creating your custom UIPresentationController
and UIViewControllerTransitioningDelegate
. Lets say we have TestViewController
and we want to present SecondViewController
with total presentedHeight
of 1.0 (fullScreen). Presentation will be triggered with @IBAction func buttonPressed
and can be dismissed by dragging controller down (as we are used to it). It would be also nice to add some backgroundEffect
to be gradually changed while user is sliding down the SecondViewController
(especially when used only presentedHeight
of 0.6).
Firstly we define
OverlayViewController
which will be later superclass of presentedSecondViewController
and will containUIPanGestureRecognizer
.class OverlayViewController: UIViewController {
var hasSetPointOrigin = false
var pointOrigin: CGPoint?
var delegate: OverlayViewDelegate?
override func viewDidLoad() {
super.viewDidLoad()
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerAction))
view.addGestureRecognizer(panGesture)
}
override func viewDidLayoutSubviews() {
if !hasSetPointOrigin {
hasSetPointOrigin = true
pointOrigin = self.view.frame.origin
}
}
@objc func panGestureRecognizerAction(sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: view)
// Not allowing the user to drag the view upward
guard translation.y >= 0 else { return }
let currentPosition = translation.y
let originPos = self.pointOrigin
delegate?.userDragged(draggedPercentage: translation.y/originPos!.y)
// setting x as 0 because we don't want users to move the frame side ways!! Only want straight up or down
view.frame.origin = CGPoint(x: 0, y: self.pointOrigin!.y + translation.y)
if sender.state == .ended {
let dragVelocity = sender.velocity(in: view)
if dragVelocity.y >= 1100 {
self.dismiss(animated: true, completion: nil)
} else {
// Set back to original position of the view controller
UIView.animate(withDuration: 0.3) {
self.view.frame.origin = self.pointOrigin ?? CGPoint(x: 0, y: 400)
self.delegate?.animateBlurBack(seconds: 0.3)
}
}
}
}
}
protocol OverlayViewDelegate: AnyObject {
func userDragged(draggedPercentage: CGFloat)
func animateBlurBack(seconds: TimeInterval)
}Next we define custom
PresentationController
class PresentationController: UIPresentationController {
private var backgroundEffectView: UIView?
private var backgroundEffect: BackgroundEffect?
private var viewHeight: CGFloat?
private let maxDim:CGFloat = 0.6
private var tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer()
convenience init(presentedViewController: UIViewController,
presenting presentingViewController: UIViewController?,
backgroundEffect: BackgroundEffect = .blur,
viewHeight: CGFloat = 0.6)
{
self.init(presentedViewController: presentedViewController, presenting: presentingViewController)
self.backgroundEffect = backgroundEffect
self.backgroundEffectView = returnCorrectEffectView(backgroundEffect)
self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissController))
self.backgroundEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.backgroundEffectView?.isUserInteractionEnabled = true
self.backgroundEffectView?.addGestureRecognizer(tapGestureRecognizer)
self.viewHeight = viewHeight
}
private override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
}
override var frameOfPresentedViewInContainerView: CGRect {
CGRect(origin: CGPoint(x: 0, y: self.containerView!.frame.height * (1-viewHeight!)),
size: CGSize(width: self.containerView!.frame.width, height: self.containerView!.frame.height *
viewHeight!))
}
override func presentationTransitionWillBegin() {
self.backgroundEffectView?.alpha = 0
self.containerView?.addSubview(backgroundEffectView!)
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
switch self.backgroundEffect! {
case .blur:
self.backgroundEffectView?.alpha = 1
case .dim:
self.backgroundEffectView?.alpha = self.maxDim
case .none:
self.backgroundEffectView?.alpha = 0
}
}, completion: { (UIViewControllerTransitionCoordinatorContext) in })
}
override func dismissalTransitionWillBegin() {
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
self.backgroundEffectView?.alpha = 0
}, completion: { (UIViewControllerTransitionCoordinatorContext) in
self.backgroundEffectView?.removeFromSuperview()
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
presentedView?.frame = frameOfPresentedViewInContainerView
backgroundEffectView?.frame = containerView!.bounds
}
@objc func dismissController(){
self.presentedViewController.dismiss(animated: true, completion: nil)
}
func graduallyChangeOpacity(withPercentage: CGFloat) {
self.backgroundEffectView?.alpha = withPercentage
}
func returnCorrectEffectView(_ effect: BackgroundEffect) -> UIView {
switch effect {
case .blur:
var blurEffect = UIBlurEffect(style: .dark)
if self.traitCollection.userInterfaceStyle == .dark {
blurEffect = UIBlurEffect(style: .light)
}
return UIVisualEffectView(effect: blurEffect)
case .dim:
var dimView = UIView()
dimView.backgroundColor = .black
if self.traitCollection.userInterfaceStyle == .dark {
dimView.backgroundColor = .gray
}
dimView.alpha = maxDim
return dimView
case .none:
let clearView = UIView()
clearView.backgroundColor = .clear
return clearView
}
}
}
extension PresentationController: OverlayViewDelegate {
func userDragged(draggedPercentage: CGFloat) {
graduallyChangeOpacity(withPercentage: 1-draggedPercentage)
switch self.backgroundEffect! {
case .blur:
graduallyChangeOpacity(withPercentage: 1-draggedPercentage)
case .dim:
graduallyChangeOpacity(withPercentage: maxDim-draggedPercentage)
case .none:
self.backgroundEffectView?.alpha = 0
}
}
func animateBlurBack(seconds: TimeInterval) {
UIView.animate(withDuration: seconds) {
switch self.backgroundEffect! {
case .blur:
self.backgroundEffectView?.alpha = 1
case .dim:
self.backgroundEffectView?.alpha = self.maxDim
case .none:
self.backgroundEffectView?.alpha = 0
}
}
}
}
enum BackgroundEffect {
case blur
case dim
case none
}Create
SecondViewController
subclassingOverlayViewController
:class SecondViewController: OverlayViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .blue
// Do any additional setup after loading the view.
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
addSlider()
}
func addSlider() {
let sliderWidth:CGFloat = 100
let centerOfScreen = self.view.frame.size.width / 2
let rect = CGRect(x: centerOfScreen - sliderWidth/2, y: 80, width: sliderWidth, height: 10)
let slider = UIView(frame: rect)
slider.backgroundColor = .black
self.view.addSubview(slider)
}Add
showOverlay()
function that will be triggered afterbuttonPressed
and conform your presentingUIViewController
(TestViewController) toUIViewControllerTransitioningDelegate
:class TestViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
@IBAction func buttonPressed(_ sender: Any) {
showOverlay()
}
func showOverlay() {
let secondVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "secondVC") as! SecondViewController
secondVC.modalPresentationStyle = .custom
secondVC.transitioningDelegate = self
self.present(secondVC, animated: true, completion: nil)
}
}
extension TestViewController: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController?
{
let presentedHeight: CGFloat = 1.0
let controller = PresentationController(presentedViewController: presented,
presenting: presenting,
backgroundEffect: .dim,
viewHeight: presentedHeight)
if let vc = presented as? OverlayViewController {
vc.delegate = controller
}
return controller
}
}Now we should be able to present
SecondViewController
withshowOverlay()
function setting itspresentedHeight
to1.0
and.dim
background effect. We can dismissSecondViewController
similar to another modal presentations.
Related Topics
Xcode iOS Project Only Shows "My MAC 64-Bit" But Not Simulator or Device
How to Change Text on a Back Button
Error When Trying to Obtain a Certificate: the Specified Item Could Not Be Found in the Keychain
Localizing Strings in iOS: Default (Fallback) Language
Restore Already Bought In-App-Purchases on Iphone
Adding Google Maps as Subview Crashes iOS App with Exc_Bad
Decode Base64Url to Base64 -- Swift
Using Secrandomcopybytes in Swift
Swiftui - iOS 13 Uiviewrepresentable of Wkwebview Gets Thread 1: Exc_Breakpoint Crash
Uiview - How to Get Notified When the View Is Loaded
Draw a Circle of 1000M Radius Around Users Location in Mkmapview
Refresh Devices in Team Provisioning Profile Managed by Xcode 7
How to Run the iOS 7.1 Simulator in Xcode 7.0 Beta 2
How to Use a 'Container View' in iOS
Module Compiled with Swift 4.2.1 Cannot Be Imported by the Swift 5.0 Compiler