在 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
| extension Zonable where Base: UIView { @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 }
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)
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 协议的事,聪明如你一定可以信手拈来,🤪