UITouchEvent Propagation Revisited
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:
- how touch event propagates and gestures get recognized.
- 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)
}
}