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 points fall in the view, the view will repeat step 1 to its subviews. until there are no more subViews for the view. if the view handles the event via a gesture recognizer, the event is consumed. if the view does not handle the event properly, the event will bubble up in from the view to its parent view and eventually to the window object. If in the middle there is any object can consume the event, just return nil to the nextResponder and handle the event.

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