UITouchEvent Propagation Revisited

2021, Sep 03    

Touch event propagation and UI responder chain is one of the most frequently visited topics during iOS interviews. As there are some of the most critical concepts in this topic which can prove whether the candidate have a solid understanding regarding event and gesture detections. A summary is made in this article to discuss:

  1. how touch event propagates and gestures get recognized.
  2. how UIResponder is relevant to this process.

Knowledge Required

  • touches, presses and gestures. Touches are a sub type of UIEvent, which happens when user’s finger touches on the device screen. when type == UIEvent.EventType.touches, the event instance will contain a set of unique UITouch instance to describe each of the unique touch point on screen.
  • UIResponder provides responder delegate methods for:
    • Motion events
    • Press events
    • Touch events

Key Points

About the UIEvent propagation:

The overall sequence between Touch event and responder chain happens in the following way. When touch event happens, the event propagates from UIWindow to ViewController and to its root view, further down to children, during the process:

Step 1. The view will invoke the children’s method hitTest and inside of hitTest will further call pointInside to check if the view can respond to the event.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    //系统默认会忽略isUserInteractionEnabled设置为NO、隐藏、alpha小于等于0.01的视图
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

Step 2. If the point is inside the view, it repeats step 1 for its subviews until it finds the deepest eligible view. That single view is the hit-test result (the initial touch target). A parent does not become the target first and then pass the touch down through next.

Hit testing, gesture recognizers, and next are separate mechanisms

It is easy to conflate these three concepts because they often visit the same views in a nested hierarchy. For a tap inside layer 3, nested in layer 2 and layer 1, the order is:

Hit-test path:       UIWindow -> layer 1 -> layer 2 -> layer 3
Hit-test result:                                      layer 3

Recognizers involved:             layer 1, layer 2, and layer 3

Responder chain:                                layer 3 -> layer 2 -> layer 1
                                                 -> view controller -> UIWindow

1. hitTest(_:with:) chooses one initial touch target

UIWindow recursively asks its subviews, frontmost first, to perform hitTest(_:with:). A view is skipped when it is hidden, has alpha <= 0.01, has isUserInteractionEnabled == false, or the point is outside its bounds. In particular, disabling a parent prevents UIKit from inspecting any of that parent’s children.

The recursion stops at the deepest eligible view, so only one view is the initial target for a touch. The same view hierarchy and frames should therefore give the same hit-test result on a simulator and a device. When comparing the two, make the sample hierarchy deterministic and verify the device is running the current build before drawing conclusions from the logs.

2. Gesture recognizers observe the hit-test hierarchy, not next

After hitTest selects the target, the window supplies the touch stream to enabled recognizers attached to that target and its ancestor views. Thus, a recognizer on each of layer 1, layer 2, and layer 3 can participate when layer 3 is tapped. This does not mean every view whose hitTest method happened to run participates: unselected sibling views are excluded. UIKit does not deliver the touch to the parent recognizers by calling layer3.next -> layer2.next.

The recognizers receive the stream before the window delivers it to the hit-tested view. If none recognizes, the target view receives its complete touchesBegan/Moved/Ended sequence. If one recognizes, UIKit keeps the same hit-test target; it does not run hit testing again. Instead, with the default cancelsTouchesInView == true, the target view’s remaining touches are cancelled.

Recognizers are not simply a responder-chain “consumer”. UIKit coordinates their recognition alongside view touch delivery. Setting cancelsTouchesInView to false allows ordinary touchesBegan/Moved/Ended delivery to continue while the recognizer’s action runs.

When several recognizers could recognize the same touch, do not rely on layer depth or action callback order as a priority rule. Express the policy explicitly:

// Prefer the deepest tap: a parent waits for its child to fail.
layer1Tap.require(toFail: layer2Tap)
layer2Tap.require(toFail: layer3Tap)

Alternatively, implement UIGestureRecognizerDelegate and return true from gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) if all applicable recognizers should succeed.

3. next describes the responder chain after hit testing

The responder chain runs upward from the hit-test target. If the target does not handle a touch, UIKit can continue to the target’s next responder (typically its superview, then the view controller and window). Calling super from a touches... override preserves UIKit’s normal responder handling. Overriding next is useful for inspecting or customizing the responder chain, but it neither re-runs hit testing nor delivers a touch to ancestor gesture recognizers.

Customized UIResponderChain:

You can also dispatch a customized action/events via UIApplication, and assign a UIResponder subclass to become the first responder, the event will directly get resolved and dispatch to the responder instance.

final class BlinkableView: UIView {
    override var canBecomeFirstResponder: Bool {
        return true
    }

    func select() {
        becomeFirstResponder()
    }

    @objc func performBlinkAction() {
        //Blinking animation
    }
}

UIApplication.shared.sendAction(#selector(BlinkableView.performBlinkAction), to: nil, from: nil, for: nil)

Source Codes

The source code for the above demo is listed below, you may need to setup a sample project first and then embed the following code and execute:


import UIKit

/// Experimenting the overall touch propagation process in a view hierachys
class UIResponderLabExperimentalView: UILabel {
    
    let label: String
    var isInAnimation: Bool = false
    
    init(_ label: String) {
        self.label = label
        super.init(frame: .zero)
        super.isUserInteractionEnabled = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("current class not support NSCoder")
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("touchesBegan ==> \(label) with event \(event.hashValue)")
        super.touchesBegan(touches, with: event)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("touchesMoved ==> \(label) with event \(event.hashValue)")
        super.touchesMoved(touches, with: event)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("touchesEnded ==> \(label) with event \(event.hashValue)")
        super.touchesEnded(touches, with: event)
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("hitTest ==> \(label) with event \(event.hashValue)")
        return super.hitTest(point, with: event)
    }
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        print("point ==> \(label) with event \(event.hashValue)")
        hightlightStatusChange()
        return super.point(inside: point, with: event)
    }
    
    override var next: UIResponder? {
        get {
            print("next responder ==> \((super.next as? UIResponderLabExperimentalView)?.label ?? "nil"))")
            return super.next
        }
    }
    
    override func drawText(in rect: CGRect) {
        
        var newRect = rect
        let str:NSAttributedString = NSAttributedString.init(string: self.text ?? "", attributes: [NSAttributedString.Key.font: self.font!])
        
        newRect.size.height = str.boundingRect(with: rect.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size.height
        
        if self.numberOfLines != 0 {
            newRect.size.height = min(rect.size.height, CGFloat(self.numberOfLines) * self.font.lineHeight)
        }
        
        super.drawText(in: newRect)
    }
    
    // Show Highlight
    func hightlightStatusChange() {
        DispatchQueue.main.async {
            if self.isInAnimation {
                return
            } else {
                self.isInAnimation = true
            }
                        
            let currentColor = self.layer.backgroundColor
            // based on the layer calibrate the delay to show the event
            // propagation:
            let delay = Int(String.init(self.label.split(separator: "-").first ?? "0")) ?? 0
            UIView.animate(withDuration: 0.4, delay: Double(delay) * 0.2) {
                self.layer.backgroundColor = CGColor.init(red: 0.9,
                                                          green: 0.9,
                                                          blue: 0.9,
                                                          alpha: 0.9)
            } completion: { (finished) in
                if finished {
                    self.layer.backgroundColor = currentColor
                    self.isInAnimation = false
                }
            }
        }
    }
}

class UIResponderLab: BaseExampleController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var currentViews:[UIView] = [self.view]
        for layer in 1...3 {
            var nextRoundViews:[UIView] = []
            for parentView in currentViews {
                nextRoundViews += generateRandomizedViewIn(parentView, "\(layer)")
            }
            
            // for the next layer, iterate and produce subviews based on the current generated views
            currentViews = nextRoundViews
            
            // now if we add guesture recognizer to the last layer of the child view.
            // once a gusture is reconized, the parent touchEvent: `touchesEnd` callback shall not be invoked anymore.
            if layer == 3 {
                for view in currentViews {
                    view.isUserInteractionEnabled = true
                    view.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(touchRecognized(gesture:))))
                }
            }
        }
    }
    
    @objc func touchRecognized(gesture: UITapGestureRecognizer) {
        print("gesture \(gesture.hashValue) has been processed, \((gesture.view as! UIResponderLabExperimentalView).label)")
    }
    
    // render view with a recursive and random events.
    func generateRandomizedViewIn(_ parent: UIView, _ layer: String) -> [UIView] {
        
        // for every layer, let's assume there can be 0...2 subviews
        let numberOfChildren: Int = (1...3).randomElement() ?? 2
        let isHorizontalLayout: Bool = [true, false].randomElement() ?? false
                
        var appendedSubviews: [UIView] = []
        
        for index in 0..<numberOfChildren {
            let label = "\(layer)-\(index)"
            let view = UIResponderLabExperimentalView.init(label)
            parent.addSubview(view)
            
            // ramdonmize color
            view.translatesAutoresizingMaskIntoConstraints = false
            view.layer.backgroundColor = UIColor.init(red: randomColor(),
                                                green: randomColor(),
                                                blue: randomColor(),
                                                alpha: 1.0).cgColor
            view.layer.cornerRadius = 2.0
            view.layer.masksToBounds = true
            view.textAlignment = .left
            view.text = label
            
            // configure layout constraint
            let margin: CGFloat = 12.0
            
            if isHorizontalLayout == true {
                view.topAnchor.constraint(equalTo: parent.safeAreaLayoutGuide.topAnchor, constant: margin).isActive = true
                view.bottomAnchor.constraint(equalTo: parent.safeAreaLayoutGuide.bottomAnchor, constant: -margin).isActive = true
                view.widthAnchor.constraint(equalTo: parent.widthAnchor, multiplier: 1/CGFloat(numberOfChildren), constant: -margin * CGFloat((numberOfChildren+1))/CGFloat(numberOfChildren)).isActive = true
                
                if appendedSubviews.count > 0 {
                    view.leadingAnchor.constraint(equalTo: appendedSubviews.last!.trailingAnchor, constant: margin).isActive = true
                } else {
                    view.leadingAnchor.constraint(equalTo: parent.leadingAnchor, constant: margin).isActive = true
                }
                
            } else {
                view.leadingAnchor.constraint(equalTo: parent.leadingAnchor, constant: margin).isActive = true
                view.trailingAnchor.constraint(equalTo: parent.trailingAnchor, constant: -margin).isActive = true
                view.heightAnchor.constraint(equalTo: parent.safeAreaLayoutGuide.heightAnchor, multiplier: 1/CGFloat(numberOfChildren), constant: -margin * CGFloat((numberOfChildren+1))/CGFloat(numberOfChildren)).isActive = true
                
                if appendedSubviews.count > 0 {
                    view.topAnchor.constraint(equalTo: appendedSubviews.last!.bottomAnchor, constant: margin).isActive = true
                } else {
                    view.topAnchor.constraint(equalTo: parent.safeAreaLayoutGuide.topAnchor, constant: margin).isActive = true
                }
            }
            
            appendedSubviews.append(view)
        }
        
        return appendedSubviews
    }
    
    // generate random color
    func randomColor() -> CGFloat {
        return CGFloat(arc4random()) / CGFloat(UInt32.max)
    }
}

References

TOC