iOS 平台基础系列之一 手势(Gesture)和响应链(Responder Chain)

本文最后更新于 2021年4月4日 晚上

通过读书外带写代码, 了解 iOS 平台上和触摸相关的编程内容, 许多内容都来自 Pro iPhone Development with Swift 4 一书.

本系列共 n 篇文章, 旨在提高 iOS 平台上的编程技巧, 具有一定的平台相关性. 由于手势处理是所有移动端开发的重点内容, 故放到首轮讲解.

并且由于 iOS 平台上由于源码封闭, 很难找到这些原理性讲解, 且许多都只是表面现象, 难达本质, 所以不求精, 只求准.

简介

在 iOS 平台上可以针对非常多的手势进行处理, 比如短按, 长按, 侧滑, 3D touch 等等. 下面就来看有关触摸编程的内容, 并实现一些例子.

术语解析

手势(gesture): 手势指的是用户从触摸屏幕到结束触摸所生成的事件, 有若干类型. 手势通过一系列事件(事件流)的形式传递给系统.

手势识别器(gesture recognizer): 是一类对象, 它们可以接收并处理用户手势事件流.

响应链(Responder Chain)

用户手势通过事件流传递进入系统, 而事件流通过响应链(或响应者链)进行实际传递, 故需要了解响应链的内容, 从而理解如何进行手势处理(另外在 iOS 平台上的响应链和 macOS 平台上是类似的).

在一个运行的程序中, 响应链由一系列能够响应用户事件的响应者对象构成, 响应者对象是 UIResponder 的子类对象. UIView, UIViewController, UIApplication, 甚至是 UIApplication 的代理对象(通常是名为 AppDelegate 类的对象)均继承自 UIResponder.

程序启动后, 首先在 main 函数中创建了 UIApplication 对象和它的代理对象, 而后程序代理对象持有 window, window 持有根视图控制器, 这一条线上的所有对象都是 UIResponder, 另外所有的视图和视图控制器都是 UIResponder, 可以看到, 实际上响应者充斥着整个程序.

事件响应过程

顾名思义, 响应链就是响应者组成的一个链式结构.

比如在正常情况下, 第一个响应者就是当前用户进行交互的对象, 这个响应者也成为响应链的 head, 后续响应者和它一起就构成了一个链条(比如它的父视图–>父视图所处的视图控制器, 如此往上推, 最后到达 UIApplication 对象).

事件的处理: 指的是在某个响应者上重写了 UIResponder 的触摸处理方法.

当某个响应者不处理某个事件时(不重写 UIResponder 的触摸处理方法), 该事件就会沿响应链向上传递, 而如果某个响应者能够处理该事件, 则它就将该事件”消费”掉, 从而终止事件的传递. 当然还有一种情况就是响应者接收到事件并进行处理, 而后仍然将事件进行传递.

1
2
3
4
5
6
7
// 比如在某个响应者上要将开始触摸的事件向后传递, 则需要实现:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("视图控制器开始触摸")
_msgLabel.text = "开始触摸"
updateLabelsFrom(touch: touches.first, allTouches: event?.allTouches)
next?.touchesBegan(touches, with: event)
}

通常开发者面对的都是前一种情况.

如果事件经过整个响应链都未被处理, 则该事件会被丢弃.

上面的过程中, 事件又是如何到达第一个响应者的呢?

事件首先被传递给 UIApplication, 而后传递给程序的 UIWindow 对象, 然后由 UIWindow 对象决定事件的初始响应者(initial responder):

  • 对于触摸事件(手势): UIWindow 对象可以检测用户手势发生在哪个视图上, 而后让该视图作为初始响应者, 开始事件传递过程. 如果该视图或其父视图上有手势识别器, 当触发手势识别器的处理方法后, 事件便不再向后传播. 否则事件会沿响应链传播, 直到遇到处理方法或可以处理的手势识别器.

  • 对于传感器事件(比如摇一摇)或远程控制设备事件, 则直接交给指定的第一响应者(first responder).

如果初始响应者没有处理这个事件, 则沿响应链向上传递, 而当整个链都没有处理该事件, 事件就又回到了 UIWindow 上, 而后被传递给 UIApplication, UIApplication 对象会直接将这个事件交给它的代理对象. 如果 app 代理对象也没有处理这个事件(代理对象不是继承自 UIResponder, 而是只实现了 UIApplicationDelegate 协议的话), 则事件就被丢弃掉了.

总结起来事件的传递过程是这样的: 事件–>UIApplication(不处理)–>UIWindow(不处理)–>中间处理过程(可能处理)–>沿控制器链条到达 UIWindow(可能处理)–>UIApplication(可能处理, 只要是自定义的话)–> app 代理对象(可能处理)–>事件被丢弃

理解这套模式, 有助于解决许多编程中遇到的问题, 因为有些视图自己就默认带有手势识别器, 如果不知道这个模式, 很难去实现某些复杂效果.

事件传递

在处理用户手势过程中, 如果某个手势识别器拦截了事件, 但没有进行实际处理的话, 实际后续响应者就无法收到手势, 故在某些时候需要手动将事件传递到下一个响应者:

1
2
3
4
5
if 能够处理事件 {
进行事件处理
} else {
将事件传递给下一个响应者
}

并且在处理事件的时候应该遵循:

  • 如果事件只牵扯特定视图, 则事件处理代码在视图中实现

  • 否则事件处理代码应该写到包含若干个受影响的视图所在的视图控制器类代码中.

UIResponder 中 Touch 事件处理的方法

当用户触摸开始后, 系统就会按照之前讲的方式将事件传递给第一个响应者(如果没有手势识别器), 此时该响应者的方法 touchesBegan 会被调用, 比如一个视图控制器上:

1
2
3
4
5
6
7
8
9
10
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
// 如果返回 2 则表示连续触摸了两次
let numTaps = touch.tapCount
// 如果返回 2 则表示有两个手指在同时触摸
let numTouches = event?.allTouches?.count
// 获取自己关注的某个视图上的触摸, myTouches 为 Set<UITouch>? 类型, 其中每一个 touch 对象就代表一个手指的触摸状况.
let myTouches = event?.touches(for: self.view)
}
}

当任意手指离开屏幕, 都会触发 touchesEnded 方法调用.

此外, 如果在触摸过程中有外部事件打断触摸过程(比如来电话), 会触发 touchesCancelled 方法调用, 而之后不会出现 touchesEnded 调用. 故有时为了触摸的一致性, 需要在 touchesCancelled 实现一些操作过程中中断的处理.

示例程序

下面来创建一个可以显示当前触摸有多少个手指, 多少次触摸, 以及 3D Touch 按压力度的程序.

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
//
// GestureDemoViewController.swift
// DemoApp
//
// Created by raymond Peng on 2019/1/13.
// Copyright © 2019 raymond Peng. All rights reserved.
//

import UIKit
import SnapKit

class GestureDemoViewController: UIViewController {
/// 展示信息
private let _msgLabel = UILabel()
/// 展示多少次触摸
private let _tapsLabel = UILabel()
/// 展示多少个手指点击
private let _touchesLabel = UILabel()
/// 展示 3D Touch 的力度信息
private let _forceLabel = UILabel()

override func viewDidLoad() {
super.viewDidLoad()
layoutStack()
resetLabelText()

let gest = UITapGestureRecognizer(target: self, action: #selector(tapGesture))
view.addGestureRecognizer(gest)
}

@objc private func tapGesture() {
print("手势识别器的点击事件")
}

private func resetLabelText() {
_msgLabel.text = "无数据"
_tapsLabel.text = "无连续触摸"
_touchesLabel.text = "无手指触摸"
_forceLabel.text = "无按压力度"
_forceLabel.numberOfLines = 0
}

private func layoutStack() {
let stack = UIStackView()
stack.axis = .vertical
// 主轴上的布局
stack.distribution = .fillProportionally
// 相当于 crossAxis: 另外一个轴的布局方向
stack.alignment = .leading
view.addSubview(stack)

stack.snp.makeConstraints {
$0.center.equalToSuperview()
$0.width.equalToSuperview().multipliedBy(0.75)
}

stack.addArrangedSubview(getRowWith(title: "信息: ", contentView: _msgLabel))
stack.addArrangedSubview(getRowWith(title: "触摸次数: ", contentView: _tapsLabel))
stack.addArrangedSubview(getRowWith(title: "手指数量: ", contentView: _touchesLabel))
stack.addArrangedSubview(getRowWith(title: "按压力度: ", contentView: _forceLabel))
}

private func getRowWith(title: String, contentView: UIView) -> UIView {
let stack = UIStackView()
stack.axis = .horizontal
let label = UILabel()
label.text = title
stack.addArrangedSubview(label)
stack.addArrangedSubview(contentView)
return stack
}

private func updateLabelsFrom(touch: UITouch?, allTouches: Set<UITouch>?) {
let numTaps = touch?.tapCount ?? 0
_tapsLabel.text = "连续点击次数: \(numTaps)"
let numTouches = allTouches?.count ?? 0
_touchesLabel.text = "触摸的手指数量: \(numTouches)"
// 若有 3D Touch 支持, 则获取力度
if traitCollection.forceTouchCapability == .available {
let force = touch?.force ?? 0
let maxForce = touch?.maximumPossibleForce ?? 0
let forceString = String(format: "%.3f", force)
let maxForceString = String(format: "%.3f", maxForce)
_forceLabel.text = "按压力度: \(forceString), 支持最大力度: \(maxForceString)"
} else {
_forceLabel.text = "不支持 3D Touch"
}
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("视图控制器开始触摸")
_msgLabel.text = "开始触摸"
updateLabelsFrom(touch: touches.first, allTouches: event?.allTouches)
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
_msgLabel.text = "触摸取消"
updateLabelsFrom(touch: touches.first, allTouches: event?.allTouches)
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
_msgLabel.text = "触摸结束"
updateLabelsFrom(touch: touches.first, allTouches: event?.allTouches)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
_msgLabel.text = "手指移动"
updateLabelsFrom(touch: touches.first, allTouches: event?.allTouches)
}

}

待续.

响应链中插入有手势识别器的话, 这些手势识别器就相当于是事件拦截器的作用, 且手势识别器不会拦截 began 事件, 因为那个时候还没有手势能识别出来…

更多内容

下一篇我们来看 iOS 平台上的屏幕旋转处理.


iOS 平台基础系列之一 手势(Gesture)和响应链(Responder Chain)
https://blog.rayy.top/2019/01/13/2019-37-iOS-touches/
作者
貘鸣
发布于
2019年1月13日
许可协议