Search code examples
swiftpopupuitapgesturerecognizer

Close a popup view when tapping anywhere outside it, including buttons, textfields, etc


Question EDITED since it seems people were confused...

See my code below and watch the attached "video" of what is happening. The popup closes:

  • when the user taps on a button selection within the popup
  • when the user taps outside the popup, anywhere on the parent view (thus closing the popup without making a selection from the popup)

This is the behavior I want. BUT the popup does NOT close if the user taps on a button or textfield in the parent view. Consequently, the popup remains popped up in that situation.

How do I detect a tap gesture anywhere OUTSIDE the popup, including buttons, textfields, and any other UI elements that already have their own tap handlers, so that I can close the popup without hijacking the behaviors of those tap handlers?

import UIKit

class ViewController: UIViewController, UIGestureRecognizerDelegate, UITextFieldDelegate {
    
    var popup: UIView!
    var label: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .darkGray
        
        let textfield = UITextField()
        textfield.backgroundColor = .white
        textfield.translatesAutoresizingMaskIntoConstraints = false
        textfield.placeholder = "some text"
        view.addSubview(textfield)
        
        let button1 = UIButton()
        button1.setTitle("Button", for: .normal)
        button1.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        button1.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button1)
        
        let button2 = UIButton()
        button2.setTitle("Show Popup", for: .normal)
        button2.addTarget(self, action: #selector(popupButtonTapped), for: .touchUpInside)
        button2.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button2)
        
        label = UILabel()
        label.textColor = .yellow
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)

        NSLayoutConstraint.activate([
            textfield.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            textfield.topAnchor.constraint(equalTo: view.topAnchor, constant: 160),
            textfield.widthAnchor.constraint(equalToConstant: 299),
            textfield.heightAnchor.constraint(equalToConstant: 30),
            button1.topAnchor.constraint(equalTo: textfield.bottomAnchor, constant: 40),
            button1.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button2.topAnchor.constraint(equalTo: button1.bottomAnchor, constant: 40),
            button2.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            //label.topAnchor.constraint(equalTo: button2.bottomAnchor),
            label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -260),
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
        
        textfield.delegate = self
        let viewTapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped))
        viewTapGesture.delegate = self
        view.addGestureRecognizer(viewTapGesture)
    }
    
    @objc func viewTapped(gestureRecognizer: UITapGestureRecognizer) {
        popup?.isHidden = true
    }

    @objc func buttonTapped(_ button: UIButton) {
        label.text = "Button tapped!"
    }
    
    @objc func popupButtonTapped(_ button: UIButton) {
        if popup == nil {
            popup = UIView()
            popup.backgroundColor = #colorLiteral(red: 1, green: 0.9175537825, blue: 0.79708004, alpha: 1)
            popup.layer.borderWidth = 1
            popup.layer.borderColor = UIColor.black.cgColor
            popup.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(popup)
            
            let stackview = UIStackView()
            stackview.axis = .vertical
            stackview.alignment = .fill
            stackview.distribution = .fillEqually
            stackview.translatesAutoresizingMaskIntoConstraints = false
            popup.addSubview(stackview)
            
            for i in 1...5 {
                let button = UIButton()
                button.setTitle("Selection \(i)", for: .normal)
                button.setTitleColor(.black, for: .normal)
                button.widthAnchor.constraint(equalToConstant: 120).isActive = true
                button.addTarget(self, action: #selector(popupItemTapped), for: .touchUpInside)
                stackview.addArrangedSubview(button)
            }
            
            NSLayoutConstraint.activate([
                stackview.topAnchor.constraint(equalTo: popup.topAnchor),
                stackview.leadingAnchor.constraint(equalTo: popup.leadingAnchor),
                stackview.trailingAnchor.constraint(equalTo: popup.trailingAnchor),
                stackview.bottomAnchor.constraint(equalTo: popup.bottomAnchor),
                popup.topAnchor.constraint(equalTo: button.topAnchor),
                popup.leadingAnchor.constraint(equalTo: button.leadingAnchor),
            ])
        } else {
            popup.isHidden = false
        }
    }
    
    @objc func popupItemTapped(_ button: UIButton) {
        label.text = "\(button.currentTitle!) tapped!"
        popup.isHidden = true
    }
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        label.text = "You typed:   \(textField.text!)"
        return true
    }

}

Here's what it looks like in action. Note that the popup closes when you tap inside it to make a selection. It also closes when you tap outside it, but NOT when you tap in the textfield or tap on "Button". I want the popup to close when you tap anywhere outside it, even inside the textfield or "Button". And if you close it by tapping inside the textfield or on "Button", they should continue to respond as usual.

Tap gesture issue demo


Solution

  • After much fiddling around, I finally found a way to close/hide the popup any time and anywhere the tap occurs and still let whatever UI element anywhere on the screen (including the popup itself) that got the tap do its thing. If the tap is inside the popup, the popup selection is made and the popup closes. If the tap is anywhere outside the popup on the parent view, the popup closes. If the tap is made on any other UI element on the screen, even if there were a thousand of them on the screen, the popup also closes but the UI element still responds to the tap as usual.

    We need to set up the UITapGestureRecognizer, but instead of having a #selector function, we let gestureRecognizer(_:shouldReceive:) do the work, so we set action: nil.

    // Setup TapGestureRecognizer. The ACTION PARAMETER IS NIL since we do not need a
    // selector function. We'll let gestureRecognizer(_:shouldReceive:) do the work.
    // But we MUST at least register the gesture recognizer's delegate with the view!
    let viewTapGesture = UITapGestureRecognizer(target: self, action: nil)
    viewTapGesture.delegate = self
    view.addGestureRecognizer(viewTapGesture)
    

    and

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        if !popup.isHidden {
            popup.isHidden = true
        }
        return false
    }
    

    Here's the full (revised) code:

    import UIKit
    
    class ViewController: UIViewController, UIGestureRecognizerDelegate, UITextFieldDelegate {
        
        var popup: UIView!
        var label: UILabel!
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .darkGray
            
            let textfield = UITextField()
            textfield.backgroundColor = .white
            textfield.translatesAutoresizingMaskIntoConstraints = false
            textfield.placeholder = "some text"
            view.addSubview(textfield)
            
            let button1 = UIButton()
            button1.setTitle("Button", for: .normal)
            button1.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
            button1.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button1)
            
            let button2 = UIButton()
            button2.setTitle("Show Popup", for: .normal)
            button2.addTarget(self, action: #selector(popupButtonTapped), for: .touchUpInside)
            button2.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button2)
            
            label = UILabel()
            label.textColor = .yellow
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
    
            NSLayoutConstraint.activate([
                textfield.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                textfield.topAnchor.constraint(equalTo: view.topAnchor, constant: 160),
                textfield.widthAnchor.constraint(equalToConstant: 299),
                textfield.heightAnchor.constraint(equalToConstant: 30),
                button1.topAnchor.constraint(equalTo: textfield.bottomAnchor, constant: 40),
                button1.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                button2.topAnchor.constraint(equalTo: button1.bottomAnchor, constant: 40),
                button2.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -260),
                label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
            ])
            
            popup = UIView()
            popup.backgroundColor = #colorLiteral(red: 1, green: 0.9175537825, blue: 0.79708004, alpha: 1)
            popup.layer.borderWidth = 1
            popup.layer.borderColor = UIColor.black.cgColor
            popup.translatesAutoresizingMaskIntoConstraints = false
            popup.isHidden = true
            view.addSubview(popup)
    
            let stackview = UIStackView()
            stackview.axis = .vertical
            stackview.alignment = .fill
            stackview.distribution = .fillEqually
            stackview.translatesAutoresizingMaskIntoConstraints = false
            popup.addSubview(stackview)
    
            for i in 1...5 {
                let button = UIButton()
                button.setTitle("Selection \(i)", for: .normal)
                button.setTitleColor(.black, for: .normal)
                button.widthAnchor.constraint(equalToConstant: 120).isActive = true
                button.addTarget(self, action: #selector(popupItemTapped), for: .touchUpInside)
                stackview.addArrangedSubview(button)
            }
    
            NSLayoutConstraint.activate([
                stackview.topAnchor.constraint(equalTo: popup.topAnchor),
                stackview.leadingAnchor.constraint(equalTo: popup.leadingAnchor),
                stackview.trailingAnchor.constraint(equalTo: popup.trailingAnchor),
                stackview.bottomAnchor.constraint(equalTo: popup.bottomAnchor),
                popup.topAnchor.constraint(equalTo: button2.topAnchor),
                popup.leadingAnchor.constraint(equalTo: button2.leadingAnchor),
            ])
            
            textfield.delegate = self
            
            // Setup TapGestureRecognizer. The ACTION PARAMETER IS NIL since we do not need a
            // selector function. We'll let gestureRecognizer(_:shouldReceive:) do the work.
            // But we MUST at least register the gesture recognizer's delegate with the view!
            let viewTapGesture = UITapGestureRecognizer(target: self, action: nil)
            viewTapGesture.delegate = self
            view.addGestureRecognizer(viewTapGesture)
    
        }
        
        @objc func buttonTapped(_ button: UIButton) {
            label.text = "Button tapped!"
        }
        
        @objc func popupButtonTapped(_ button: UIButton) {
            popup.isHidden = false
        }
        
        @objc func popupItemTapped(_ button: UIButton) {
            label.text = "\(button.currentTitle!) tapped!"
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            label.text = "You typed:   \(textField.text!)"
            return true
        }
        
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
            if !popup.isHidden {
                popup.isHidden = true
            }
            return false
        }
    }
    

    Problem fixed