解析iOS触摸事件响应机制(一)

iOS设备支持的用户操作事件有三种:

  • Multitouch Event(多点触摸事件)
  • Motion Event(设备运动事件)
  • Remote Control Event(远程控制事件)

其中最常用的就是多点触摸事件。一个触摸事件的响应过程如下:

  1. 用户触摸屏幕时,UIKit会生成UIEvent对象来描述触摸事件。对象内部包含了触摸点坐标等信息。
  2. 通过Hit Test确定用户触摸的是哪一个UIView。这个步骤通过- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法来完成。
  3. 找到被触摸的UIView之后,如果它能够响应用户事件,相应的响应函数就会被调用。如果不能响应,就会沿着响应链(Responder Chain)寻找能够响应的UIResponder对象(UIView是UIResponder的子类)来响应触摸事件。

Hit Test

我们知道,iOS应用界面是通过许多UIView以树状结构组织而成的。视图树的根视图就是UIViewController的view属性,它的frame是最大的。每个视图的子视图(subview),一般都是在父视图(superview)的内部。视图的层级关系一级一级延伸,越往下视图面积越小。

Hit Test的过程是这样的:

从视图树的根部开始,按照下面的规则遍历所有的子视图。

  1. 检查一个视图A的所有子视图,如果触摸点不在任何一个子视图内部,那么A就是用户触摸的视图。Hit Test结束。
  2. 如果某一个触摸点在某一个子视图B内部,按照1的方法检查B的所有子视图。依此类推。

很明显,Hit Test就是对视图树的一个广度优先搜索过程。

我们尝试自己实现一下- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    //为了方便展示Hit Test的过程,添加一些log
    NSMutableString *log = [NSMutableString string];
    UIView *superview = self.superview;
    while (superview) {
        [log appendString:@"--"];
        superview = superview.superview;
    }
    [log appendString:NSStringFromClass(self.class)];
    printf("%s\n",log.UTF8String);
    //排除几种视图不需要响应触摸事件的情况
    if (self.hidden || !self.userInteractionEnabled || self.alpha <= 0.01 ) {
        return nil;
    }
    if (![self pointInside:point withEvent:event]) {
        return nil;
    }
    //同一层级的视图,后添加的在上层,有更高的响应优先级。所以要把subviews数组反过来遍历
    for (UIView *view in self.subviews.reverseObjectEnumerator) {
        CGPoint localPoint = [self convertPoint:point toView:view];
        UIView *hitView = [view hitTest:localPoint withEvent:event];
        if (hitView) {
            return hitView;
        }
    }
    return self;
}

为了验证我们的实现,需要利用runtime把UIView的默认实现替换掉。我们写一个UIView子类,就叫MLHitTestHackView,然后把刚才的实现放进去:

@implementation MLHitTestHackView

+ (void)load
{
    [self hackHitTest];
}

+ (void)hackHitTest
{
    SEL hitTestSEL = @selector(hitTest:withEvent:);
    Method customHitTest = class_getInstanceMethod([MLHitTestHackView class], hitTestSEL);
    Method originalHitTest = class_getInstanceMethod([UIView class], hitTestSEL);
    method_exchangeImplementations(customHitTest, originalHitTest);
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
  //这里放上文的实现
}

@end

随手画几个view测试一下,如图所示:

views.jpg

Button的Action方法中打个log:

@IBAction func onClickButton(sender: AnyObject) {
    print("button clicked")
}

点击Button输出结果如下:

UIWindow
--UIView
----_UILayoutGuide
----_UILayoutGuide
----UIView
------UIView
------UIButton
--------UIButtonLabel
UIStatusBarWindow
--UIStatusBar
UIWindow
--UIView
----_UILayoutGuide
----_UILayoutGuide
----UIView
------UIView
------UIButton
--------UIButtonLabel
button clicked

可以看到整个Hit Test流程和上文描述的一致,遵循广度优先的规则。

通过这个log输出我们还可以看到一些UIKit没有暴露的细节,比如UIButton内部有个UIButtonLabel视图。

通过对Hit Test过程的分析,我们也明白了为什么一个子视图超出了父视图边界,超出的部分不能响应触摸:一个视图在Hit Test中被找到的前提是它的父视图被找到。如果用户触摸的位置超出了父视图的边界,UIKit无法根据这个触摸点找到父视图,自然也就无法找到子视图。

另外貌似整个Hit Test过程走了两遍,暂时还没有搞清楚原因,欢迎高手指教。

Hit Test原理的应用

知道了Hit Test的原理,我们可以用它来做一些有趣的事情。

app做了新功能,我们当然希望用户马上去用,这就需要做一些操作引导。一种常见的形式是用户第一次打开新版本界面时,用一个半透明的蒙板蒙在界面上,画一些指引和说明文字。但这种蒙板往往需要用户点一下让它消失,然后才能继续操作,体验不是很好。

我们可以利用Hit Test的机制,让用户点击指定位置时,被蒙板盖住的控件能够响应用户的操作,同时蒙板消失。这样用户按照指示操作马上就能生效,整个流程就顺畅多了。

回顾一下文章开头提到的触摸事件响应过程,一个视图要响应触摸事件,先要通过Hit Test被“找到”。所以只要在- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法上做做文章,就可以让点击事件穿透蒙板到达下面的控件了。

关键逻辑也不复杂:如果用户触摸了指定的区域,则调用removeFromSuperview方法将蒙板移除,然后返回蒙板的superview。因为蒙板一般都是直接加在UIViewController的根视图上,所以这里返回的就是根视图。然后触摸位置对应的视图就可以通过正常的Hit Test流程找到。

这个部分不需要runtime,使用Swift实现。代码如下:


import Foundation
import UIKit

extension UIColor {
    class func color(hex hex:UInt, alpha:CGFloat = 1.0) -> UIColor{
        let redValue = CGFloat(hex & 0xFF0000 >> 16) / 255.0
        let greenValue = CGFloat(hex & 0xFF00 >> 8) / 255.0
        let blueValue = CGFloat(hex & 0xFF) / 255.0
        
        return UIColor(red: redValue, green: greenValue, blue: blueValue, alpha: alpha)
    }
}

class MLGuideMaskView: UIView {
    var BGColor = UIColor.color(hex: 0x000000, alpha: 0.6)
    
    var transparentAreaPath:CGPath?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        insideInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        insideInit()
    }
    
    private func insideInit(){
        backgroundColor = UIColor.clearColor()
    }
    //这里用BGColor填充transparentAreaPath之外的区域,让触摸事件可以穿透的区域全透明高亮显示
    override func drawRect(rect: CGRect) {
        let context = UIGraphicsGetCurrentContext()
        CGContextSetFillColorWithColor(context, BGColor.CGColor)
        CGContextAddRect(context, rect)
        if let path = transparentAreaPath{
            CGContextAddPath(context, path)
            CGContextEOFillPath(context)
        }else{
            CGContextFillPath(context)
        }
    }
    
    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        //被点击时,XYGuideMaskView消失,同时将点击事件传给下面的view。
        //必须先removeFromSuperview()再调用superview的hitTest。UIView的hitTest方法会逐个调用subview的hitTest,直接调用superview的hitTest会造成循环调用。
        print("hit test in guide mask view")
        var hitInReactiveArea = false
        if let path = transparentAreaPath {
            hitInReactiveArea = CGPathContainsPoint(path, nil, point, false)
        }
        if let superview = superview where hitInReactiveArea{
            removeFromSuperview()
            return superview.hitTest(point, withEvent: event)
        }else{
            //点击其他区域的处理留在touchesBegan中完成
            return self
        }
    }
    
    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        removeFromSuperview()
    }
}

可以把这个GuideMaskView加到某个界面上试一试,下面的控件响应触摸的同时蒙板消失,效果很有趣。记得设置transparentAreaPath属性。

下一篇文章会分析Responder Chain的原理,同样给出一个有意思的应用。敬请期待。

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

推荐阅读更多精彩内容