在使用 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 接收到一个事件时,处理过程大体如下:
当你通过一个动作(触摸/摇晃/线控)等触发一个事件,这时候会唤起处于休眠状态的 cup。
事件会通过使用
IOKit.framework
来封装成IOHIDEvent
对象。IOKit.framework
是一个系统框架的集合,用来驱动一些系统事件。IOHIDEvent
中的 HID 代表 Human Interface Device,即人机交互驱动。然后系统通过 mach port(IPC 进程间通信) 将
IOHIDEvent
对象转发给 SpringBoard.app。SpringBoard.app 是 iOS 系统桌面 App,它只接收按键、触摸、加速、接近传感器等几种 Event。SpringBoard.app 会找到可以响应这个事件的 App,并通过 mach port(IPC 进程间通信) 将
IOHIDEvent
对象转发给这个 App。前台 App 主线程 Runloop 接收到 SpringBoard.app 转发过来的消息之后,触发对应的 mach port 的 Source1 回调
__IOHIDEventSystemClientQueueCallback()
。Source1 回调内部触发了 Source0 回调
__UIApplicationHandleEventQueue()
。- Source0 回掉内部,将
IOHIDEvent
对象转化为UIEvent
。 - 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
中,UIView
、UIViewController
和 UIApplication
这些类都是继承自 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
这个分类中,定义了 inputView
和 inputAccessoryView
两个输入视图,样式分别如下:
设置了 UITextView
的 inputView
属性之后,将不再弹出键盘,弹出的是自定义的 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 中实现 UIViewControllerPreviewingDelegate
delegate;第二部是在预览 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 上也可以使用,但使用的功能有限,这里就不多说了。
针对上面的内容,有问题可以提出,我会尽快修改。
参考文献
- Touches, Presses, and Gestures
- iOS事件处理之Hit-Testing
- UIKit: UIResponder