iOS - 事件的响应链和传递链

UIResponder(响应对象)

An abstract interface for responding to and handling events.

一个UIResponder类为那些需要响应并处理事件的对象定义了一组接口。在iOS中不是任何对象都能处理事件, 只有继承了UIResponder的对象才能接收并处理事件,称为响应者对象。UIApplicationUIViewControllerUIView都继承自UIResponder,因此他们都是响应者对象,,都能够接收并处理事件。这意味着所有的视图(all views)和大多数的关键视图控制器对象都是响应者。但是要注意核心动画中的层(layer)不是响应者。

继承自UIResponder的类能处理事件是由于UIResponder内部提供了以下方法:

// 触摸事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);

// 按压事件
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);

// 传感器事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);

// 远程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);

分发机制(Hit-Testing)

iOS使用hit-testing来找到触摸点所在的视图。hit-testing将会检测是否触摸事件在相关视图的显示区域之内。如果在,将递归检测当前视图的所有子视图。视图层级中最底层的view如果包含触摸点将成为hit-test view。在iOS确认了hit-test view之后,将传递触摸事件给对应的视图进行处理。

为了解释上面,下面看一下官方例子:用户触摸视图view EiOS将有序查找子视图,找到hit-test view

1:由下图可知,触摸位于视图A的区域之内,所以会对B,C进行检测
2:如果触摸事件不在视图B区域中,但位于视图区域C,将对视图C的子视图D,E进行检测
3:如果触摸事件不在视图D区域中,但位于视图区域E中,又因为视图E是视图层级结构中最底层的视图,所以视图E将成为hit-test view

Hit-testing returns the subview that was touched

20160719093700200.png

hitTest:withEvent:方法会根据给定触摸点(CGPoint)和事件对象(UIEvent)两个参数,返回点击的视图(hit test view)

1、该方法首先会调用pointInside:withEvent:方法,如果hitTest:withEvent:方法中所传递的参数point点是位于视图之内,pointInside:withEvent:方法将返回true,然后,在返回true的所有子视图上将递归调用hitTest:withEvent:方法。

2、如果hitTest:withEvent:方法中传递的point点不在视图显示区域之内,第一次调用pointInside:withEvent:方法将返回false,那么该点将被忽略,hitTest:withEvent:方法将返回nil。如果一个子视图返回false,那么整个视图层级都将被忽略,因为触摸并不在子视图当中,所以子视图的子视图同样也不会发生触摸事件。

总结hitTest处理流程

调用当前viewpointInside:withEvent:方法来判定触摸点是否在当前view内部,如果返回false,则hitTest:withEvent:返回nil;如果返回true,则向当前view内的subViews发送hitTest:withEvent:消息,所有subView的遍历顺序是从数组的末尾向前遍历,直到有subView返回非空对象或遍历完成。如果有subView返回非空对象,hitTest方法会返回这个对象,如果每个subView返回都是nil,则返回自己。

注意:

hitTest:withEvent:方法忽略隐藏(hidden=YES)的视图,禁止用户操作(userInteractionEnabled=YES)的视图,以及alpha级别小于0.01(alpha<0.01)的视图。

hit-test view将首先处理触摸事件,如果hit-test view并不能够处理事件,那么该事件将由视图的响应者链进行查找,一直到系统找到能够处理事件的对象。

事件响应者链

许多类型的事件都依赖于响应者链进行事件的传递。响应者链关联着一系列的响应者对象,由第一个响应者对象开始一直到application对象结束,如果第一个响应者不能够处理事件,事件将会被传递到响应者链中的下一个响应者对象。

第一响应者首先接收事件。代表性的就是:视图是第一响应者对象。一个对象要成为第一响应者需要做两件事:

  • 1、重写canBecomeFirstResponder方法,返回true,接收成为第一响应者信息
  • 2、以及becomeFirstResponder方法,如果有必要,对象能够自己给自己发送信息

注意:

在对象被赋值成为第一响应者之前,确保APP已经建立的对象图形(object graph)。例如:我们可以在viewDidAppear:方法中调用becomeFirstResponder方法,但是,如果我们尝试viewWillAppear:中赋值第一响应者,我们的对象图形可能还没有建立,所以becomeFirstResponder方法将返回false。

响应者链遵守事件传递的具体路径

如果最初的对象hit-test视图或者第一响应者(first responder)不能够处理事件,UIKit将传递事件到响应者链中的下一个响应者。每一个响应者都会决定是否处理事件还是调用nextResponder方法将事件传递给下级响应者。该过程一直到有一个响应者对象能够处理事件或者没有下级响应者为止。

下图显示了两个APP配置下2种不同事件类型的路径传递,事件传递路径取决于具体的结构,所有的事件传递都遵守相同的起始。

image.png

左边App事件所传递的路径

1、初始视图(initial view)将尝试着处理事件或消息。如果它不能处理事件,将传递事件到自己的父视图(superview),因为初始视图并不是它所在视图控制器中视图层级的最顶部视图
2、父视图(superview)将尝试处理所传递的事件,如果父视图不能够处理事件,该事件将传递到它自己的父视图,因为它仍然不是视图层级的最顶部视图
3、视图控制器视图层级中最顶部视图(topmost view)将尝试处理所传递的事件,如果最顶部视图不能够处理事件,它将传递事件给它的视图控制器
4、视图控制器(view controller)将尝试处理所传递事件,如果它不能够处理事件,该事件将被传递到window
5、如果window对象不能够处理事件,它将传递事件到APP全局单例对象(singleton app object).
6、如果app对象不能够处理事件,该事件将被放弃

右边App事件的传递流程

1、视图传递事件到它所在的视图控制器的视图层级中,一直到最顶部视图。
2、最顶部视图将传递事件到它的视图控制器
3、视图控制器将传递事件到它的最顶部视图的父视图。1~3步重复,直到找到根控制器
4、根视图控制器将传递事件到window对象
5、window对象将传递事件到app对象

唯一不同就在于,如果当前的ViewController是有层级关系的,那么当子ViewController不能处理事件时,它会将事件继续往上传递,直到传递到其Root ViewController,其他流程是一样的。

事件的传递和响应链

传递链

由系统向离用户最近的view传递 UIKit –> active app’s event queue –> window –> root view –>……–>lowest view

响应链

由离用户最近的view向系统传递。initial view –> super view –> …..–> view controller –> window –> Application

hitTest:withEvent:的使用

  • 1、增加视图的触摸区域

比如:按钮本身大小为20和20,由于太小不方便操作,所以可以通过自定义UIButton,重写hitTest方法,增加点击区域。下面实现每个方向增加40的可点击区域,具体实现代码:

class MyButton: UIButton {
    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
            return nil
        }
        
        // 扩大点击区域
        if CGRectContainsPoint(CGRectInset(self.bounds, -40, -40), point) {
            for subview in self.subviews.reverse() {
                let convertPoint = subview.convertPoint(point, fromView:self)
                if let sview = subview.hitTest(convertPoint, withEvent: event) {
                    return sview
                }
            }
            return self
        }
        return nil
    }
}

hitTest:withEvent:方法首先检查视图是否允许接收触摸事件。视图允许接收触摸事件的条件是:

  • 视图不是隐藏的:self.hidden == NO
  • 视图是允许交互的:self.userInteractionEnabled ==true
  • 视图透明度大于:0.01:self.alpha >0.01
  • 视图包含这个点: pointInside:withEvent: ==true

如果视图允许接收触摸事件,这个方法通过从后往前发送hitTest:withEvent:消息给每一个子视图来穿过接收者的子树,直到子视图中的一个返回nil。这些子视图中的第一个返回的非nil就是在触摸点下面的最前面的视图,被接收者返回。如果所有的子视图都返回nil或者接收者没有子视图,那么返回接收者自己。否则,如果视图不允许接收触摸事件,这个方法返回nil而根本不会传递到接收者的子树。因此,hit-test可能不会访问所有的视图体系结构中的视图。

测试功能

  func testExpandButtonClickArea(){
        //为了便于观察,添加一个背景视图,大小正好为100*100
        let backgroundView = UIView(frame:  CGRect(x: 60, y: 160, width: 100, height: 100))
        backgroundView.backgroundColor = UIColor.purpleColor()
        view.addSubview(backgroundView)
        
        let btn = MyButton(type: .Custom)
        btn.frame = CGRect(x: 100, y: 200, width: 20, height: 20)
        btn.backgroundColor = UIColor.redColor()
        btn.setTitle("btn", forState: .Normal)
        btn.addTarget(self, action: #selector(UIButtonViewController.tapButton), forControlEvents: .TouchUpInside)
        view.addSubview(btn)
    }
    
    func tapButton(){
        print("button has been pressed!");
    }

点击紫色区域内容,同样可以响应点击事件,可以在console看到打印输出:button has been pressed!

20160719095223943.png
  • 2、实现传递事件到点击视图之下的视图

有的时候对于一个视图忽略触摸事件并传递给下面的视图是很重要的。例如,假设一个透明的视图覆盖在应用内所有视图的最上面。覆盖层有子视图应该相应触摸事件的一些控件和按钮。但是触摸覆盖层的其他区域应该传递给覆盖层下面的视图。为了完成这个行为,覆盖层需要覆盖hitTest:withEvent:方法来返回包含触摸点的子视图中的一个,然后其他情况返回nil,包括覆盖层包含触摸点的情况:

class SHView: UIView {
    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        var hitTestView = super.hitTest(point, withEvent:event)
        if hitTestView == self{
           hitTestView = nil
        }
        return hitTestView;
    }
}

测试部分代码:

func testCoverView(){
        let btn1 = UIButton(type: .Custom)
        btn1.frame = CGRect(x: 80, y: 200, width: 20, height: 20)
        btn1.backgroundColor = UIColor.redColor()
        btn1.setTitle("btn1", forState: .Normal)
        btn1.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
        view.addSubview(btn1)
        
        let btn2 = UIButton(type: .Custom)
        btn2.frame = CGRect(x: 120, y: 200, width: 20, height: 20)
        btn2.backgroundColor = UIColor.yellowColor()
        btn2.setTitle("btn2", forState: .Normal)
        btn2.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
        view.addSubview(btn2)
        
        //添加一个覆盖层
        let backgroundView = SHView(frame:  CGRect(x: 60, y: 160, width: 100, height: 100))
        backgroundView.backgroundColor = UIColor.purpleColor()
        backgroundView.alpha = 0.75;
        view.addSubview(backgroundView)
    }
 
    func tapButton(button:UIButton){
        print("button = %@,title = %@",button,button.currentTitle);
    }

当点击覆盖层的时候,如果点击的位置属于对应的按钮的区域,将响应对应的触发事件,点击btn1将打印按钮1的相关信息,点击按钮2将打印按钮2的相关信息。页面效果如下:

屏幕快照 2019-02-22 下午5.28.33.png
  • 3、超出父视图区域部分响应事件

首先看一下页面效果:当前页面上有3个控件,紫色视图是红色视图的子视图,红色视图是灰色视图的子视图。最上面是一个按钮,方便我们进行测试:现在我们要实现点击红色视图之外的紫色区域能够响应事件。

20160719103214623.png

实现代码:自定义TestView实现hitTest方法,并调用我们对UIView的扩展方法

class TestView: UIView {
    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        super.hitTest(point, withEvent: event)
        return overlapHitTest(point, withEvent: event)
    }
}
 
extension UIView{
    func overlapHitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        // We should not send touch events for hidden or transparent views, or views with userInteractionEnabled set to NO;
        if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
            return nil
        }
        // If touch is inside self, self will be considered as potential result.
        var hitView: UIView? = self
        if !self.pointInside(point, withEvent: event) {
            if self.clipsToBounds {
                return nil
            } else {
                hitView = nil
            }
        }
        // Check recursively all subviews for hit. If any, return it.
        for subview in self.subviews.reverse() {
            let insideSubview = self.convertPoint(point, toView: subview)
            if let sview = subview.overlapHitTest(insideSubview, withEvent: event) {
                return sview
            }
        }
        // Else return self or nil depending on result from step 2.
        return hitView
    }
}

测试部分代码:

 func testOverSuperview(){
        
        let view1 = TestView(frame:CGRect(x: 100, y: 100, width: 200, height: 200))
        view1.backgroundColor = UIColor.lightGrayColor()
        view.addSubview(view1)
        
        let view2 = UIView(frame: CGRect(x: 40, y: 40, width: 100, height: 100))
        view2.backgroundColor = UIColor.redColor()
        view1.addSubview(view2)
        
        let view3 = UIButton(type: .Custom)
        view3.frame = (frame: CGRect(x: 10, y: 10, width: 200, height: 80))
        view3.backgroundColor = UIColor.purpleColor()
        view3.addTarget(self, action: #selector(ThirdViewController.tapButton), forControlEvents: .TouchUpInside)
        view2.addSubview(view3)
    }
    
    func tapButton(){
        print("button has been pressed!");
    }

参考

UIResponder

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容