iOS 触摸事件的探索

iOS屏幕触摸事件的处理对于APP来说是很重要的,如果我们只了解监听UIControl类的点击事件或者手势事件的话, 我们只能做简单的点击响应处理, 对于用户体验有较高的要求时就解决不了,比如饼状图点击区域、扩大小按钮的响应区域、UIScrollView与右滑返回手势冲突的问题.

一、View上的触摸事件

UIView继承于UIResponder, 对于每个视图都能通过链式调用nextResponder找到一条响应者链,这个是视图添加到控制器窗口时就已经存在了,如下是一个典型的响应者链。

响应者链

触摸事件分为查找最佳响应用户触摸点视图的过程响应事件沿响应者链传递的过程

1. 查找最佳响应用户触摸点视图的过程

系统基于Port的进程间通信交给当前的Application -> 从可见的最顶层的UIWindow开始遍历内部window -> window内部UIView通过- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;这个方法来返回最佳响应者。

1.1 hitTest方法的默认实现
//作用:去寻找最适合的View
//什么时候调用:当一个事件传递给当前View,就会调用.
//返回值:返回的是谁,谁就是最适合的View(就会调用最适合的View的touch方法)
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
   //1.判断自己能否接收事件
    if(self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
        return nil;
    }
    //2.判断当前点在不在当前View.
    if (![self pointInside:point withEvent:event]) {
        return nil;
    }
    //3.从后往前遍历自己的子控件.让子控件重复前两步操作,(把事件传递给,让子控件调用hitTest)
    int count = (int)self.subviews.count;
    for (int i = count - 1; i >= 0; i--) {
        //取出每一个子控件
        UIView *chileV =  self.subviews[i];
        //把当前的点转换成子控件坐标系上的点.
        CGPoint childP = [self convertPoint:point toView:chileV];
        UIView *fitView = [chileV hitTest:childP withEvent:event];
        //判断有没有找到最适合的View
        if(fitView){
            return fitView;
        }
    }
    
    //4.没有找到比它自己更适合的View.那么它自己就是最适合的View
    return self;
}
1.2. 扩大响应区域的问题

比如当我们列表中有个小的收藏按钮,要扩大响应区域,我们基于上面的了解,我们可以这样做,在收藏按钮的父视图重写-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event, 如果点击的point在point外加扩大的区域内,则将收藏按钮上的center赋值给point,再调用[super hitTest:point.....]即可.

1.3 写demo调试查看寻找最佳响应者的过程

在控制器的view中添加一个自定义view,在自定义view的-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法中打断点。查看函数调用情况如下:

hitTest方法调用前函数调用情况.png

能看出的是:首先点击传递到主线程时是一个souce0事件,接下来会找到最适合window(怎么找到的这个暂时不知道,系统调用的是私有方法_),接下来window让内部的view调用-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event.

1.4 hitTest:方法会调用两遍是为什么?

Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine. 翻译为:

是的,这很正常。系统可能会在两次呼叫之间调整被命中测试的点。因为hitTest应该是一个没有副作用的纯函数,所以这应该很好。

2. 沿响应者链传递的过程

在找到了最合适的view之后,我们通过在- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event中打断点查看函数调用情况:

找到最合适响应view之后的sendEvent.png

可以看到是:
[UIApplication sendEvent:] -> [UIWindow sendEvent:] -> [UIWindow _sendTouchesForEvent:] -> [ZLView touchesBegan:withEvent:] -> 如果ZLView的touchesBegan:withEvent:内有调用super -> ZLView的nextResponder调用touchesBegan:withEvent:] -> nextResponder...依次类推
我们在view中的touchesBan方法不调用super,就拦截掉了之后的事件传递(不会拦截掉手势的),即他的nextResponder之后都收不到下面4个方法的调用了:

- (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;
二、如果是有手势添加在view上
2.0 触摸事件在手势识别上是怎么发生的?

我们新建一个UITapGestureRecognizer子类ZLTestGesure,重写里面的touchesBegan的那四个方法。
接下来创建demo:控制器view添加红色view,红色view添加绿色view,红色view添加手势.

demo的情况.png

点击绿色view,查看打印情况:

-[ZLView hitTest:withEvent:]
-[ZLGreenView hitTest:withEvent:]
-[ZLView hitTest:withEvent:]
-[ZLGreenView hitTest:withEvent:]
-[ZLTestGesure touchesBegan:withEvent:]
-[ZLGreenView touchesBegan:withEvent:]
-[ZLView touchesBegan:withEvent:]
-[ViewController touchesBegan:withEvent:]
-[ZLTestGesure touchesEnded:withEvent:]
-[ZLView tap:]
-[ZLGreenView touchesCancelled:withEvent:]
-[ZLView touchesCancelled:withEvent:]
-[ViewController touchesCancelled:withEvent:]

可以看到:

1.手势的touchesBegan:withEvent:方法是优先于所有view调用的(UIControl的优先级会更高,这里说的是UIView),我们点击point在绿色view,而绿色view是红色view的subview,手势是添加在红色view上的, 我们的打印结果是手势的touchesBegan优先于子View上的。
2.手势识别到之后view上触摸会取消。

接下来我们在绿色view也添加手势的tap:方法打断点:

系统识别到了手势事件.png

2.1 手势发生时,手势怎么调用到target-action这一步

手势初始化都有对应的target-action,在添加在view上后,触摸事件的查找最合适响应view的过程不受影响,而是在识别到一个手势时:
1.找到手势初始化对应的target-action执行。
2.让最合适响应view调用touchesCancelled:withEvent: ,view不再处理这个触摸事件了。
3.如果view上的子view上有添加相同的手势,则子view上的手势会优先识别出;如果是不同的手势,那就看这次触摸优先被哪个手势识别到,与父视图子视图层级无关,手势识别出来后,对于这次的触摸系统不会再去识别手势(默认情况)

2.2 如何控制多个手势识别之间的关系

可以给手势设置代理,通过代理方法来控制:

// 是否允许多个手势同时进行识别,返回YES时,多个手势事件的识别互不干扰
// 默认是NO的
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;

// 当自己手势进行识别时是否让别的手势失败,返回YES时,只识别自己的手势
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;

// 当有其它手势识别时,自己的手势识别是否要设置为失败。返回YES是当有别的手势,自己就失败
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
2.3 UIScrollView上的手势

UIScrollView内部默认封装了两个手势 pan和pinch和两个重要的属性delaysContentTouchescanCancelContentTouches
delaysContentTouches:默认值为YES;如果设置为NO,则无论手指移动的多么快,始终都会将触摸事件传递给内部控件;设置为NO可能会影响到UIScrollView的滚动功能。
canCancelContentTouches:如果属性值为YES并且跟踪到手指正触摸到一个内容控件,这时如果用户拖动手指的距离足够产生滚动,那么内容控件将收到一个touchesCancelled:withEvent:消息,而scrollview将这次触摸作为滚动来处理。如果值为NO,一旦contentview开始跟踪(tracking==YES),则无论手指是否移动,scrollView都不会滚动。

2.4 我们可以创建自定义手势

继承自UIGestureRecognizer,详情查看API之UIGestureRecognizer及自定义手势

三、如果有UIControl情况下,事件的传递会有什么不同?
3.1 UIControl的作用

UIControl是继承于UIView的,相比UIView,UIControl能识别特定的触摸事件,使我们能对特定的事件比如UIControlEventTouchUpInside添加Target-action; 首先我们知道,如果手势添加在UIControl上,那么手势会优先识别出,识别后会打断UIControl的事件传递,下面我们来看手势是添加在父视图上的情况。
测试demo:我把上述的绿色view换成一个继承于UIControl的蓝色view, 并且添加所有UIControl特定事件识别的监听, 手势是添加在父视图上的时候。

[blueView addTarget:self action:@selector(btnClick:events:) forControlEvents:UIControlEventAllEvents];

- (void)btnClick:(UIButton *)sender events:(UIControlEvents)controlEvents{
    if (controlEvents & UIControlEventTouchDown) {
        NSLog(@"监听到了UIControlEventTouchDown事件");
    }else if (controlEvents & UIControlEventTouchDownRepeat) {
        NSLog(@"监听到了UIControlEventTouchDownRepeat事件");
    }else if (controlEvents & UIControlEventTouchDragInside) {
        NSLog(@"监听到了UIControlEventTouchDragInside事件");
    }else if (controlEvents & UIControlEventTouchDragOutside) {
        NSLog(@"监听到了UIControlEventTouchDragOutside事件");
    }else if (controlEvents & UIControlEventTouchDragEnter) {
        NSLog(@"监听到了UIControlEventTouchDragEnter事件");
    }else if (controlEvents & UIControlEventTouchDragExit) {
        NSLog(@"监听到了UIControlEventTouchDragExit事件");
    }else if (controlEvents & UIControlEventTouchUpInside) {
        NSLog(@"监听到了UIControlEventTouchUpInside事件");
    }else if (controlEvents & UIControlEventTouchUpOutside) {
        NSLog(@"监听到了UIControlEventTouchUpOutside事件");
    }else if (controlEvents & UIControlEventTouchCancel) {
        NSLog(@"监听到了UIControlEventTouchCancel事件");
    }
}
UIContol的事件识别测试.png

点击蓝色的UIControl后看打印效果:

-[ZLBlueView hitTest:withEvent:]
-[ZLView hitTest:withEvent:]
-[ZLBlueView hitTest:withEvent:]
-[ZLTestGesure touchesBegan:withEvent:]
-[ZLBlueView touchesBegan:withEvent:]
监听到了UIControlEventTouchUpOutside事件
-[ZLTestGesure touchesEnded:withEvent:]
-[ZLView tap:]
-[ZLBlueView touchesCancelled:withEvent:]
监听到了UIControlEventTouchUpOutside事件

由上面看出,对于UIControl识别出了UIControlEventTouch事件并没有打断手势的识别。手势识别出来的话是会打断UIControl的事件传递的。

3.2 UIControl识别出了UIControlEventTouch事件后的方法调用
UIControl事件识别后的发送.png

-> UIControl识别出一个特定的触摸事件
-> UIControl调用- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event方法发送给UIApplication处理
-> UIApplication调用内部- (BOOL)sendAction:(SEL)action to:(id)target from:(id)sender forEvent:(UIEvent *)event,让事件的target调用对应的action。
-> taget对象收到方法调用。
从这里可以知道:手势识别到之后不会发送给UIApplication, 而是直接让target调用对应的action,这是与UIControl不同的地方.
所以我们如果是埋点事件是在自定义UIApplication类里面做的话,我们是收集不到手势点击事件的。

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

推荐阅读更多精彩内容

  • 在iOS开发中经常会涉及到触摸事件。本想自己总结一下,但是遇到了这篇文章,感觉总结的已经很到位,特此转载。作者:L...
    WQ_UESTC阅读 6,010评论 4 26
  • 在开发过程中,大家或多或少的都会碰到令人头疼的手势冲突问题,正好前两天碰到一个类似的bug,于是借着这个机会了解了...
    闫仕伟阅读 5,330评论 2 23
  • 本文主要讲解iOS触摸事件的一系列机制,涉及的问题大致包括: 触摸事件由触屏生成后如何传递到当前应用? 应用接收触...
    baihualinxin阅读 1,208评论 0 9
  • 作者:Lotheve链接:https://www.jianshu.com/p/c294d1bd963d[https...
    寻心_0a46阅读 858评论 0 2
  • 好奇触摸事件是如何从屏幕转移到APP内的?困惑于Cell怎么突然不能点击了?纠结于如何实现这个奇葩响应需求?亦或是...
    Lotheve阅读 57,089评论 51 599