SwiftUI: how to block swipe to dismiss behavior when presenting [duplicate] - swift

In iOS 13 modal presentations using the form and page sheet style can be dismissed with a pan down gesture. This is problematic in one of my form sheets because the user draws into this box which interferes with the gesture. It pulls the screen down instead of drawing a vertical line.
How can you disable the vertical swipe to dismiss gesture in a modal view controller presented as a sheet?
Setting isModalInPresentation = true still allows the sheet to be pulled down, it just won't dismiss.

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 the began phase before the sheet’s dismiss gesture does. If you recognize as quickly as UIPanGestureRecognizer, 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 return NO if the passed in gesture recognizer is a UIPanGestureRecognizer.
If your canvas drawing is done with manual touch handling (e.g. touchesBegan:), override -gestureRecognizerShouldBegin on your touch handling view, and return NO if the passed in gesture recognizer is a UIPanGestureRecognizer.
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.

This gesture can be found in the modal view controller's presentedView property. As I debugged, the gestureRecognizers array of this property has only one item and printing it resulted in something like this:
UIPanGestureRecognizer: 0x7fd3b8401aa0
(_UISheetInteractionBackgroundDismissRecognizer);
So to disable this gesture you can do like below:
let vc = UIViewController()
self.present(vc, animated: true, completion: {
vc.presentationController?.presentedView?.gestureRecognizers?[0].isEnabled = false
})
To re-enable it simply set isEnabled back to true:
vc.presentationController?.presentedView?.gestureRecognizers?[0].isEnabled = true
Note that iOS 13 is still in beta so a simpler approach might be added in an upcoming release.
Although this solution seems to work at the moment, I would not recommend it as it might not work in some situations or might be changed in future iOS releases and possibly affect your app.

Use this in the presented ViewController viewDidLoad:
if #available(iOS 13.0, *) {
self.isModalInPresentation = true
}

In my case, I have a modal screen with a view that receives touches to capture customer signatures.
Disabling the gesture recognizer in the navigation controller solved the problem, preventing the modal interactive dismissal from being triggered at all.
The following methods are implemented in our modal view controller, and are called via delegate from our custom signature view.
Called from touchesBegan:
private func disableDismissalRecognizers() {
navigationController?.presentationController?.presentedView?.gestureRecognizers?.forEach {
$0.isEnabled = false
}
}
Called from touchesEnded:
private func enableDismissalRecognizers() {
navigationController?.presentationController?.presentedView?.gestureRecognizers?.forEach {
$0.isEnabled = true
}
}
Here is a GIF showing the behavior:
This question, flagged as duplicate, describes better the issue I had: Disabling interactive dismissal of presented view controller on iOS 13 when dragging from the main view

you can change the presentation style, if its in full screen the pull down to dismiss would be disabled
navigationCont.modalPresentationStyle = .fullScreen

No need to reinvent the wheel. It is as simple as adopting the UIAdaptivePresentationControllerDelegate protocol on your destinationViewController and then implement the relevant method:
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return false
}
For example, let's suppose that your destinationViewController is prepared for segue like below:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "yourIdentifier",
let destinationVC = segue.destination as? DetailViewController
{
//do other stuff
destinationVC.presentationController?.delegate = destinationVC
}
}
Then on the destinationVC (that should adopt the protocol described above), you can implement the described method func presentationControllerShouldDismiss(_ presentationController:) -> Bool or any of the other ones, in order to handle correctly your custom behaviour.

You can use the UIAdaptivePresentationControllerDelegate method presentationControllerDidAttemptToDismiss and disable the gestureRecognizer on the presentedView.
Something like this:
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
presentationController.presentedView?.gestureRecognizers?.first?.isEnabled = false
}

For every body having problems with Jordans solution #3 running.
You have to look for the ROOT viewcontroller which is beeing presented, depending on your viewstack, this is maybe not you current view.
I had to look for my navigation controllers PresentationViewController.
BTW #Jordam: Thanks!
UIGestureRecognizer *gesture = [[self.navigationController.presentationController.presentedView gestureRecognizers] firstObject];
if ([gesture isKindOfClass:[UIPanGestureRecognizer class]]) {
UIPanGestureRecognizer * pan = (UIPanGestureRecognizer *)gesture;
pan.delegate = self;
}

You may first get a reference to the UIPanGestureRecognizer handling the page sheet dismissal in viewDidAppear() method. Notice that this reference is nil in viewWillAppear() or viewDidLoad(). Then you simply disable it.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
presentationController?.presentedView?.gestureRecognizers?.first.isEnabled = false
}
If you want more customization rather than disabling it completely, for example, when using a navBar within the page sheet, set the delegate of that UIPanGestureRecognizer to your own view controller. That way, you can disable the gesture recognizer exclusively in your contentView while keeping it active in your navBar region by implementing
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {}

in IOS 13
if #available(iOS 13.0, *) {
obj.isModalInPresentation = true
} else {
// Fallback on earlier versions
}

Me, I use this :
-(void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
for(UIGestureRecognizer *gr in self.presentationController.presentedView.gestureRecognizers) {
if (#available(iOS 11.0, *)) {
if([gr.name isEqualToString:#"_UISheetInteractionBackgroundDismissRecognizer"]) {
gr.enabled = false;
}
}
}

Will try to describe method 2 already suggested by #Jordan H in more details:
1) To be able to catch and make decisions about the modal sheet's pan gesture add this into view controller's viewDidLoad:
navigationController?.presentationController?.presentedView?.gestureRecognizers?.forEach {
$0.delegate = self
}
2) Enable the ability to catch the pan gesture together with your own gestures using gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)
3) The actual decision can go in gestureRecognizer(_:shouldBeRequiredToFailBy:)
Example code, which makes the swipe gesture to be preferred over sheet's pan gesture, if both present. It doesn't affect original pan gesture in areas where there is no swipe gesture recognizer and therefore the original "swipe to dismiss" can still work as designed.
extension PeopleViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === UIPanGestureRecognizer.self && otherGestureRecognizer === UISwipeGestureRecognizer.self {
return true
}
return false
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
In my case I have only a few swipe gesture recognizers, so comparing types is enough for me, but if there more of them it might make sense to compare the gestureRecognizers themselves (either programmatically added ones or as outlets from interface builder) as described in this doc: https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/coordinating_multiple_gesture_recognizers/preferring_one_gesture_over_another
Here's how the code works in my case. Without it the swipe gesture was mostly ignored and worked only occasionally.

In the case when a UITableView or UICollectionView initiates the page sheet dismiss gesture when the user attempts to scroll past the top end of the scrolling view, this gesture can be disabled by adding an invisible UIRefreshControl that calls endRefreshing immediately.
See also https://stackoverflow.com/a/58676756/2419404

SwiftUI since iOS 15
.interactiveDismissDisabled()
For Example:
.sheet(isPresented: $add) {
AddView()
.interactiveDismissDisabled()
}

For navigation Controller, to avoid swipe interaction for presented view we can use:
if #available(iOS 13.0, *) {navController.isModalInPresentation = true}

In prepare(for:sender:) :
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == viewControllerSegueID {
let controller = segue.destination as! YourViewController
controller.modalPresentationStyle = .fullScreen
}
}
or, after you initialize your controller:
let controller = YourViewController()
controller.modalPresentationStyle = .fullScreen

Related

#IBSegueAction with condition

In my app I want to perform a segue based on a UITapGestureRecognizer. In case the tap is in the top area of the screen, a segue to the SettingsView should be performed.
In UIKit this was quite simple. I've triggered the performSegue in the UITapGestureRecognizer by wrapping it in an if-statement.
Now I would like to write the SettingsView() in SwiftUI. The SettingsView() will be embedded in a UIHostingController.
My question is:
How can I perform the segue to this UIHostingController (while telling the UIHostingController which View to display)?
I tried to use the new #IBSegueAction. The reason to use this #IBSegueAction is that I can use it to tell the UIHostingController which View to display. The problem is that I can't insert a condition now. Wherever the tap is on the screen, the segue is performed. I haven't found a way to cancel the segue in #IBSegueAction.
My code currently looks like this:
#IBSegueAction func showSettingsHostingControler(_ coder: NSCoder, sender: UITapGestureRecognizer, segueIdentifier: String?) -> UIViewController? {
let location = sender.location(in: self.tableView)
if let _ = self.tableView.indexPathForRow(at: location) {
return nil
} else {
if location.y < 32 {
if location.x < view.bounds.midX {
direction = .left
} else {
direction = .right
}
return UIHostingController(coder: coder, rootView: SettingsView())
}
}
return nil
}
The result currently is that the app segues to a non-existing Nil-view when the tap is in the wrong area.
based on a UITapGestureRecognizer. In case the tap is in the top area of the screen
It sounds to me like your tap gesture recognizer is attached to the wrong view. There should not be any decision to make here. Position a view in the top area of the screen and attach the tap gesture recognizer to that. That way, if this view gets a tap, there is no decision to be made: the tap is in the right place.

Ordering gesture recognisers

I was wondering if I could get some clarification on an issue because I have found Apple's documentation to be very unclear. I have added an edge pan gesture to a UIScrollView (or more accurately the scrollview of a UIPageViewController) and I have found that the swipe/pan gestures of the scrollview clash with the edge pan gesture that I have added.
edit: As requested below, here is the code I have used to implement the gesture on the scroll view and the delegate functions that I have used.
PageViewController VDL:
override func viewDidLoad(){
super.viewDidLoad()
self.dataSource = self
for eachSubView in self.view.subviews {
if String(describing: type(of: eachSubView)) == "_UIQueuingScrollView" {
let leftEdge = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleSwipeFromLeft(_:)))
leftEdge.edges = .left
leftEdge.delegate = self
eachSubView.addGestureRecognizer(leftEdge)
}
}
}
Handle Swipe From Left Function:
func handleSwipeFromLeft(_ gesture: UIScreenEdgePanGestureRecognizer) {
let percent = gesture.translation(in: gesture.view!).x / gesture.view!.bounds.size.width
if gesture.state == .began {
interactionController = UIPercentDrivenInteractiveTransition()
if self.navigationController!.viewControllers.count > 1 {
self.navigationController?.popViewController(animated: true)
} else {
dismiss(animated: true)
}
} else if gesture.state == .changed {
interactionController?.update(percent / 4.8)
} else if gesture.state == .ended {
if percent > 0.2 && gesture.state != .cancelled {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
}
}
GestureRecogniserDelegate:
extension ArticleViewPageController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if String(describing: type(of: gestureRecognizer)) == "UIScreenEdgePanGestureRecognizer" {
return false
} else {
return true
}
}
}
I have been reading Apple's documentation about this issue (Coordinating Multiple Gesture Recognisers, Preferring One Gesture Over Another) but their documentation has not helped. In the first of the two documents there is a section that reads:
To prevent the unintended side effects of the default recognition behavior, you can tell UIKit to recognize gestures in a specific order using a delegate object. UIKit uses the methods of your delegate object to determine whether a gesture recognizer must come before or after other gesture recognizers.
This is exactly what I want to achieve as I want to preference the edge swipe over the other gestures of the scrollview. However, that section goes on to talk about achieving this by implementing the UIGestureRecognizerDelegate method shouldRequireFailureOf which I have implemented but since the scrollview's pan gesture does not actually fail until after a finger is lifted, this does nothing to preference the edge gesture.
I have also implemented the shouldRecognizeSimultaneouslyWith method which does resolve the conflict but it also causes the scrollview to scroll during the edge pan.
I would love to be able to do as that excerpt says and have my gestures recognised in a specific order. Any help in achieving this would be very much appreciated.
Thanks!
This is a bit long shot, but to me it looks like that you should set your leftEdge recogniser to delay touch events and make table recogniser to require leftEdge to fail before it can take over.
leftEdge.delaysTouchesBegan = true
tableView.panGestureRecognizer.require(toFail: leftEdge)
For anyone trying to achieve this very niche thing in the future, I have found a work around.
I implemented the UIGestureRecognizerDelegate method shouldRecognizeSimultaneouslyWith like so:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
Because I only implemented the delegate on my edge pan gesture (as shown above) it is only called when the edge pan is activated. This stopped the conflict between my edge pan and the scrollview's pan but introduced the problem of the PageViewController paging whilst my edge pan caused a pop back in the navigation stack. To counteract this, I added the following code to my edge gesture function (again, as outlined above) to be called when the gesture state was .begun:
for eachSubView in self.view.subviews {
if String(describing: type(of: eachSubView)) == "_UIQueuingScrollView", let queueScrollView = eachSubView as? UIScrollView {
queueScrollView.isScrollEnabled = false
}
}
Finally, I added the same block to the viewDidAppear function in my PageViewController except I set queueScrollView.isScrollEnabled = true. This meant that even if the pop gesture was cancelled, paging between views would still work.
This isn't a fantastic solution but it does have the intended effect of prioritising the edge gesture over the pan gesture, just in a very inelegant way. If a better answer comes up, I will edit this post.

Disable swipe to close on AVPlayerController

iOS 11 has introduced a swipe to close AVPlayerController. I have app that is aimed at toddlers so the screen is easily swiped causing the video to close. Is there anyway to remove the gesture to close the player?
I have tried adding a gesture override to the AVPlayerController's view but it doesn't work. There is a possible solution on How can I add Swipe Gesture to AVPlayer in swift 3 but there must be a cleaner way
If AVPlayerController is embedded (not presenting) the Close button is not presented in the controls view.
My solution is to find the subview with gesture recognizers and remove pan gesture recognizer
for v in playerViewController.view.subviews {
if v.gestureRecognizers != nil {
for gr in v.gestureRecognizers! {
if gr is UIPanGestureRecognizer {
// remove pan gesture to prevent closing on pan
v.removeGestureRecognizer(gr)
}
}
}
}
I managed to solve issue. As #Vakas commented, the AVPlayerController shouldn't be subclassed. I had originally subclassed it and presented using a modal segue. This was causing the problem.
To solve it, I created another view controller that embeds the AVPlayerController in it.
import UIKit
import AVKit
class PlayerViewController: UIViewController, AVPlayerViewControllerDelegate {
var videoRecord: Video!
var presentingController = ""
var videos = [Video]()
var presentingPlaylist: Playlist?
let playerViewController = TFLPlayerController()
override func viewDidLoad() {
super.viewDidLoad()
playerViewController.delegate = self
playerViewController.videoRecord = videoRecord
playerViewController.videos = self.videos
playerViewController.allowsPictureInPicturePlayback = false
// Add the original AVPlayerController in here
self.addChildViewController(playerViewController)
let playerView = playerViewController.view
playerView?.frame = self.view.bounds
self.view.addSubview(playerView!)
playerViewController.didMove(toParentViewController: self)
}
}
I basically use this View Controller to pass through the properties such as videos, etc, to the originally subclassed AVPlayerController.
None of the comments above solved the issue (iOS 13+). Solution:
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
avPlayerViewController.view.addGestureRecognizer(panGestureRecognizer)
where handlePanGesture(_:) is the method that will be called if a pan happen on the screen (and the video won't move - that was the problem in the question that it was dragged), and avPlayerViewController is the AVPlayerViewController instance.
Note: if you want to prevent the pinch / rotation and any other gesture, you can add for every gesture a new UI...GestureRecognizer. Just make sure, that all UI...GestureRecognizers' delegate is set, and this function is implemented:
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}

Pop view controller using Screen edge pan gesture recogniser not following the thumb

As soon as I've added custom bar navigation bar button item I've lost the ability to use the default function to go back. I'd like to have the ability to use "swipe from edge" to go back.
I've added the Edge Pan Gesture Recogniser and connected it to #IBAction, but the dismissing action happens completely as soon as the pan gesture is recognised.
Instead of slowly following my thumb (as seen in other apps), the current view moves out with predefined animation.
How to make the animation following my thumb using Edge Pan Gesture Recogniser?
#IBAction func edgeSwipe(sender: AnyObject) {
navigationController?.popViewControllerAnimated(true)
}
There's no need to add Edge Pan Gesture Recogniser. Following #beyowulf's suggestions I've been able to implement the swipe to go back feature that behaves the same way as the default system implementation does - the views edge follows my thumb as I swipe it to dismiss it.
So I've removed the ScreenEdge Pan Gesture Recogniser from the storyboard and also removed the related #IBAction.
I've made my first view controller to be the delegate for interactivePopGestureRecognizer. Here's the code:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.interactivePopGestureRecognizer?.delegate = self
}
}
extension ViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
return navigationController?.viewControllers.count > 1 ? true : false
}
}

When I call setLeftBarButtonItem,something like "Screen Edge Pan Gesutre Recognizer" just can not work

I know it is a feature of iOS7 added to UINavigationController to pop current ViewController by panning from the screen's left edge. And I found there is a "Screen Edge Pan Gesture Recognizer" in Object Library. But when I implement it by code, its behavior is slightly different from the previous one.
I want to know why this behavior just gone when I call setLeftBarButtonItem method. Hopes someone could help me.
Finally I found the solution to my problem.
class MyViewController : UIGestureRecognizerDelegate {
override func viewDidLoad() {
self.navigationItem.setLeftBarButtonItem(backButtonItem, animated: true) // disable the gesture recognizer
// the magic code
self.navigationController?.interactivePopGestureRecognizer.delegate = self
}
}