在使用 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 接收到一个事件时,处理过程大体如下:

iOS 事件响应

  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层级图

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

view层级树状图

在上面图层树中,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:] 方法实现大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (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:] 方法实现。例如下图中的情况:

扩大touch区域

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (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:] 方法实现。

转化为模型如下图:

ScrollView

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

1
2
3
4
5
6
7
- (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状态转换

系统为 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:] 方法,手势识别失败。

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

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

3. Response Chain

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

1
2
3
4
5
6
7
8
9
10
11
- (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 对象保存了事件的相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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 找到的视图或者第一响应者,如果两者都能处理,则传递给下一个响应者,之后依次传递。官方给出了一个传递过程图,我就懒得画了:

Responder chains in an app

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

4.UIResponder

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

确定第一响应者

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#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 两个输入视图,样式分别如下:

inputView

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

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

复制粘贴相关

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

复制黏贴菜单

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

1
2
3
4
5
6
7
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
if (action == @selector(copy:)) {
// 为你所欲为
}

return YES;
}

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

1
2
3
4
5
6
7
- (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] 方法。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 自定义视图
@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.手势类使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// 创建一个系统手势或者自定义手势,添加到一个 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 的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

@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,主要做的几件事情我已经用注释标出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@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 文件配置这些东西,例如下面这样:

plist 配置 3D touch

(2) 使用代码动态配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (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 之后,效果大概是这个样子:

3dtouch

(3) 处理对应的事件

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

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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 中实现 UIViewControllerPreviewingDelegate delegate;第二部是在预览 view controller 实现 previewActionItems delegate。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

/** 当前 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];
}

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

peek_and_pop

Force Properties

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



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

1
2
3
4
5
6
7
-(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 的状态。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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