iOS - Dismiss presented view controller touching outside its View
Asked Answered
W

5

6

I have a CustomPresentationController which animates in and out with custom animations;

This specific controller gets presented, more less at 50% of the screen size, and when I present it, I add a shadow-gray view to the presentingViewController so it adds some depth.

I can only dismiss the presentedViewController if I tap the cancel button in the NavBar which I call the default dismiss(:) method.

What I'm trying to accomplish is to detect a tap outside the presentedViewController, maybe inside the gray zone, so I can dismiss the presentedViewController, somehow like dismissing an ActionSheet but I've failed to do it. Let me explain what I've tried so far.

I tried to add a UITapGestureRecognizer to the shadow-gray view but since I'm presenting a different controller, the app-engine might think that since the shadow view isn't on the top hierarchy view it might not be accessible so it 'blocks' the recognizer - whenever I tap it, the gesture handles doesn't fire.

I'm implementing now in addition a swipe down to dismiss, which I can make it easily, but I really wanted the tap-outside feature to work as well.

Any hint on how can I approach this?

The apps image is the following:

screenshot

Werewolf answered 3/7, 2017 at 9:6 Comment(1)
Instead of adding an invisible button to cover up the outer view, there is a more elegant way to do it : https://mcmap.net/q/450681/-dismiss-modal-form-sheet-view-on-outside-tap-ios-8Sectionalize
P
3

I just had to implement this in one of my app.

I made it worked by adding a button that covers the entire view and this button, once tapped triggers the VC to be dismissed.

Once the button is added you can add your custom View on top.

So far it looks like it's working pretty well.

My code below (I do everything programmatically, no storyboard)

    //—————————————————————————————
    // MARK: View Life Cycle
    //—————————————————————————————

override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = UIColor.clear //VC view background transparent
    setupUI()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    //Animate blackView opacity to 1 to give some depth
    UIView.animate(withDuration: 0.4, delay: 0.2, options: .curveEaseInOut, animations: {
        self.blackView.alpha = 1 
    })
}

    //————————————————
    // MARK: Setup UI
    //————————————————

    let blackView: UIView = {
        let view = UIView()
        view.alpha = 0.0
        view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
        return view
    }()
    //Invisible button which covers the entire view that can be tapped 
    lazy var dismissLayerBtn: UIButton = {
        let btn = UIButton()
        btn.addTarget(self, action: #selector(tapToDismiss), for: .touchUpInside)
        return btn
    }()

    @objc func tapToDismiss() {
        print("tapToDimiss")
        self.dismiss(animated: true, completion: nil)
    }

    let milestonePickerView: MilestonePickerView = {
        let view = MilestonePickerView(frame: .zero)
        return view
    }()

    func setupUI() {

        view.addSubview(blackView)
        view.addSubview(dismissLayerBtn)
        view.addSubview(milestonePickerView) //Important to add the customView after the button.

        blackView.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)

        dismissLayerBtn.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)

        milestonePickerView.anchor(top: nil, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 20, paddingBottom: 40, paddingRight: 20, width: 0, height: 400)

//I'm using a custom extension to setup constraints (anchors) 
   } 

If you're using storyboard, make sure you put the invisible button under the custom view.

I hope this helps.

Piracy answered 28/2, 2018 at 9:12 Comment(0)
E
7

My solution:

In presenting view controller (aka ViewControllerA):

let storyboard = UIStoryboard(name: "Main", bundle: nil)
                
let vcb = storyboard.instantiateViewController(withIdentifier: "ViewControllerB") as! ViewControllerB // ViewControllerB is the presented view controller 
        
vcb.modalPresentationStyle = .custom
        
vcb.transitioningDelegate = self

modalRatio = Float(0.5) // modalRatio is an object property 
   
self.present(pvc, animated: true)

ViewControllerA shall also implement Transitioning delegate:

extension ViewControllerA: UIViewControllerTransitioningDelegate {
        
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
                
        return PartialSizePresentController(presentedViewController: presented, presenting: presenting, withRatio: modalRatio ?? 0.5) // modal ratio is configurable using modalRatio property
        
    }
    
}

Then, implement the presentation controller (aka PartialSizePresentController), so that it also handles tap gesture:

class PartialSizePresentController: UIPresentationController {
    
    let heightRatio : CGFloat
    
    init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, withRatio ratio: Float = 0.5) {
        
        heightRatio = CGFloat(ratio)
        
        super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
        
    }

    override var frameOfPresentedViewInContainerView: CGRect {
        
        guard let cv = containerView else { fatalError("No container view available") }
        
        return CGRect(x: 0, y: cv.bounds.height * (1 - heightRatio), width: cv.bounds.width, height: cv.bounds.height * heightRatio)
        
    }
    
    override func presentationTransitionWillBegin() {
        
        let bdView = UIView(frame: containerView!.bounds)
        
        bdView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
        
        containerView?.addSubview(bdView)
        
        bdView.addSubview(presentedView!)
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(PartialSizePresentController.handleTap(_:)))
        
        bdView.addGestureRecognizer(tapGesture)
        
    }
    
    @objc func handleTap(_ sender: UITapGestureRecognizer) {
        presentedViewController.dismiss(animated: true, completion: nil)
    }
}
Electronegative answered 3/8, 2020 at 16:50 Comment(1)
Perfect. Very nicely explained.Aplenty
P
3

Try with my below code:

You need to implement this method inside you presented controller which you are working with as a popup.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
   // Here write down you logic to dismiss controller     
}

Hope this will work. :D

Piselli answered 3/7, 2017 at 9:20 Comment(2)
It didn't work. Since my popup view is of type UICollectionView I had to set the collectionView?.isUserInteractionEnabled = false to detect touches inside it, but even though it detects touches it does not detect if touches were in the shadow. If I touch the shadow area it doesn't fire the touches(:) methodWerewolf
This will not work, I met the same issue with OP, the tricky thing is we are using viewController instead of view, and the touchesBegan cannot detect touches outside current VC.Cellulosic
P
3

I just had to implement this in one of my app.

I made it worked by adding a button that covers the entire view and this button, once tapped triggers the VC to be dismissed.

Once the button is added you can add your custom View on top.

So far it looks like it's working pretty well.

My code below (I do everything programmatically, no storyboard)

    //—————————————————————————————
    // MARK: View Life Cycle
    //—————————————————————————————

override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = UIColor.clear //VC view background transparent
    setupUI()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    //Animate blackView opacity to 1 to give some depth
    UIView.animate(withDuration: 0.4, delay: 0.2, options: .curveEaseInOut, animations: {
        self.blackView.alpha = 1 
    })
}

    //————————————————
    // MARK: Setup UI
    //————————————————

    let blackView: UIView = {
        let view = UIView()
        view.alpha = 0.0
        view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
        return view
    }()
    //Invisible button which covers the entire view that can be tapped 
    lazy var dismissLayerBtn: UIButton = {
        let btn = UIButton()
        btn.addTarget(self, action: #selector(tapToDismiss), for: .touchUpInside)
        return btn
    }()

    @objc func tapToDismiss() {
        print("tapToDimiss")
        self.dismiss(animated: true, completion: nil)
    }

    let milestonePickerView: MilestonePickerView = {
        let view = MilestonePickerView(frame: .zero)
        return view
    }()

    func setupUI() {

        view.addSubview(blackView)
        view.addSubview(dismissLayerBtn)
        view.addSubview(milestonePickerView) //Important to add the customView after the button.

        blackView.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)

        dismissLayerBtn.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)

        milestonePickerView.anchor(top: nil, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 20, paddingBottom: 40, paddingRight: 20, width: 0, height: 400)

//I'm using a custom extension to setup constraints (anchors) 
   } 

If you're using storyboard, make sure you put the invisible button under the custom view.

I hope this helps.

Piracy answered 28/2, 2018 at 9:12 Comment(0)
K
1

You were on the right track with the UITapGestureRecognizer. Just make sure you implement the shouldRecognizeSimultaneouslyWith as such:

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    return true
}

This should allow the gesture to fire correctly.

Kendo answered 3/7, 2017 at 9:16 Comment(2)
hmm... I've never used that property, that might be why I couldn't figure that out :D I'll try it and I'll feedback youWerewolf
It didn't work. Setting that property somehow the gesture recognizer never fires. It may think that the view is not available to the user since it's not in the top view hierarchy. There should be a way to detect touches anywhere on the screen independently of the presented view, then I'd check if the touch location was above the presented popup then I knew when to dismissWerewolf
S
1

// it works for me

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    var touch: UITouch? = touches.first
    if touch?.view == yourView {
        navigationController?.popViewController(animated: true)
        dismiss(animated: true, completion: nil)
    }
}
Sacring answered 18/6, 2021 at 13:14 Comment(4)
This is not worked, touchesBegan will only detect touches inside current viewController, and clearly OP said it is a viewController but not a view.Cellulosic
could you expalin a little more then i can help youSacring
sure, please see my questionCellulosic
are you want to dismiss that bottom viewSacring

© 2022 - 2024 — McMap. All rights reserved.