iOS 中的事件响应与处理

作者: bool周 原文链接: iOS 中的事件响应与处理

在使用 iPhone 过程中,会产生很多交互事件,例如点击、长按、摇晃、3D Touch 等。这些事件都需要 iOS 系统去响应并作出处理。这篇文章主要讲解一下系统如何去响应并处理这些事件。

事件种类

为满足用户需求,iOS 提供了多种事件,这里先说一下具体有哪些事件,现在脑中有一个清晰的轮廓。iOS 中的事件大致可以分为如下几类:

1.触摸事件

触摸事件主要来源于人体触摸和通过 Apple Pencil (iPad) 触摸。触摸事件也分为以下几类:

  • 手势事件
    • 长按手势 (UILongPressGestureRecognizer)
    • 拖动手势 (UIPanGestureRecognizer)
    • 捏合手势 (UIPinchGestureRecognizer)
    • 响应屏幕边缘手势 (UIScreenEdgePanGestureRecognizer)
    • 轻扫手势 (UISwipeGestureRecognizer)
    • 旋转手势 (UIRotationGestureRecognizer)
    • 点击手势 (UITapGestureRecognizer)
  • 自定义手势
  • 点击 button 相关

2.运动事件

iPhone 内置陀螺仪、加速器和磁力仪,可以感知手机的运动情况。iOS 提供了 Core Motion 框架来处理这些运动事件。根据这些内置硬件,运动事件大致分为三类:

  • 陀螺仪相关:陀螺仪会测量设备绕 X-Y-Z 轴的自转速率,倾斜角度等。通过 Core Motion 提供的一些 API 可以获取到这些数据,并进行处理;通过系统可以通过内置陀螺仪获取设备的朝向,以此对 App UI 做出调整。
  • 加速器相关:设备可以通过内置加速器测量设备在 X-Y-Z 轴速度的改变; Core Motion 提供了高度计(CMAltimeter)、计步器(CMPedometer) 等对象,来获取并处理这些产生的数据。
  • 磁力仪相关:使用磁力仪可以获取当前设备的磁极、方向、经纬度等数据,这些数据多用于地图导航开发。

3.远程控制事件

远程控制事件指通过耳机去控制手机上的一些操作。目前 iOS 仅提供我们远程控制音频和视频的权限。即对音频实现暂停/播放、上一曲/下一曲、快进/快退操作。可以在 UIEventSubtype 中看到这些事件,一般用于开发播放器相关。

4.按压事件

iOS 9 提供了 3D Touch 事件,通过使用这个功能我们可以做如下操作:

  • Quick Actions,重压 App icon 可以进行很多快捷操作。
  • Peek and Pop,使用这个功能对文件进行预览和其他操作,可以在手机自带 “信息” 里面试验。
  • Pressure Sensitivity,压力响应敏感,可以在备忘录中选择画笔,按压不同力度画出来的颜色深浅不一样。

事件响应

当 iPhone 接收到一个事件时,处理过程大体如下:

  1. 当你通过一个动作(触摸/摇晃/线控)等触发一个事件,这时候会唤起处于休眠状态的 cup。

  2. 事件会通过使用 IOKit.framework 来封装成 IOHIDEvent 对象。

    IOKit.framework 是一个系统框架的集合,用来驱动一些系统事件。IOHIDEvent 中的 HID 代表 Human Interface Device,即人机交互驱动

  3. 然后系统通过 mach port(IPC 进程间通信) 将 IOHIDEvent 对象转发给 SpringBoard.app。

  4. SpringBoard.app 是 iOS 系统桌面 App,它只接收按键、触摸、加速、接近传感器等几种 Event。SpringBoard.app 会找到可以响应这个事件的 App,并通过 mach port(IPC 进程间通信) 将 IOHIDEvent 对象转发给这个 App。

  5. 前台 App 主线程 Runloop 接收到 SpringBoard.app 转发过来的消息之后,触发对应的 mach port 的 Source1 回调 __IOHIDEventSystemClientQueueCallback()

  6. Source1 回调内部触发了 Source0 回调 __UIApplicationHandleEventQueue()

  7. Source0 回掉内部,将 IOHIDEvent 对象转化为 UIEvent
  8. Soucre0 回调内部调用 UIApplication+[sendEvent:] 方法,将 UIEvent 传给UIWindow

UIWindow 接收到这个事件后,开始传递事件,就是下一节要说的问题了。

事件传递

UIWindow 的收到的事件,有的是通过响应链传递,找到合适的 view 进行处理的;有的是不用传递,直接用 first responder 来处理的。这里先介绍使用响应链传递的过程,之后再说不通过响应链传递的一些事件。

事件传递大致可以分为三个阶段:Hit-Testing(寻找合适的 view)、Recognize Gesture(响应应手势)、Response Chain(touch 事件传递)。通过手去触摸屏幕所产生的事件,都是通过这三步去传递的,例如上文所说的触摸事件按压事件

1. Hit-Testing

这一过程主要来确定由哪个视图来首先处理 UITouch 事件。当你点击一个 view,事件传到 UIWindow 这一步之后,会去遍历 view 层级,直至找到那个合适的 view 来处理这个事件,这一过程也叫做 Hit-Testing

遍历方式

既然遍历,就会有一定的顺序。系统会根据添加 view 的前后顺序,确定 view 在 subviews 数组中的顺序。然后根据这个顺序将视图层级转化为图层树,针对这个树,使用倒着进行前序深度遍历的算法,进行遍历。

如果使用 storyboard 添加视图,添加顺序等同于使用 addSubview() 的方式添加视图。即先拖入的属于 subviews 数组中第 0 个元素。

例如下面一个图层,我点击了红色箭头标注的地方:

这个图层,转化为图层树如下,同时我也将遍历顺序标记出来了:

在上面图层树中,View A,B,C 平级,以 A,B,C 先后顺序加入。所以当我点击一个 point 的时候,会从 View C 开始遍历;判断点不在 View C 上,转向 View B;判断点在 View B 上,转向右子树 View b2;判断点不在 View b2 上,转向 View b1; 点在 View b1 上,且其没有子视图,那么 View b1 为最合适的点。

有时候你点击一次,会发现 [hitTest:withEvent:] 被调用了多次,我也不清楚为什么,但是这并不影响事件传递。可能你的手指点击时有轻微移动产生了多个事件。

[hitTest:withEvent:] 方法实现原理

UIWindow 拿到事件之后,会先将事件传递给图层树中距离最靠近 UIWindow 那一层最后一个 view,然后调用其 [hitTest:withEvent:]。注意这里是**先将视图传递给 view,再调用其 [hitTest:withEvent:] 方法。并遵循这样的原则:

  • 如果点不在这个视图内,则去遍历其他视图。
  • 如果点击在这个视图内,但是其还有自视图,那么将事件传递给自视图,并且调用自视图的 [hitTest:withEvent:].
  • 如果点击在这个视图内,并且这个视图没有子视图,那么 return self,即它就是那个最合适的视图。
  • 如果点击在这个视图内,并且这个视图没有子视图,但是不想作为处理事件的 view,可以 return nil,事件由父视图处理。

有几种方式,设置了之后视图和其自视图不会再接收 touch 事件。分别为:

  • 视图被隐藏:self.hidden = YES.
  • 视图不允许响应交互事件:self.userInteractionEnabled = NO.
  • 视图的 alpha 在 0~0.01 之间。几乎透明。

综上,我们可以得出 [hitTest:withEvent:] 方法实现大致如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 是否响应 touch 事件
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) return nil;
    
    // 点是否在 view 内
    if (![self pointInside:point withEvent:event]) return nil;
    
    for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
        CGPoint convertedPoint = [subview convertPoint:point fromView:self];
        // point 进行坐标转化,递归调用,寻找自视图,直到返回 nil 或者 self
        UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
        if (hitTestView) {
            return hitTestView;
        }
    }
    return self;
}
复制代码

重写 [hitTest:withEvent:]
当你想中断传递时

当时想在当前 view 处理事件,不想在对 subview 进行遍历,可以直接重写 [hitTest:withEvent:] 方法并 return self 即可。不过一般没有这样做的,这样会影响事件传递,产生一些 bug。

因为遍历顺序在层级树中是从上向下,但是反应到视图上面,是从里向外传,所以这种情况也可以理解为 “透传”,即你点击了 View b2,但是最终响应的是 View B。

当你想增加视图的 touch 区域

在实际开发中,有些 button 面积很小,不容易点击上。这时候你想扩大 touch 响应区域。可以通过重写 [hitTest:withEvent:] 方法实现。例如下图中的情况:

实现代码如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) return nil;
    
    CGFloat inset = 45.0f - 78.0f;
    CGRect touchRect = CGRectInset(self.bounds, inset, inset);
    
    if (CGRectContainsPoint(touchRect, point)) {
        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;
}
复制代码

当然,你也可以通过重写父视图的 [hitTest:withEvent:] 方法实现。很多 App 都有这样的需求,例如自定义 UITabbar 时,中间的那个按钮一般比较大,超出了 UITabbar 高度,有时需要重写 [hitTest:withEvent:] 来处理响应范围。

当你想指定某个 view 响应事件

有时候在一个父视图中有多个子视图 A,B,C,无论点击 B 还是 C,你都想让 A 响应。例如 App Store 中的预览 App 页面就属于这种类型:



当你点击两侧边缘的时候,你想让中间的 UIScrollView 去响应,这时候可以通过重写 [hitTest:withEvent:] 方法实现。

转化为模型如下图:

当我点击边缘视图 B 和 C 时,我希望能够响应到 UIScrollView 上面,即可以正常滚动,这时候可以重写父视图[hitTest:withEvent:],指定响应 View。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView) {
        hitTestView = self.scrollView;
    }
    return hitTestView;
}
复制代码

以上即 Hit-Testing 过程相关知识,如果这一过程最终都没有找到合适的 View,那么本次事件将被丢弃。当你想改变遍历路径时,你可以考虑重写 [hitTest:withEvent:] 以达到你想要的结果。

2. Gesture Recognizer

Gesture Recognizer(手势识别器)是系统封装的一些类,用来识别一系列的常见手势,例如点击、长按等。在上一步中确定了合适的 View 之后,UIWindow 会首先将 touches 事件先传递给 Gesture Recognizer,再传递给视图,这一点你可以通过自定义一个手势,并将手势添加到 View 上来验证。你会发现会先调用自定义手势中的一系列 touches 方法,再调用视图自己的一系列 touches 方法。

Gesture Recognizer 有一套自己的 touches 方法和状态转换机制。一个手势的响应到结束,流程如下:

系统为 Gesture Recognizer 提供了如下几种状态:

  • UIGestureRecognizerStatePossible : 未确定状态。
  • UIGestureRecognizerStateBegan : 接收到 touches,手势开始。
  • UIGestureRecognizerStateChanged : 接收到 touches,手势改变。
  • UIGestureRecognizerStateEnded : 手势识别结束,在下个 run loop 前调用对应的 action 方法。
  • UIGestureRecognizerStateCancelled : 手势取消,恢复到 possible 状态。
  • UIGestureRecognizerStateFailed : 手势识别失败,恢复到 possible 状态。
  • UIGestureRecognizerStateRecognized : 等同于 UIGestureRecognizerStateEnded。

当接收到一个系统定义的手势,首先会调用 recognizer 的 [touchesBegan:withEvent:] 方法,这时候 recognizer 的状态是未确定的;然后调用 [touchesMoved:withEvent:] 方法,依然没有识别成功;接下来要么调用 [touchesEnded:withEvent:] 方法,手势识别成功,调用对应的 action;要么调用 [touchesCancelled:withEvent:] 方法,手势识别失败。

官方也给出了一张比较明晰的图:

大致过程如此,但是细节上还有些不同。关于状态转换过程,官方给了几篇不错的文档:

  • About the Gesture Recognizer State Machine
  • Implementing a Discrete Gesture Recognizer
  • Implementing a Continuous Gesture Recognizer

3. Response Chain

上面也涉及到了,对于 touch 事件,系统提供了四个方法来处理:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

/**
  iOS 9.1 增加的 API,当无法获取真实的 touches 时,UIKit 会提供一个预估值,并设置到 UITouch 对应的 estimatedProperties 中监测更新。当收到新的属性更新时,会通过调用此方法来传递这些更新值。
  
  eg: 当使用 Apple Pencil 靠近屏幕边缘时,传感器无法感应到准确的值,此时会获取一个预估值赋给 estimatedProperties 属性。不断去更新数据,直到获取到准确的值
  */
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
复制代码

上面的前四个方法,是由系统自动调用的。

  • 默认情况下,当发生一个事件时,view 只接收到一个 UITouch 对象。当你使用多个手指同时触摸是,会接收多个 UITouch 对象,每个手指对应一个。多个手指分开触摸,会调用多次 touches 系列方法,每个 touches 里面有一个 UITouch 对象。
  • 如果你想处理一些额外的事件,可以重写以上四个方法,处理你想要处理的事件。之后不要忘记调用 [super touchexxxx] 方法,否则事件处理就中断于此 view 了,不会传递上去了。

UITouch 对象保存了事件的相关信息:

@property(nonatomic,readonly) NSTimeInterval timestamp; ///< 事件产生或变化时间
@property(nonatomic,readonly) UITouchPhase phase;       ///< 所处阶段
@property(nonatomic,readonly) NSUInteger tapCount;      ///< 短时间内点击屏幕次数

/** 点击类型,直接点击、间接点击还是笔触*/
@property(nonatomic,readonly) UITouchType         type NS_AVAILABLE_IOS(9_0);

/** 使用硬件设备点击时,以点为圆心的 touch 半径,以此确定 touch 范围大小 */
@property(nonatomic,readonly) CGFloat majorRadius NS_AVAILABLE_IOS(8_0);
/** 半径公差 */
@property(nonatomic,readonly) CGFloat majorRadiusTolerance NS_AVAILABLE_IOS(8_0);

@property(nullable,nonatomic,readonly,strong) UIWindow *window; ///< 事件所属 window
@property(nullable,nonatomic,readonly,strong) UIView *view;     ///< 事件所属 view
/** 所包含的手势识别器 */
@property(nullable,nonatomic,readonly,copy) NSArray <UIGestureRecognizer *> *gestureRecognizers NS_AVAILABLE_IOS(3_2);
复制代码

touch 事件处理的传递过程与 Hit-Testing 过程正好相反。Hit-Tesing 过程是从上向下(从父视图到子视图)遍历;touch 事件处理传递是从下向上(从子视图到父视图)传递。这也就是传说中的 Response Chain。最有机会处理事件的对象就是通过 Hit-Testing 找到的视图或者第一响应者,如果两者都能处理,则传递给下一个响应者,之后依次传递。官方给出了一个传递过程图,我就懒得画了:

如果你不重写这几个 touches 方法,系统会通过响应链找到视图响应。如果你想做自己的事件处理操作,可以重写这几个方法。就是说,你不重写,事件处理正常传递;你重写了,处理完之后不要忘记调用 super 方法,使处理过程继续传递。

4.UIResponder

App 可以接收并处理很多事件,这过程中使用的是 UIResponder 对象来接收和处理的。UIResponder类为那些需要响应比处理事件的对象定义了一组接口,使用这些接口可以处理各种花式事件。在 UIKit 中,UIViewUIViewControllerUIApplication 这些类都是继承自 UIResponder 类。下面根据提供的这些接口,讲解一下这个类相关的东西。

确定第一响应者

对于每个事件发生之后,系统会去找能给处理这个事件的第一响应者。根据不同的事件类型,第一响应者也不同:

  • 触摸事件:被触摸的那个 view。
  • 按压事件:被聚焦按压的那个对象。
  • 摇晃事件:用户或者 UIKit 指定的那个对象。
  • 远程事件:用户或者 UIKit 指定的那个对象。
  • 菜单编辑事件:用户或者 UIKit 指定的那个对象。

与加速计、陀螺仪、磁力仪相关的运动事件,是不遵循响应链机制传递的。Core Motion 会将事件直接传递给你所指定的第一响应者。更多信息可以查看 Core Motion Framework。

UIResponder 提供了几个方法(属性)来管理响应链 :

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
- (nullable UIResponder*)nextResponder;
#endif

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canBecomeFirstResponder;    // default is NO
#else
- (BOOL)canBecomeFirstResponder;    // default is NO
#endif
- (BOOL)becomeFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canResignFirstResponder;    // default is YES
#else
- (BOOL)canResignFirstResponder;    // default is YES
#endif
- (BOOL)resignFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL isFirstResponder;
#else
- (BOOL)isFirstResponder;
#endif
复制代码

  • -[nextResponder] 方法负责事件传递,默认返回 nil。子类必须实现此方法。例如 UIView 返回的是管理他的 UIViewController 对象或者其父视图;UIViewController 返回的是他的视图的父视图;UIWindow 返回的是 App 对象;UIApplication 返回的是 nil。这些在构建视图层次结构的时候就形成了。
  • 使用 -[isFirstResponder] 来判断响应对象是否为第一响应者。
  • 使用 -[canBecomeFirstResponder] 方法判断是否可以成为第一响应者。
  • 使用 -[becomeFirstResponder] 方法将响应对象设置为第一响应者。

对应的 Resignxxxx 系列方法使用场景类似。

处理各种事件的方法

UIResponder 定义了 touches 系列方法用来处理手势触摸事件;定义了 press 系列方法处理按压事件;定义了 motion 系列方法处理运动事件;定义了 remote 系列方法处理远程事件。可以说大部分事件都是通过这个类来处理的。这里就不详细说了。

输入视图相关

当我们使用 UITextView 或者 UITextField 时,点击视图会让其成为 fist responder,然后弹出一个视图(系统键盘 or 自定义键盘)让用户进行文本输入。在 UIResponder + UIResponderInputViewAdditions 这个分类中,定义了 inputViewinputAccessoryView 两个输入视图,样式分别如下:

设置了 UITextViewinputView 属性之后,将不再弹出键盘,弹出的是自定义的 view;设置了 inputAccessoryView 属性之后,将会在键盘上面显示一个自定义图,这个属性默认为 nil。

还有一些其他属性,与输入视图相关,这里不再详细说。

复制粘贴相关

在文本中选中一些文字后,会弹出一个编辑菜单,我们可以通过这些菜单进行复制、粘贴等操作。如下图是微信读书的自定义菜单:

UIResponder 这个类中定义了 UIResponderStandardEditActions protocol,来处理复制粘贴相关事件。你可以通过重写 UIResponder 提供的 -[canPerformAction:withSender] 方法,判断 action 是否是你想要的,如果是的话,你便可以为所欲为:

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if (action == @selector(copy:)) {
        // 为你所欲为
    }
    
    return YES;
}
复制代码

我们还可以重写 UIResponder 提供的 -[targetForAction:withSender:] 方法来处理某个 action 的接收者。和上面类似:

- (id)targetForAction:(SEL)action withSender:(id)sender {
    if (action == @selector(cut:)) {
        // 为你所欲为
    }
    
    return [super targetForAction:action withSender:sender];
}
复制代码

响应键盘快捷键

iOS 7 新增加了 UIResponder + UIResponderKeyCommands 分类,添加了一个 keyCommands 属性,同时还定义了 UIKeyCommands 类和一系列方法。使用这些方法,我们可以处理一些键盘快捷键。没用过,不多说,了解即可。

支持 User Activities

iOS 8 Apple 提供了 Handoff 功能,通过这个功能,用户可以在多个 Apple 设备中共同处理一件事。例如我们使用 Mac 的 Safari 浏览一些东西,因为某些事情离开,这时候我们可以使用移动设备(iPad)上的的 Safari 继续浏览。

Handoff 的基本思想是用户在一个应用里所做的任何操作都可以看作是一个 Activity,一个 Activity 可以和一个特定 iCloud 用户的多台设备关联起来。设备和设备之间使用 Activity 传递信息,达到共享操作。

为了支持这个功能,iOS 8 后新增加了 UIResponder + ActivityContinuation 分类,提供了一些方法来处理这些事件。对于继承自 UIResponder 的对象,已经为我们提供了一个 userActivity 属性,多个响应者可以共享这个 NSUserActivity 类型的属性。另外我们可以使用 -[updateUserActivityState:]方法来更新这个属性;使用 -[restoreUserActivityState:] 方法重置这个属性的状态。

更秀的操作,请看 iOS 8 Handoff 开发指南。

如你所见,UIResponder 类提供了处理大部分事件的接口,熟练了这些接口的使用,你便可以为所欲为。

5.不遵循 Responder Chain 的事件

上面也说了,与加速计、陀螺仪、磁力仪相关的运动事件,是不遵循响应链机制传递的。而是直接传递给用户指定的 frist responder。所以要将运动事件传递给一个对象,需要遵循:

  • 对象的 -[canBecomeFirstResponder] 方法必须返回 YES。
  • 在 view controller 控制器中,在合适的地方调用对象的 -[becomeFirstResponder]-[resignFirstResponder] 方法。

下面是一个处理摇一摇事件的例子:

// 自定义视图
@implementation CustomShakeView

#pragma mark - Overrid Method

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event {
    if (motion == UIEventSubtypeMotionShake) {
        CGFloat width = self.frame.size.width;
        CGFloat height = self.frame.size.height;
        UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, width, height)];
        label.text = @"phone was shaked";
        label.textAlignment = NSTextAlignmentCenter;
        [self addSubview:label];
    }
}

- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event {
    // nothing
}

- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event {
    // nothing
}
@end

// 视图控制器
@interface ViewController ()
@property (nonatomic, strong) CustomShakeView *shakeView;
@end

@implementation ViewController

- (void)viewDidLoad {
    self.shakeView = [[CustomShakeView alloc] initWithFrame:CGRectMake(0, 250, viewWidth, 60)];
    self.shakeView.backgroundColor = [UIColor grayColor];
    [self.view addSubview:_shakeView];
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [self.shakeView becomeFirstResponder];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self.shakeView resignFirstResponder];
}
复制代码

远程控制事件与此类似,不在多说。

各种事件的使用

这一章节主要是一些事件的使用 demo,基本 API 的调用,已经熟练使用的同学可以略过了。

1.手势类使用

// 创建一个系统手势或者自定义手势,添加到一个 view 上即可。
@implementation ViewController

- (void)viewDidLoad {
  UIView *customView = [UIView new];
    UITapGestureRecognizer *gesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAcation:)];
    [customView addGestureRecognizer:gesture];
}

- (void)tapAcation:(UIGestureRecognizer *)gestureRecognizer {
    // 为所欲为
}
@end
复制代码

2.touches 系列方法使用

这里是一个可以被拖动的 imageView 的例子。

@interface DragView : UIImageView
@end

@implementation DragView

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 获取前后两个点,计算偏移量,然后做平移转换
    UITouch *touch = [touches anyObject];
    CGPoint currentPoint = [touch locationInView:self];
    CGPoint previousPoint = [touch previousLocationInView:self];
    CGFloat offsetX = currentPoint.x - previousPoint.x;
    CGFloat offsetY = currentPoint.y - previousPoint.y;
    self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}

@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    DragView *dragView = [[DragView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    dragView.userInteractionEnabled = YES;
    dragView.image = [UIImage imageNamed:@"picture.jpg"];
    [self.view addSubview:dragView];
}
@end
复制代码

3.摇一摇事件(运动事件)

请参见上一章的最后一小节。

4.远程控制事件

一个可以通过耳机控制音乐播放的 view controller,主要做的几件事情我已经用注释标出。

@interface PlayVideoViewController ()
@property (assign, nonatomic) BOOL  isPlaying;
@property (strong, nonatomic) AVAudioPlayer *avAudioPlayer;
@end

@implementation PlayVideoViewController

#pragma mark - Override Method

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    
    // 接收线控事件,并设置 VC 为第一响应者
    [[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
    [self becomeFirstResponder];
    
    // 读取一个音频文件到 player 中
    NSString *filePath = [[NSBundle mainBundle] pathForResource:@"周杰伦-我的地盘" ofType:@"mp3"];
    NSURL *url = [NSURL fileURLWithPath:filePath];
    self.avAudioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];
}

- (void)viewWillDisappear:(BOOL)animated {
    // 取消接收线控事件
    [[UIApplication sharedApplication] endReceivingRemoteControlEvents];
    [self resignFirstResponder];
}

/** 重写方法,返回 YES */
- (BOOL)canBecomeFirstResponder {
    return YES;
}

/** 实现这个方法,处理各种事件 */
- (void)remoteControlReceivedWithEvent:(UIEvent *)event {
    switch (event.subtype) {
        case UIEventSubtypeRemoteControlTogglePlayPause:
            // 同时控制播放和暂停
            if (!_isPlaying) {
                [_avAudioPlayer play];
                _isPlaying = YES;
            } else {
                [_avAudioPlayer pause];
                _isPlaying = NO;
            }
            break;
        case UIEventSubtypeRemoteControlPlay:
            // 播放
            break;
        case UIEventSubtypeRemoteControlPause:
            // 暂停
            break;
        case UIEventSubtypeRemoteControlStop:
            // 停止
            break;
        case UIEventSubtypeRemoteControlNextTrack:
            // 下一曲
            break;
        case UIEventSubtypeRemoteControlPreviousTrack:
            // 上一曲
            break;
        default:
            break;
    }
}

@end
复制代码

初次建立这个工程,发现无论如何都不响应 [remoteControlReceivedWithEvent:] 方法,这时候你想工程中加入一段音频,并想办法使用代码播放一下这段音频(点击 button,调用 AVAudioPlayer 的 play) 方法,然后再重新编译应该就好了。属于玄学领域,我也不清楚为什么。

5.3D Touch 事件

Home Screen Quick Actions

使用这个功能,点击 icon 可以快速预览某些功能,并以此为入口点击进入。有两种方式来配置这个功能,一是直接使用 pilst 文件进行静态配置;另外一种是使用代码来动态配置。

(1)使用 plist 文件配置

所有事件的数组叫做 UIApplicationShortcutItems,每个事件叫做 UIApplicationShortcutItem,每个 UIApplicationShortcutItem 中包含的信息如下:

系统默认最多只能添加 4 个 item(不算“分享”这个 item),即使你添加了很多,最多也只显示四个。如果你想添加更多,可以效仿一下支付宝的做法,即在预览 view 中添加对应功能,这里就不贴图了。

Key Description Required
UIApplicationShortcutItemType 事件的标识 YES
UIApplicationShortcutItemTitle 事件标题 YES
UIApplicationShortcutItemSubtitle 事件子标题 NO
UIApplicationShortcutItemIconType 系统定义的 icon 类型 NO
UIApplicationShortcutItemIconFile icon 图片,以单一颜色,35*35 大小展示,如果设置了这个属性,UIApplicationShortcutItemIconType 属性将不起作用 NO
UIApplicationShortcutItemUserInfo 传递信息的 dictionary NO

你可以通过使用 plist 文件配置这些东西,例如下面这样:

(2) 使用代码动态配置

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    // 创建 item
    UIApplicationShortcutIcon *cameraIcon = [UIApplicationShortcutIcon iconWithTemplateImageName:@"camera"];
    UIApplicationShortcutIcon *mosaicIcon = [UIApplicationShortcutIcon iconWithTemplateImageName:@"mosaic"];
    
    UIMutableApplicationShortcutItem *cameraItem = [[UIMutableApplicationShortcutItem alloc] initWithType:@"event://camera" localizedTitle:@"Camera" localizedSubtitle:nil icon:cameraIcon userInfo:nil];
    UIMutableApplicationShortcutItem *mosaicItem = [[UIMutableApplicationShortcutItem alloc] initWithType:@"event://mosaic" localizedTitle:@"Mosaic" localizedSubtitle:nil icon:mosaicIcon userInfo:nil];
    
    // 放到应用中
    [UIApplication sharedApplication].shortcutItems = @[cameraItem,mosaicItem];
    
    return YES;
}
复制代码

用上述任何一种方式添加了 item 之后,效果大概是这个样子:

(3) 处理对应的事件

上述两种方式是配置事件入口,这里是响应对应事件。在 AppDelegate 中系统提供了一个代理方法:

- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL))completionHandler {
    if (shortcutItem) {
        if ([shortcutItem.type isEqualToString:@"event.responser.test://camera"]) {
            // 跳转到照相页面
        } else if ([shortcutItem.type isEqualToString:@"event.responser.test://mosaic"]) {
            // 跳转到马赛克页面
        }
    }
    
    if (completionHandler) {
        completionHandler(YES);
    }
}
复制代码

Peek and Pop

只需要两步,第一步是在当前的 View Controller 中实现 UIViewControllerPreviewingDelegatedelegate;第二部是在预览 view controller 实现 previewActionItems delegate。具体代码如下:

/** 当前 View Controller */
@interface TableViewController () <UIViewControllerPreviewingDelegate>
@property (nonatomic, strong) NSArray *dataArray;
@end

@implementation TableViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.dataArray = @[@"依然范特西",@"十一月的肖邦",@"七里香",@"叶惠美",@"八度空间"];
}

#pragma mark - Table view data source

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 5;
}


- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"CellIdentifier" forIndexPath:indexPath];
    cell.textLabel.text = self.dataArray[indexPath.row];
    return cell;
}

#pragma mark - UIViewControllerPreviewingDelegate

/** peek 操作,预览模式 */
- (UIViewController *)previewingContext:(id<UIViewControllerPreviewing>)previewingContext viewControllerForLocation:(CGPoint)location {
   // 这里没有使用 indexPath,实际项目中,需要根据 indexPath 选择对应的 VC
    NSIndexPath *indexPath = [self.tableView indexPathForCell:(UITableViewCell *)[previewingContext sourceView]];
    PreViewController *preViewController = [[UIStoryboard storyboardWithName:@"Main" bundle:nil]
                                            instantiateViewControllerWithIdentifier:@"PreViewController"];
    preViewController.preferredContentSize = CGSizeMake(0.0f, 400.0f);
    CGRect rect = CGRectMake(0, 0, 375.0f, 40);
    previewingContext.sourceRect = rect;
    return preViewController;
}

/** pop 操作,继续按压 */
- (void)previewingContext:(id<UIViewControllerPreviewing>)previewingContext commitViewController:(UIViewController *)viewControllerToCommit {
    PreViewController *preViewController = [[UIStoryboard storyboardWithName:@"Main" bundle:nil]
                                            instantiateViewControllerWithIdentifier:@"PreViewController"];
    [self.navigationController pushViewController:preViewController animated:YES];
}
@end

/** 预览 view controller */
@implementation PreViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor clearColor];
}

- (NSArray<id<UIPreviewActionItem>> *)previewActionItems {
    UIPreviewAction *shareAction = [UIPreviewAction actionWithTitle:@"分享" style:UIPreviewActionStyleDefault handler:^(UIPreviewAction * _Nonnull action, UIViewController * _Nonnull previewViewController) {
        // 分享
    }];
    
    UIPreviewAction *markAction = [UIPreviewAction actionWithTitle:@"标记" style:UIPreviewActionStyleDefault handler:^(UIPreviewAction * _Nonnull action, UIViewController * _Nonnull previewViewController) {
        // 标记
    }];
    
    return @[shareAction, markAction];
}
复制代码

实现之后效果大概是这个样子:

Force Properties

3D Touch 所提供的最后一个功能,就是可以感应按压力度,转化到实际应用中,就是下面这张图:



根据按压程度不同,颜色有深有浅。我们可以通过 UITouch 对象获取到这个值,使用这个值做一些其他操作:

-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSArray *arrayTouch = [touches allObjects];
    UITouch *touch = (UITouch *)[arrayTouch lastObject];
    CGFloat force = touch.force;
    NSLog(@"压力值为 %f",force);
}
复制代码

6.自定义手势

有些时候,系统提供的手势已经不能满足我们的需求了,这时候我们可以根据需要,自定义一个手势。自定义手势的一个思路就是:继承 UIGestureRecognizer 类,然后重写那几个 touches 方法,在里面处理手势识别器的状态,即从 began -> end 的状态。

下面是效仿大神,写的一个“点击对角线两个点”才能响应的手势:

typedef NS_OPTIONS(NSInteger, TouchArea) {
    other = 0,
    topLeft = 1,
    topRight = 1 << 1,
    bottomLeft = 1 << 2,
    bottomRight = 1 << 3,
    
    bingoOne = topLeft | bottomRight,
    bingoTwo = topRight | bottomLeft,
    none = other,
};

@interface TapDiagonalGesture()
@property (nonatomic, assign) TouchArea alreadyTouched;
@property (nonatomic, strong) NSMutableSet<UITouch *> *trackingTouches;
@property (nonatomic, strong) NSMutableDictionary <NSValue *, NSNumber *> *allTouchedArea;
@end

@implementation TapDiagonalGesture

- (instancetype)initWithTarget:(id)target action:(SEL)action {
    self = [super initWithTarget:target action:action];
    if (self) {
        _trackingTouches = [NSMutableSet set];
        _allTouchedArea = [NSMutableDictionary dictionary];
    }
    
    return self;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesBegan:touches withEvent:event];
    for (UITouch *touch in touches) {
        TouchArea touchArea = [self toucheAreaForPosition:[touch locationInView:self.view] inView:self.view];
        if (touchArea == other) {
            self.state = UIGestureRecognizerStateFailed;
            return;
        }
        
        [self.trackingTouches addObject:touch];
        NSValue *value = [NSValue valueWithNonretainedObject:touch];
        self.allTouchedArea[value] = @(touchArea);
    }
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesMoved:touches withEvent:event];
    for (UITouch *touch in touches) {
        if (![_trackingTouches containsObject:touch]) {
            continue;
        }
        
        NSValue *value = [NSValue valueWithNonretainedObject:touch];
        TouchArea touchArea = self.allTouchedArea[value].integerValue;
        TouchArea currentArea = [self toucheAreaForPosition:[touch locationInView:self.view] inView:self.view];
        if (currentArea == other || touchArea != currentArea) {
            self.state = UIGestureRecognizerStateFailed;
            return;
        }
    }
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesEnded:touches withEvent:event];
    for (UITouch *touch in touches) {
        if (![_trackingTouches containsObject:touch]) {
            continue;
        }
        
        NSValue *value = [NSValue valueWithNonretainedObject:touch];
        TouchArea touchArea = self.allTouchedArea[value].integerValue;
        TouchArea currentArea = [self toucheAreaForPosition:[touch locationInView:self.view] inView:self.view];
        if (currentArea == other || touchArea != currentArea) {
            self.state = UIGestureRecognizerStateFailed;
            return;
        }
        
        [self.trackingTouches removeObject:touch];
        self.allTouchedArea[value] = nil;
        self.alreadyTouched |= currentArea;
        if (self.alreadyTouched == bingoOne ||
            self.alreadyTouched == bingoTwo) {
            self.state = UIGestureRecognizerStateRecognized;
        }
    }
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesCancelled:touches withEvent:event];
    for (UITouch *touch in touches) {
        if (![_trackingTouches containsObject:touch]) {
            continue;
        }
        
        self.state = UIGestureRecognizerStateCancelled;
    }
}

- (void)reset {
    [super reset];
    [self.trackingTouches removeAllObjects];
    [self.allTouchedArea removeAllObjects];
    self.alreadyTouched = none;
}

- (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    return YES;
}

#pragma mark - Private Method

- (TouchArea)toucheAreaForPosition:(CGPoint)point inView:(UIView *)view {
    CGPoint origin = view.bounds.origin;
    CGSize size = view.frame.size;
    int horizontoalArea = [self areaForValue:point.x rangeBegin:origin.x rangeLength:size.width];
    int verticalArea = [self areaForValue:point.y rangeBegin:origin.y rangeLength:size.height];
    
    if (horizontoalArea == 0 || verticalArea == 0) {
        return other;
    }
    
    int shifts = (horizontoalArea > 0 ? 1 : 0) + (verticalArea > 0 ? 2 : 0);
    return 1 << shifts;
}

- (int)areaForValue:(CGFloat)value
         rangeBegin:(CGFloat)rangeBegin
        rangeLength:(CGFloat)rangeLength {
    CGFloat threadShold = MAX(40, rangeLength / 3);
    if (rangeLength < threadShold * 2) {
        return 0;
    }
    
    if (value <= rangeBegin + threadShold) {
        return -1;
    }
    
    if (value >= rangeBegin + rangeLength - threadShold) {
        return 1;
    }
    return 0;
}
@end
复制代码

在一个 view 上面添加这个手势之后,同时点击这个 view 对角线两个点(左上 & 右下;左下 & 右上),便会响应对应的 action。

总结

上面讲述了大部分事件以及其原理,了解了之后,对我们的开发很有帮助。当然,iOS 11 新增了 Drag and Drop 功能,这个功能大多在 Mac 或者 iPad 上面用,在 iPhone 上也可以使用,但使用的功能有限,这里就不多说了。

针对上面的内容,有问题可以提出,我会尽快修改。

参考文献

  1. Touches, Presses, and Gestures
  2. iOS事件处理之Hit-Testing
  3. UIKit: UIResponder


你可能感兴趣的:(iOS 中的事件响应与处理)