在开发 App 的过程中,最让人挠头的事之一,就是各种手势互相打架。尤其当屏幕上放了一个 UIScrollView,里面又塞进了自定义手势,一个不小心,你想要的“拖一下”变成了“滚一下”,或者反过来。今天这篇文章,就把这个问题掰开揉碎,聊聊如何让滚动视图和自定义手势好好配合。
一、准备知识:两个手势都是谁
1.1 滚动视图的平移手势
每个 UIScrollView 背后都有一个 panGestureRecognizer。当你用手指滑动屏幕时,它负责让内容跟着移动。如果你没有特殊处理,它会“霸道”地认为屏幕上所有拖动都归它管。这个手势平时很乖,但一旦遇到其他手势,它的独断专行就体现出来了:它会尝试把每一次触摸都据为己有,然后根据移动方向决定要不要滚动。
1.2 自定义手势
自定义手势就是你自己创建的 UIGestureRecognizer,比如长按、双击、左右滑动。它们的触发规则由你定,你想让它什么时候响,它就什么时候响。问题在于,自定义手势和 scrollView 的平移手势在识别过程中会互相竞争。就好比两个小朋友同时去抢一个球,谁先抢到,谁就能把玩。iOS 给出的默认规则是:大多数情况下,UIScrollView 的 pan 会赢,因为它是系统级的“大块头”。于是,你的自定义手势常常被它“劝退”,或者反过来,你把系统手势劝退了,列表滚不动了。
二、手动指定依赖:让滚动等长按先“表态”
2.1 什么叫依赖
iOS 提供了一个方法:require(toFail:),它的作用非常直接——让一个手势等待另一个手势失败以后,才允许自己开始识别。打个比方:你和同事商量好,如果同事没做成某件事,你才动手;只要同事做成了,你就永远不动。
在代码里,这句话是这样写的:
scrollView.panGestureRecognizer.require(toFail: longPress)
意思就是:scrollView 的平移手势,必须等到那个长按手势失败以后,才能开始。如果长按成功,那么平移手势就“没戏了”,滚动自然也就不会发生。
2.2 一个长按拖拽的完整例子
场景:scrollView 里有一张卡片,长按卡片可以把它拖起来,在屏幕上随便移动。拖拽的过程中,页面绝对不能滚动。手指松开以后,滚动恢复正常。
这个需求听起来不复杂,但如果不做任何处理,你会遇到两个问题:
- 手指一放上去,scrollView 就开始判断是不是要滚动,结果长按还没成功,页面先滚了。
- 长按成功后拖动,页面也会跟着滚动,导致卡片和内容乱跑。
用上面的 require(toFail:) 就正好能解决。下面是完整代码,使用的是 Swift 和 UIKit。
// 技术栈:Swift + UIKit
import UIKit
class DragCardViewController: UIViewController, UIGestureRecognizerDelegate {
let scrollView = UIScrollView()
let cardView = UIView()
lazy var longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
setupCard()
setupLongPress()
}
private func setupScrollView() {
scrollView.frame = view.bounds
// 让 contentSize 足够高,才能上下滚动
scrollView.contentSize = CGSize(width: view.bounds.width, height: 2000)
scrollView.backgroundColor = .systemGray6
view.addSubview(scrollView)
}
private func setupCard() {
cardView.frame = CGRect(x: 80, y: 300, width: 200, height: 120)
cardView.backgroundColor = .systemBlue
cardView.layer.cornerRadius = 12
scrollView.addSubview(cardView)
}
private func setupLongPress() {
// 长按 0.4 秒就算成功
longPress.minimumPressDuration = 0.4
// 把长按手势加到 scrollView 上,这样所有触摸它都能收到
scrollView.addGestureRecognizer(longPress)
longPress.delegate = self
// 核心:让 scrollView 的平移手势等待长按手势失败后再识别
scrollView.panGestureRecognizer.require(toFail: longPress)
}
// 只有触摸点在卡片内部时,才允许长按手势开始
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == longPress {
let location = gestureRecognizer.location(in: scrollView)
return cardView.frame.contains(location)
}
return true
}
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
// 长按触发,卡片放大一点,表示“拎起来了”
UIView.animate(withDuration: 0.2) {
self.cardView.transform = CGAffineTransform(scaleX: 1.08, y: 1.08)
}
case .changed:
// 手指移动,卡片跟着走
let point = gesture.location(in: self.view)
cardView.center = point
case .ended, .cancelled:
// 松手,恢复原样
UIView.animate(withDuration: 0.2) {
self.cardView.transform = .identity
}
default:
break
}
}
}
这段代码里,最核心的一行就是 scrollView.panGestureRecognizer.require(toFail: longPress)。它告诉系统:滚动手势必须等长按“认输”才能行动。长按没有成功时,比如你只是在页面上快速滑动,长按会因为触摸点不在卡片上,或者手指移动太快而失败。失败之后,滚动立刻接管,页面正常滚。长按成功时,比如你在卡片上按够了 0.4 秒,长按状态变成“成功”,滚动手势就只能一直站在那里等,永远等不到失败信号,所以就不会滚动。这时候,你的手可以在屏幕上随便划,卡片也会跟着动,页面非常稳。
2.3 为什么长按要加到 scrollView 上
你可能注意到了,我把 longPress 加到了 scrollView 上,而不是加到 cardView 上。这是为了让长按手势能收到整个 scrollView 区域内的触摸。如果只加在 cardView 上,当你的手指在卡片外面滑动时,longPress 根本不会收到触摸,它的状态会一直停留在“可能失败”的尴尬境地。而滚动手势还在等它失败呢,等半天等不到,滚动就废了。所以,我把长按加到 scrollView 上,再通过 gestureRecognizerShouldBegin 判断触摸点是否在卡片内部。这样,卡片外的触摸会让长按快速失败,滚动才能正常工作。
这个细节特别重要,不少开发者在用 require(toFail:) 时踩坑,就是因为忘了考虑“被依赖的手势是否一定能失败”。
三、冲突的其它解决办法
3.1 用手势代理拦住不该开始的手势
除了 require(toFail:),手势代理也特别常用。gestureRecognizerShouldBegin 这个方法,会在手势已经开始识别但还没正式“确认”前问一句:你确定要开始吗?你可以在里面返回 true 或 false,决定这个手势到底能不能继续。
下面这个例子是“左滑删除”卡片的效果。scrollView 上下滚动,卡片支持左右滑动,用来显示删除按钮。我们希望:上下滑动时,列表正常滚;左右滑动时,卡片跟着移动。
核心思路是:在自定义 UIPanGestureRecognizer 的代理里判断速度方向。如果横向速度更大,就允许自定义手势开始;如果纵向速度更大,就让它闭嘴,把控制权还给 scrollView。
// 技术栈:Swift + UIKit
import UIKit
class SwipeCardViewController: UIViewController, UIGestureRecognizerDelegate {
let scrollView = UIScrollView()
let card = UIView()
lazy var horizontalPan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()
setupCard()
setupPan()
}
private func setupScrollView() {
scrollView.frame = view.bounds
scrollView.contentSize = CGSize(width: view.bounds.width, height: 2000)
scrollView.alwaysBounceVertical = true
view.addSubview(scrollView)
}
private func setupCard() {
card.frame = CGRect(x: 20, y: 200, width: view.bounds.width - 40, height: 80)
card.backgroundColor = .systemGreen
card.layer.cornerRadius = 8
scrollView.addSubview(card)
}
private func setupPan() {
horizontalPan.delegate = self
card.addGestureRecognizer(horizontalPan)
}
// 关键:横向速度大于纵向速度,才允许这个手势开始
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: pan.view)
return abs(velocity.x) > abs(velocity.y)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
// 计算手指在水平方向移动的距离
let translation = gesture.translation(in: card)
switch gesture.state {
case .changed:
// 只能向左滑,并且最多滑出 100 点;右滑时不做处理
let offset = min(0, max(-100, translation.x))
card.transform = CGAffineTransform(translationX: offset, y: 0)
case .ended:
// 如果滑出的距离超过一半,就保持“打开”状态,否则弹回去
if card.transform.tx < -50 {
UIView.animate(withDuration: 0.2) {
self.card.transform = CGAffineTransform(translationX: -100, y: 0)
}
} else {
UIView.animate(withDuration: 0.2) {
self.card.transform = .identity
}
}
default:
break
}
}
}
在这个例子中,gestureRecognizerShouldBegin 就像一道闸门。当用户上下滑动时,手势速度的纵向分量大,闸门关闭,自定义手势不开始,scrollView 自己处理滚动。当用户左右滑动时,横向分量大,闸门打开,自定义手势开始工作,于是卡片跟着手滑。因为 scrollView 的 contentSize 宽度和屏幕一样宽,没有横向滚动的空间,所以即使 scrollView 的 pan 也被系统识别了,它也不会真的让界面左右移动。这样,滚动和滑动各管各的,非常清爽。
3.2 允许两个手势同时识别
还有一种思路是反着来:不去禁止某个手势,而是让它们同时工作,然后你按照移动方向去判断到底该谁干活。对应的方法是 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:),让它返回 true,两个手势就能一起识别。
这个方案听起来很自由,但实现起来比较考验逻辑。比如你要同时响应滚动手势和自定义手势,就得时刻判断当前手指到底是想滚动还是想滑卡片。万一判断错了,就会出现卡片跟着页面一起滚动、整个界面像喝醉了一样乱晃的效果。所以,除非你有特别充分的理由,否则我不太建议优先用它。
3.3 动态开关滚动
最“粗暴”的解决办法是:在某一个手势开始时,直接把 scrollView.isScrollEnabled = false 关掉,等手势结束再把它打开。比如长按拖拽的时候,在 .began 里关闭滚动,在 .ended 或 .cancelled 里恢复。
这个办法的好处是逻辑很简单,不需要理解复杂的依赖关系。坏处是,如果用户已经开始滚动了,再长按的话,滚动会被强行打断,视觉上可能有一点点生硬。另外,如果你在滚动动画进行中关了 isScrollEnabled,动画也可能突然停下来,所以需要自己做好状态管理。
四、应用场景
这种手势协同的需求其实到处都是。举几个例子:
- 聊天软件里的消息列表:上下滑动看记录,长按某条消息可以拖拽或弹出菜单。如果长按的时候列表还在滚,操作就会很别扭。
- 地图 App:拖动地图是滚动手势,长按地图上的某个点可以放置标记。这两个手势必须分开。
- 卡片式设计:上下滚动翻列表,左右滑动卡片可以切换或删除。就像你手机系统里的通知卡片一样。
- 社交 App 里的左滑删除:列表滚动和左滑删除按钮,也是经典冲突场景。
只要你的界面上同时有“滚动”和“自定义操作”,就离不开今天说的这些技巧。
五、优缺点分析
5.1 使用 require(toFail:) 的优点和缺点
优点:逻辑清晰,把顺序控制交给系统,代码写起来很简单。它能精确保证“只有另一个手势失败,我才开始”,非常适合长按拖拽这种场景。
缺点:它要求被依赖的手势必须有可能失败。如果你的被依赖手势永远不失败,比如一个加在很小范围上的手势,而触摸又总不在那个范围里,那么依赖它的滚动手势就会一直等,结果整个页面滚动就“罢工”了。所以,用之前一定要想清楚:另一个手势在什么时候会失败?它会失败吗?
5.2 使用手势代理的优缺点
优点:灵活,可以在手势开始前做各种条件判断,比如判断方向、判断位置。它不会像 require(toFail:) 那样把两个手势彻底绑死,而是给了你更大的控制权。
缺点:你需要自己保证各种边界情况。比如手指速度恰好很斜怎么办?手势在什么状态开始?如果多个自定义手势都需要代理,还要区分它们,代码会稍微变长。
5.3 同时识别的优缺点
优点:理论上可以做到非常丰富的交互,比如一只手操作多个控件。
缺点:状态管理复杂,容易出 bug。两个手势同时识别后,事件回调会交叉,你需要维护额外的标记来区分当前的行为,不然很容易“翻车”。
5.4 动态开关滚动的优缺点
优点:实现最快,最暴力,适合快速验证。
缺点:体验不够细腻,可能中断正在进行的滚动,而且如果滚动状态没恢复,会留下“页面滚不动”的 bug。
六、注意事项
require(toFail:)不要乱用。它只能让一个手势等待另一个手势失败,不能反过来。如果你发现“等待失败”的手势永远不行动,先检查被依赖的手势是否真的会失败。- 手势代理方法里要区分是哪个手势,特别是同时存在多个自定义手势的时候。用
if gestureRecognizer == longPress这样的方式做判断。 - 坐标转换最容易出错。在
gestureRecognizerShouldBegin里取触摸点时,要明确location(in:)传的是哪个视图。比如长按加到 scrollView 上,就传scrollView,然后和cardView.frame比较。大小写、父视图、子视图的关系,都会影响结果。 - 不要看到“同时识别”就兴奋。大部分情况下,我们想要的是“手势之间互相理解”,而不是“同时乱跑”。同时识别用好了是神技,用不好就是灾难。
- 一定要考虑系统手势,比如 iOS 自带的“返回上一页”边缘滑动。如果你在 UINavigationController 里使用 scrollView,边缘返回手势也可能和你的自定义手势冲突,这种时候往往还需要调用
require(toFail: navigationController!.interactivePopGestureRecognizer!)之类的代码。 - 别忘了在合适的地方保存状态。比如左滑删除的卡片,如果用户在卡片被“推开”后又去滚动列表,是不是需要先把卡片收起来?这些业务细节都要自己设计。
七、总结
手势冲突是 iOS 开发里绕不开的一个坎。好在系统给了我们不少工具:require(toFail:) 能指定依赖关系,手势代理能做前置判断,同时识别能打开自由模式,还有 isScrollEnabled 这样的“大招”兜底。这篇文章用两个完整的例子,聊了滚动手势和自定义手势打架的常见场景以及对应解法。希望你再遇到“滚动视图里的拖拽、滑动、长按”时,能够心里有底,知道从哪个方向去排查和解决。
以后写代码时,遇到手势冲突,别急着硬调。先深吸一口气,想想是“顺序”的问题,还是“方向”的问题,然后用合适的手势依赖或代理方法去解决。毕竟,让手势之间互相谦让,比让它们互相比力气,要优雅得多。
评论
围绕“手势识别与滚动视图协同:UIScrollView的panGestureRecognizer与自定义手势依赖关系的手动指定以及冲突时的解决方案”参与讨论