UIView hanging off edge of superview... tap events seen by *unrelated* underlying view

98 Views Asked by At

I've created a UIView (tooltip box) and added it as a subview of another view.

Using anchor constraints, I've floated it off the edge of its superview where it hovers above an unrelated UIView (which happens to be UITextView)

To the trespassing tooltip view I've added a UIButton and a UISegmentedControl

When I tap either button or segmented control, the text view, which does not own the tooltip view, gets the event, not the button or segmented control.

My question is, how can I make UIButton or UISegmentedControl get their event and not the text view?


UPDATE 1:   (a theory)

I think what's going on has something to do with the fact that the UIView is a UIResponder, however the UITextView is currently the first responder. The view controller creating tooltip view hosts a keyboard accessory view, wherein the keyboard (and thus the accessory view controller and its accessory view) appeared because the text view became first responder.

Do I need to make the tooltip view first responder? That would be a mess, not the least of which because the accessorized keyboard, to which the tooltip is a subview, would go away. Further, the text view constraints change when it stops being first responder, so that it slides down and other views shift in when text view loses first responder status.


UPDATE 2:   (Apple says I'm a bad boy, turns out, I am, and I can prove it)

Checking Apple docs related to reordering responder chain, I found the following explicit note (consider it a warning):

Note:

If a touch location is outside of a view’s bounds, the hitTest(:with:) method ignores that view and all of its subviews. As a result, when a view’s clipsToBounds property is false, subviews outside of that view’s bounds are not returned even if they happen to contain the touch. For more information about the hit-testing behavior, see the discussion of the hitTest(_:with:) method in UIView.

So I tested that:

I created a subclass of UIView and overrode the hitTest() method with print(#function).

I observed two things:

  1. When the tool tip UIView is off the edge of the keyboard accessory UIView:

    A. Buttons aren't responded to

    B. The tool tip view's overridden hittest() isn't called!

  2. When I change constraints to position tooltip on top of the keyboard accessory view, for the buttons that do overlap the accessory view (e.g. first-responder/superview) bounds, hittest() is called, and the buttons work as expected. Buttons in the part of the tooltip view still hanging over the edge of the keyboard accessory don't feel the touch, nor is hittest() called).. the underlying text view gets them.

Conclusion: Apple's explicit note describes my problem.


UPDATE 3    (Will gesture recognizers work?)

I don't want my interactive tooltip above the keyboard accessory.

Based on the fact that gesture recognizers are handled before UIView event handling is invoked, I wonder if the solution is to add (otherwise redundant) tap gesture recognizers to the UIControls (button & seg control), knowing their parent view is out of bounds of the responder chain?

So my new question is: How can I make a UIView that's way over the edge of a non-clipped receiver UIView have its buttons work?

1

There are 1 best solutions below

1
DonMag On

We can implement hitTest() to allow touches outside the bounds of the view to be used.

But, we need to check for the view that was touched.

So, you said you have a subview (I'll call it tipView) that contains a button and a segmented control as subviews, and you're adding that tipView as a subview of a UITextView positioned outside the bounds of the text view.

An example could be:

class ToolTipTextView: UITextView {

    var tipView: UIView!
    var btn: UIButton!
    var seg: UISegmentedControl!

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit(){
        
        // we need to see the view outside our bounds
        self.clipsToBounds = false
        
        tipView = UIView()
        tipView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)

        btn = UIButton()
        btn.setTitle("Tap Me", for: [])
        btn.setTitleColor(.white, for: .normal)
        btn.setTitleColor(.lightGray, for: .highlighted)
        btn.backgroundColor = .systemBlue
        
        seg = UISegmentedControl(items: ["One", "Two", "Three"])
        
        [tipView, btn, seg].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }

        tipView.addSubview(btn)
        tipView.addSubview(seg)
        self.addSubview(tipView)

        NSLayoutConstraint.activate([
            // button at top of tipView, centerX
            btn.topAnchor.constraint(equalTo: tipView.topAnchor, constant: 4.0),
            btn.centerXAnchor.constraint(equalTo: tipView.centerXAnchor),
            
            // seg control below button
            seg.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 4.0),
            seg.bottomAnchor.constraint(equalTo: tipView.bottomAnchor, constant: -4.0),
            seg.leadingAnchor.constraint(equalTo: tipView.leadingAnchor, constant: 4.0),
            seg.trailingAnchor.constraint(equalTo: tipView.trailingAnchor, constant: -4.0),

            // tipView above self, centerX
            tipView.bottomAnchor.constraint(equalTo: self.topAnchor, constant: -4.0),
            tipView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
        ])
        
        // targets for btn and seg
        btn.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
        seg.addTarget(self, action: #selector(segSelected(_:)), for: .valueChanged)
        
    }
    
    @objc func btnTapped(_ sender: Any?) {
        let str = self.text ?? ""
        self.text = str + " Button Tapped! "
    }
    @objc func segSelected(_ sender: Any?) {
        guard let ctrl = sender as? UISegmentedControl,
              let t = ctrl.titleForSegment(at: ctrl.selectedSegmentIndex)
        else { return }
        let str = self.text ?? ""
        self.text = str + " Segment \(t) Selected! "
    }
    
}

class TipTestVC: UIViewController {
    
    let myView = ToolTipTextView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        myView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(myView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            myView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            myView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            myView.heightAnchor.constraint(equalToConstant: 160.0),
            myView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        myView.font = .systemFont(ofSize: 20.0, weight: .light)
        myView.text = "This is text in the text view."

        // so we can see the text view frame
        myView.backgroundColor = .green
        
    }
    
}

and it will look like this at run-time:

enter image description here

Assuming that is close to what you're going for...

As you've seen, you cannot tap the button or the segmented control.

So, we need to add the hitTest() to our custom ToolTipTextView class:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

    // convert the touch to the tipView coordinate space
    let cvtPoint = self.convert(point, to: tipView)

    if btn.frame.contains(cvtPoint) {
        return btn
    }
    
    if seg.frame.contains(cvtPoint) {
        return seg
    }

    return super.hitTest(point, with: event)

}

Because the view, button and seg control are not inside the view's bounds, tapping the button will result in a touch-point relative to the text view -- tapping the button may give you this point: (167.0, -59.5).

So, we convert that point to the tipView coordinate space, and see if it is contained in either the button or seg control's frame. If so, we return that view.

If not, we call super to let UIKit handle the touch... either activating the text view if the touch is inside, or allowing the touch to reach some other UI element on the screen.