将来自不同层级结构的点击手势传递给一个视图
我有两个重叠的UIViews,它们都是同一个父视图的子视图。
后面的视图有一个UITapGestureRecognizer,前面的视图有另一个手势识别器,例如UILongPressGestureRecognizer或 UIPanGestureRecognizer。
我的目标是:
- 如果用户执行的是前视图处理的手势,前视图应该处理它;
- 如果用户执行的是另外一个手势,例如点击,后面的视图应该接收它;
- 前视图不应该阻塞它不处理的手势。
我最初的问题是拖拽手势引起的,但同样的问题也会出现在长按手势上。
我尝试将cancelsTouchesInView设置为false,也尝试使用UIGestureRecognizerDelegate,但前视图仍然在命中测试中获胜。因此,在重叠区域内点击时,后面的视图的点击手势不会被触发。
下面是一个在前视图上使用长按手势的最小示例:
import SwiftUI
import UIKit
struct TestGestureConflict: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> TestGestureConflictViewController {
TestGestureConflictViewController()
}
func updateUIViewController(_ uiViewController: TestGestureConflictViewController, context: Context) {}
}
final class TestGestureConflictViewController: UIViewController {
private let tapView = UIView()
private let longPressView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
configureTapView()
configureLongPressView()
configureGestures()
}
private func configureTapView() {
tapView.translatesAutoresizingMaskIntoConstraints = false
tapView.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.35)
tapView.layer.borderColor = UIColor.systemBlue.cgColor
tapView.layer.borderWidth = 2
view.addSubview(tapView)
NSLayoutConstraint.activate([
tapView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
tapView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
tapView.widthAnchor.constraint(equalToConstant: 120),
tapView.heightAnchor.constraint(equalToConstant: 120)
])
}
private func configureLongPressView() {
longPressView.translatesAutoresizingMaskIntoConstraints = false
longPressView.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.35)
longPressView.layer.borderColor = UIColor.systemOrange.cgColor
longPressView.layer.borderWidth = 2
view.addSubview(longPressView)
NSLayoutConstraint.activate([
longPressView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
longPressView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
longPressView.widthAnchor.constraint(equalToConstant: 220),
longPressView.heightAnchor.constraint(equalToConstant: 220)
])
}
private func configureGestures() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGesture.delegate = self
tapView.addGestureRecognizer(tapGesture)
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
longPressGesture.cancelsTouchesInView = false
longPressGesture.delegate = self
longPressView.addGestureRecognizer(longPressGesture)
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard gestureRecognizer.state == .ended else { return }
print("tap")
}
@objc private func handleLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
print("long press began")
case .ended, .cancelled:
print("long press ended")
default:
break
}
}
}
extension TestGestureConflictViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// This is called for the long press gesture, but returning true or false
// does not make the tapView behind receive the tap in the overlapping area.
return true
}
}
在这个示例中,longPressView被添加在tapView上方。
期望行为:
- 橙色视图内的长按 → 由longPressView处理;
- 重叠的蓝色/橙色区域内的点击 → 由tapView处理。
实际行为:
- 前视图上的长按能正常工作;
- 点击无法到达后面的视图;
- cancelsTouchesInView = false也无法解决问题,因为前视图仍然是命中测试的结果。
我的理解是,这实际上并不是关于UIPanGestureRecognizer或 UILongPressGestureRecognizer的问题,而是关于UIKit的命中测试:一旦前视图被选为命中测试的视图,后面的视图就不会参与触摸分发。
对于这种情况,正确的UIKit做法是什么?
我应该用以下哪种方法来解决?
- 自定义hitTest(::) / point(inside:with:),
- 一个UIGestureRecognizerDelegate,
- 手动转发触摸事件,
- 将手势识别器附加到一个公共父视图,
- 还是改变视图层级结构?
有什么推荐的方式可以让前视图只处理特定手势,同时允许其他手势(例如点击)由后面的视图处理?
解决方案
核心问题不是手势识别冲突;而是命中测试/视图层级的问题。位于前视图后面的同级视图永远不会接收到触摸,因为UIKit会先选择一个单独的命中测试视图。cancelsTouchesInView = false 仅在触摸已经命中测试后再进行交付时生效;它不会让UIKit去搜索下面的同级视图。
最推荐的做法是 - 将手势识别器放在同一层级结构中
而不是:
parent
├── tapView (tap recognizer)
└── longPressView (long press recognizer, above)
改为:
parent
└── container
├── tapView
└── frontOverlay
或者将两个识别器附加到一个公共父视图:
let tap = UITapGestureRecognizer(
target: self,
action: #selector(handleTap)
)
let longPress = UILongPressGestureRecognizer(
target: self,
action: #selector(handleLongPress)
)
containerView.addGestureRecognizer(tap)
containerView.addGestureRecognizer(longPress)
tap.delegate = self
longPress.delegate = self
然后控制识别:
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
这是可行的原因是在同一响应者层级上附着的手势识别器可以协作。
第二种不那么推荐的做法是 -
在前视图上使用自定义手势识别器
对于你的具体情况,这通常是最简洁的做法:
- 将一个pan / 长按识别器放在公共父视图上;
- 检测它是否转变成你要的手势;
- 如果它仍然是点击手势,就让点击手势识别器获胜。
示例:
longPress.require(toFail: tap)
或者:
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer === longPress,
otherGestureRecognizer === tap {
return true
}
return false
}