在开发 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 这样的“大招”兜底。这篇文章用两个完整的例子,聊了滚动手势和自定义手势打架的常见场景以及对应解法。希望你再遇到“滚动视图里的拖拽、滑动、长按”时,能够心里有底,知道从哪个方向去排查和解决。

以后写代码时,遇到手势冲突,别急着硬调。先深吸一口气,想想是“顺序”的问题,还是“方向”的问题,然后用合适的手势依赖或代理方法去解决。毕竟,让手势之间互相谦让,比让它们互相比力气,要优雅得多。