iOS触摸事件探究一

目录
一、基本概念
       1.1、UITouch
       1.2、UIEvent
       1.3、UIResponder
二、查找第一响应者
三、响应者链

一、基本概念

在研究触摸事件之间先看下一些重要的类。

1.1、UITouch

An object representing the location, size, movement, and force of a touch occurring on the screen.
表示发生在屏幕上的可以表示触摸的位置,大小,移动和力度的一个对象

通俗来讲就是一个手指触摸会生成一个UITouch对象,多个手指触摸会生成多个UITouch对象。

UITouch的部分声明如下:

@interface UITouch : NSObject
@property(nonatomic,readonly) NSTimeInterval      timestamp; //触摸的时间
@property(nonatomic,readonly) UITouchPhase        phase;     //触摸的阶段
@property(nonatomic,readonly) NSUInteger          tapCount;  //在一特定的时间内触摸某一个点的次数,可用来判断单击、双击、三击
@property(nonatomic,readonly) UITouchType         type;      //触摸的类型
@property(nonatomic,readonly) CGFloat             force;     //触摸力度,平均值是1.0
@property(nullable,nonatomic,readonly,strong) UIWindow     *window; //触摸发生时的window
@property(nullable,nonatomic,readonly,strong) UIView       *view; //发生触摸时的view,第一响应者
@end

每一个UITouch都会包含一个UITouchType(可以查看UITouch类定义,其内部有一个类型是UITouchType的属性type),其类型定义如下

typedef NS_ENUM(NSInteger, UITouchType) {
    UITouchTypeDirect,                       // 直接触摸屏幕产生的touch
    UITouchTypeIndirect,                     // 非直接触摸屏幕产生的touch
    UITouchTypePencil NS_AVAILABLE_IOS(9_1), // 使用触摸笔产生的touch
    UITouchTypeStylus NS_AVAILABLE_IOS(9_1) = UITouchTypePencil, // 废弃, 使用触摸笔 选项
} NS_ENUM_AVAILABLE_IOS(9_0);

每一个UITouch都会包含一个UITouchPhase(可以查看UITouch类定义,其内部有一个类型是UITouchPhase的属性phase),其类型定义如下

typedef NS_ENUM(NSInteger, UITouchPhase) {
    UITouchPhaseBegan,             // whenever a finger touches the surface.
    UITouchPhaseMoved,             // whenever a finger moves on the surface.
    UITouchPhaseStationary,        // whenever a finger is touching the surface but hasn't moved since the previous event.
    UITouchPhaseEnded,             // whenever a finger leaves the surface.
    UITouchPhaseCancelled,         // whenever a touch doesn't end but we need to stop tracking (e.g. putting device to face)
};

1.2、UIEvent

An object that describes a single user interaction with your app.
描述和app单次交互的对象。

UIEvent的部分声明如下:

@interface UIEvent : NSObject
@property(nonatomic,readonly) UIEventType     type;  //事件的类型
@property(nonatomic,readonly) UIEventSubtype  subtype;  //事件的子类型
@property(nonatomic,readonly) NSTimeInterval  timestamp; //事件发生的时间
@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches; //每个事件中包含的触摸集合
@end

从上可以看出来每一个UIEvent事件可能包含多个UITouch,比如多指触摸。
每一个UIEvent都会包含一个UIEventType(可以查看UIEvent类定义,其内部有一个类型是UIEventType的属性type),其类型定义如下

typedef NS_ENUM(NSInteger, UIEventType) {
    UIEventTypeTouches,        // 触摸事件,按钮、手势等
    UIEventTypeMotion,         // 运动事件,摇一摇、指南针等
    UIEventTypeRemoteControl,  //使用遥控器或耳机线控等产生的事件。如播放、暂停等。
    UIEventTypePresses NS_ENUM_AVAILABLE_IOS(9_0), // 3D touch
};

1.3、UIResponder

An abstract interface for responding to and handling events.
响应和处理事件的一个抽象接口。

响应者对象(UIResponder的实例)构成app事件处理的基础。许多关键的对象都是UIResponder的实例,比如UIApplicationUIWindow, UIViewController,以及所有的UIView.当前事件发生的时候,UIKit会自动将其派发到合适的对象来处理,这个对象就叫做第一响应者

UIResponder中有很多的事件,其中包括处理触摸事件、点按事件、加速事件、远程控制事件。如果想响应事件,那么对应的UIResponder必须重写相应事件的方法,比如如果处理触摸事件,可以重写touchesBegantouchesMovedtouchesEndedtouchesCancelled方法

UIResponder除了处理事件之外还管理着如何让未处理的事件传递到其它的responder对象。如果一个指定的responder对象未处理事件,那么它会沿着响应者链传递到另一个responder对象。当前下一个responder对象可能是superView或者ViewController
UIResponder的部分声明如下:

@interface UIResponder : NSObject <UIResponderStandardEditActions>
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
@property(nonatomic, readonly) BOOL canBecomeFirstResponder;    // default is NO
@property(nonatomic, readonly) BOOL canResignFirstResponder;    // default  is YES
@property(nonatomic, readonly) BOOL isFirstResponder;
// 处理触摸事件的主要方法,所以我们自定义事件处理时,就需要在这几个方法里面做文章。
- (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);
@end

🔥 总结: UITouch、UIEvent、UIResponder的关系

UITouch 是一次“触摸点”,UIEvent 是“一组触摸的集合”,UIResponder 是“处理这些事件的对象”。

二、响应者链(The Responder Chain)

iOS事件是沿着响应者链进行传递的,🔥所谓响应者链即有响应者对象组成的链条
响应者链的目的是🔥在这个链条上找到一个最终可以处理事件的对象

The ultimate goal of these event paths is to find an object that can handle 
and respond to an event
下面是官方旧版本事件传递的过程:
响应者链.png

上图解释如下:

  • 触摸了initial view

  • 1.第一响应者就是initial view, 即initial view首先响应touchesBegan:withEvent:方法,如果其没有重写touchesBegan:withEvent:或者重写了但是调用了super方法那么事件会传递给橘黄色的view

  • 2.橘黄色的view开始响应touchesBegan:withEvent:方法,如果其没有重写touchesBegan:withEvent:或者重写了但是调用了super方法那么事件会传递给蓝绿色view

  • 3.蓝绿色view响应touchesBegan:withEvent:方法,如果其没有重写touchesBegan:withEvent:或者重写了但是调用了super方法那么事件会传递给控制器的view

  • 4.控制器view响应touchesBegan:withEvent:方法,如果其没有重写touchesBegan:withEvent:或者重写了但是调用了super方法那么事件会传递给控制器传递给了窗口

  • 5.窗口再传递给application

  • 如果app不能处理这个事件,那么这个事件将被废弃


下面是官方新版本事件传递的过程:
响应者链新.png

解释如下:

  • 如果text field未处理事件,UIKit会将事件传递给text fieldparent view--UIView,也即图中的第一个UIView,
  • 如果第一个UIView未处理事件,UIKit会将事件传递给第二个UIView
  • 如果第二个UIView未处理事件,正常情况下应该将事件传递给UIWindow。但是UIViewController也是UIResponder的子类,所以事件将传递给UIViewController
  • 如果UIViewController未处理事件,UIKit会将事件传递给第UIWindow
  • 如果UIWindow未处理事件,UIKit会将事件传递给第UIApplication,当然也会传递给是UIResponder的子类但不属于响应者链环节的delegate
  • 如果app不能处理这个事件,那么这个事件将被废弃

三、查找第一响应者

第一响应者的类型和事件的类型有关系:

事件类型 第一响应者
Touch events The view in which the touch occurred.
Press events The object that has focus.
Shake-motion events The object that you (or UIKit) designate.
Remote-control events The object that you (or UIKit) designate.
Editing menu messages The object that you (or UIKit) designate.

现在我们只关心触摸事件
查找第一响应者的原理如下:UIKit 会使用基于view的hit-testing方法来查找第一响应者。明确地说就是利用触摸发生的位置和视图层级中每个view的bounds进行比较(其实就是调用pointInside:withEvent,如果返回YES表示在这个范围内,NO则相反)。也就是说通过视图中的hitTest:withEvent方法遍历视图层级,直至找到包含触摸点的子view,那个view就是第一响应者。

  • 备注:如果触摸点在一个view 的bounds之外,那么这个view及其它的子view将会被忽略。因此,当一view的clipsToBounds=NO,如果触摸事件发生在子view上超出父视图的部分,那么hitTest:withEvent也不会将这个子view返回。

下面利用一个demo来验证一下第一响应者查找的过程。

图层.png

视图的层级如下:

Window
    └── ViewA
        ├── ViewB
        └── ViewC
            ├── ViewD
            └── ViewE

现在点击ViewD,则相应的打印如下图:

🧪 =======》Window, Begin handling hitTest 
📍 ===》Window, Begin handling pointInside
📍《=== Window, handing pointInside, result: true
🧪 =======》ViewA, Begin handling hitTest
📍 ===》ViewA, Begin handling pointInside
📍《=== ViewA, handling pointInside ViewA: true
🧪 =======》ViewC, Begin handling hitTest
📍 ===》ViewC, Begin handling pointInside
📍《=== ViewC, handling pointInside ViewC: true
🧪 =======》ViewE, Begin handling hitTest
📍 ===》ViewE, Begin handling pointInside
📍《=== ViewE, handling pointInside ViewE: false
🧪《======= ViewE, handling hitTest, result: nil
🧪 =======》ViewD, Begin handling hitTest
📍 ===》ViewD, Begin handling pointInside
📍《=== ViewD, handling pointInside ViewD: true
🧪《======= ViewD, handling hitTest, result: ViewD
🧪《======= ViewC, handling hitTest, result: ViewD
🧪《======= ViewA, handling hitTest, result: ViewD
🧪《======= Window, handing hitTest, result: ViewD

/* 重复打印上面的操作,为了二次确认*/

🔥 Window, handing sendEvent in Window
✅ First responder: Optional("ViewD")
🔥 ViewD, handling touchesBegan in ViewD
🔥 ViewC, handling touchesBegan in ViewC
🔥 ViewA, handling touchesBegan in ViewA
🔥 Window, handing touchesBegan in Window
🔥 Window, handing sendEvent in Window

结果分析:

  • 1.首先从Window开始,先调用hitTest:withEvent:,然后调用pointInside:withEvent:,因为触摸点确实在Window上所以pointInside:withEvent:返回了true
  • 2.然后遍历Window的子视图ViewA, 调用hitTest:withEvent:,然后调用pointInside:withEvent:,因为触摸点确实在ViewA上所以pointInside:withEvent:返回了true
  • 3.然后遍历ViewA的子视图(ViewB,ViewC),遍历采用的是从后往前(也即先遍历最后addSubview的视图,因为最后添加的视图通常情况下是在视图的最上层,苹果这里做了一个优化)。先遍历ViewC,操作同Window
  • 4.然后遍历ViewC的子视图(ViewD,ViewE)。同样,遍历采用的是从后往前。先遍历ViewE,操作同Window.这时pointInside:withEvent:返回了false,也即不包含触摸点。那么相应的hitTest:withEvent返回了null
  • 5.遍历ViewD,因为ViewD已经是一个叶节点(iOS中视图的层级是一个n杈树),其pointInside:withEvent:返回了true,与此相对应的hitTest:withEvent返回最终包含触摸点的视图,也即第一响应者。hitTest:withEvent的查找类似一个递归,所以在包含第一响应者的分支上的每一个视图中的hitTest:withEvent都返回第一个响应者。
  • 6.因为已经找到了第一响应者,所以ViewB这个分支就没必要遍历了。

🔥官方文档明确指出,下面情况的视图不在遍历范围内:

  • 视图的hidden等于YES
  • 视图的alpha小于等于0.01
  • 视图的userInteractionEnabledNO
    也就是说在hitTest:withEvent调用过程中会进行上面三种情况的判断。

🔥 总结

hitTest(_:with:)
   → point(inside:with:)
   → 子视图倒序遍历
   → 返回唯一一个 View

伪代码大致如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    //3种状态无法响应事件
     if (self.userInteractionEnabled == NO || self.hidden == YES ||  self.alpha <= 0.01) return nil; 
    //触摸点若不在当前视图上则无法响应事件
    if ([self pointInside:point withEvent:event] == NO) return nil; 
    //从后往前遍历子视图数组 
    int count = (int)self.subviews.count; 
    for (int i = count - 1; i >= 0; i--)  { 
        // 获取子视图
        UIView *childView = self.subviews[I]; 
        // 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
        CGPoint childP = [self convertPoint:point toView:childView]; 
        //询问子视图层级中的最佳响应视图
        UIView *fitView = [childView hitTest:childP withEvent:event]; 
        if (fitView)  {
            //如果子视图中有更合适的就返回
            return fitView; 
        }
    } 
    //没有在子视图中找到更合适的响应视图,那么自身就是最合适的
    return self;
}

在找到第一响应者之后所有的信息全部包含在一个UIEvent对象中,接下来会通过sendEvent方法直接将事件传递给第一响应者(在上面的操作中是直接传递给ViewD)。


图片.png
// 在 windows中重新sendEvent时候不要忘记写super,否则所有触摸事件都不生效
// 在这里可以遍历获取对应的触摸对象
- (void)sendEvent:(UIEvent *)event {
    NSLog(@"🔥 sendEvent in %@", NSStringFromClass([self class]));
    NSSet<UITouch *> *touches = [event allTouches];
    if (touches) {
        for (UITouch *touch in touches) {
            if (touch.phase == UITouchPhaseBegan) {
                UIView *view = touch.view;
                NSLog(@"✅ First responder: %@", NSStringFromClass([view class]));
            }
        }
    }
    [super sendEvent: event];
}

四、使用场景

场景 1:扩大按钮的点击或缩小区域
class BigTouchButton: UIButton {
    // 四周多了20的边距
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let biggerArea = bounds.insetBy(dx: -20, dy: -20)
        return biggerArea.contains(point)
    }
}
场景 2:处理蒙层是否可点击
class PopupHitTestView: UIView {
    
    weak var delegate:  PopupHitTestViewDelegate?
    private var ignoreHit: Bool = false
        
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if self.ignoreHit {
            return nil
        }
        
        var hitView = super.hitTest(point, with: event)
        //如果点中的是maskview自己, 否则原样返回
        if hitView == self {
            self.ignoreHit = true
            // 利用window二次确认是否点击了maskview
            hitView = window?.hitTest(point, with: event)
            self.ignoreHit = false
            
            var shouldHit = false
            if let hitView = hitView {
                shouldHit = self.delegate?.popupHitTestView(self, shouldHit: hitView) ?? false
            }
            
            if shouldHit {
                return hitView
            } else {
                return self
            }
        } else {
            return hitView
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        delegate?.popupHitTestViewTouched(self)
    }

}
场景 3:缩小点击区域
class PartialTouchView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitView = super.hitTest(point, with: event)
        // 处理右半部分区域
        if let hitView, hitView == self {
            if point.x > hitView.bounds.width / 2.0 && point.x <= hitView.bounds.width {
                return nil
            } else {
                print("点击了左半部分区域")
            }
        }
        return hitView
    }
}

场景 4:让 A View 替 B View 接收点击(同级或者不同级事件转发)
class RedirectTouchView: UIView {

    weak var targetView: UIView?

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitView = super.hitTest(point, with: event)
        if hitView == self {
            return targetView
        }
        return hitView
    }
}

class TargetTouchView: UIView {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("接收事件")
    }
}
场景 5:Debug 打印到底谁执行了事件
class DebugHitTestView: UIView {

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let v = super.hitTest(point, with: event)
        print("✅ 命中视图:\(String(describing: v))")
        return v
    }
}

参考资料

demo
UIGestureRecognizer
Touches, Presses, and Gestures

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 在iOS开发中经常会涉及到触摸事件。本想自己总结一下,但是遇到了这篇文章,感觉总结的已经很到位,特此转载。作者:L...
    WQ_UESTC阅读 11,317评论 4 26
  • 本文主要讲解iOS触摸事件的一系列机制,涉及的问题大致包括: 触摸事件由触屏生成后如何传递到当前应用? 应用接收触...
    baihualinxin阅读 4,933评论 0 9
  • 转载: https://blog.csdn.net/qq871531334/article/details/822...
    NicooYang阅读 5,526评论 0 9
  • 触摸事件的生命周期 当我们手指触碰屏幕的那一刻,一个触摸事件便产生了。经过进程间通信,触摸事件被传递到合适的应用之...
    Gintok阅读 5,279评论 0 3
  • 前言: 按照时间顺序,事件的生命周期是这样的: 事件的产生和传递(事件如何从父控件传递到子控件并寻找到最合适的vi...
    reviewThis阅读 4,073评论 1 2

友情链接更多精彩内容