在 Apple 提供的 UI 开发套件中,UIButton 是一个比较特殊的存在。在系统默认样式下,当用户点击时会有一个文本或者图片透明度变化的高亮效果。这个简单的交互行为能够一定程度上提升用户的使用体验,因为它含蓄地提醒了某个功能区的响应范围。不过遗憾的是这个特性并没有推广开来,UIButton 自身也是缺胳膊少腿的。而隔壁 Google 在自家 App 上已经把它玩出花儿来了,君不见那 Gmail 按钮按下时水波已经荡漾在了用户心头,而自家“孩子”却依旧像是事不关己油盐不进?

好好好!既如此,那就不妨让我们花点时间完成设计师想要的点点交互效果。

接口设计

按照之前的规划,我们打算让所有的 UIView 都具备这个接口,因此把它做为扩展方法再适合不过了。这里有提到目前主流的添加扩展方法的套路,此处就不再赘述了,于是就有了下面的这样一段接口声明:

1
2
3
extension Zonable where Base: UIView {
func onTouchUpInside(_ action: @escaping () -> Void) {}
}

调用者只需要将事件的处理行为通过闭包参数传入即可。更近一步,UIButton 的 Target-Action 模式在使用过程中是支持移除的。虽然在实际的开发中很少用到相关 API,但从完备性上来看应当支持,于是我们的接口则可以变为这样:

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

protocol Removable {
var isRemoved: Bool { get }

func remove()
}

extension Zonable where Base: UIView {
@discardableResult
func onTouchUpInside(_ action: @escaping () -> Void) -> some Removable {}
}

注意,此处使用 some 显式告知编译器使用 Opaque Return Type 而非 Existential Type,这能让我们在 Swift 6 到来时省却处理编译错误的工作。当接口使用者需要移除事件时,只需调用返回值的 remove 方法即可。

至此,它已经能够胜任绝大多数的运用场景了。不过,再细想一下,TouchUpInside 的语义是按下并且在视图范围内抬手时触发的事件,那么在界面滚动场景下就很容易被误触了。所以,我们还需要一个角色去协调事件的取消逻辑,并且提供超出视图边界则视为取消的默认实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protocol MovingCancellable {
func shouldCancel(in view: UIView, began: CGPoint, moved: CGPoint) -> Bool
}

struct OutOfBoundsCanceller: MovingCancellable {
func shouldCancel(in view: UIView, began: CGPoint, moved: CGPoint) -> Bool {
!view.bounds.contains(moved)
}
}

@discardableResult
func onTouchUpInside(
canceller: some MovingCancellable = OutOfBoundsCanceller(),
_ action: (() -> Void)?
) -> some Removable {}

而对于界面滚动的场景,我们提供一个滑动阈值取消器即可:

1
2
3
4
5
6
7
struct OutOfValueCanceller: MovingCancellable {
let value: CGFloat

func shouldCancel(in view: UIView, began: CGPoint, moved: CGPoint) -> Bool {
abs(moved.x - began.x) > value || abs(moved.y - began.y) > value
}
}

功能实现

在不改变视图层级的前提下,事件触发的载体我们自然而然地会想到 UIGestureRecognizer。它可以让我们知道手势触发的各个阶段,并且天然绑定了响应的视图,这也为后续做高亮效果提供了便捷。不过,系统内置的手势识别器无法满足需求,需要我们自定义。为了提供效率,没有必要每一个 action 都添加一个手势,这就要求自定义手势支持触发多个 action 并且支持移除某个 action。以上林林总总,归结于如下这段代码

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
// MARK: - TouchUpInside Action
extension Zonable where Base: UIView {
/// Add touchUpInside event action like UIButton with highlighted status.
///
/// - Parameters:
/// - canceller: cancel touch up inside event when touches moved. New value will override old value.
/// - action: touchUpInside event handler.
/// - Returns: A removable token to remove event handler.
@discardableResult
func onTouchUpInside(
canceller: some MovingCancellable = OutOfBoundsCanceller(),
_ action: (() -> Void)?
) -> some Removable {
var modified: Bool = false
if !base.isUserInteractionEnabled {
base.isUserInteractionEnabled = true
modified = true
}

let gesture = base.gestureRecognizers?.first(
where: { $0 is __Z_TouchUpInsideGR }
) as? __Z_TouchUpInsideGR ?? {
let value = __Z_TouchUpInsideGR(canceller: canceller)
value.onBegan = {
guard let view = $0.view else { return }
view.alpha *= 0.5
}
value.onCancelled = {
guard let view = $0.view else { return }
view.alpha *= 2
}

// Add default highlighted status.
let highlightAction = _Action(handler: value.onCancelled)
value.append(action: highlightAction)
base.addGestureRecognizer(value)
return value
}()
gesture.canceller = canceller
let action = _Action { _ in action?() }
gesture.append(action: action)
return _Remover { [weak base, weak gesture] in
gesture?.remove(action: action)

// Destory gesture if user added actions removed.
if let gesture, gesture.actions.count == 1 {
base?.isUserInteractionEnabled = !modified
base?.removeGestureRecognizer(gesture)
}
}
}

private final class _Remover: Removable {
let handler: () -> Void
init(handler: @escaping () -> Void) {
self.handler = handler
}

var isRemoved = false

func remove() {
guard !isRemoved else { return }
isRemoved = true
handler()
}
}

private typealias _Handler = (__Z_TouchUpInsideGR) -> Void

private struct _Action: Equatable {
static func == (lhs: _Action, rhs: _Action) -> Bool { lhs.id == rhs.id }

let id = UUID().uuidString
let handler: _Handler?
}

private final class __Z_TouchUpInsideGR: UIGestureRecognizer, UIGestureRecognizerDelegate {
var onBegan: _Handler?
var onCancelled: _Handler?
var canceller: MovingCancellable
private(set) var actions: [_Action] = []
init(canceller: some MovingCancellable) {
self.canceller = canceller
super.init(target: nil, action: nil)
delegate = self
cancelsTouchesInView = false
addTarget(self, action: #selector(_action))
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
_start = touches.first?.location(in: view) ?? .zero
state = .began
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
guard let view = view, let point = touches.first?.location(in: view) else { return }
if canceller.shouldCancel(
in: view,
began: _start,
moved: point
) { state = .cancelled }
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
state = .cancelled
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
state = .ended
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool { true }

func append(action: _Action) {
actions.append(action)
}

func remove(action: _Action) {
guard let idx = actions.firstIndex(where: { $0 == action }) else { return }
actions.remove(at: idx)
}

@objc
private func _action(sender: UIGestureRecognizer) {
switch sender.state {
case .began: onBegan?(self)
case .possible, .changed: break
case .cancelled, .failed: onCancelled?(self)
case .ended: actions.forEach { $0.handler?(self) }
@unknown default: break
}
}

private var _start: CGPoint = .zero
}
}

总结

这个功能点很简单,但我们依旧经历了需求分析、接口设计与实现三个阶段。理清思路之后,每个步骤都显得游刃有余。而在实际的工作当中,我们常常忽略掉这样的一个过程,就会在编码完成之后的测试阶段发现这样或者那样的接口设计与实现上的问题。或许这就是 TDD 的魅力,让我们在动手之前仔细斟酌方案的全面性,极大程度上避免了实施过程中的沉没成本。话说回来,这个接口还存在一个问题,那就是高亮效果是内部定死无法自定义的。如果我们谨遵 POP 思想去扩展接口的话,无非就是一个 Highlighted 协议的事,聪明如你一定可以信手拈来,🤪