UIScrollView与导航控制器滑动返回手势冲突问题原因与解决方案

4,557 阅读6分钟

前言

看标题就知道,这是一个老生常谈的问题了,网上有很多相关的文章和讨论。但是我发现大部分文章都只是给出一个解决方案,并没有解释为什么会冲突,更不要说解决冲突的原理了。

正好我最近有接触到这一块,所以在这里总结一下,希望可以讲清楚以下问题:

  • 1.为什么在一些页面里,滑动返回会失效?
  • 2.如何解决滑动返回失效问题?

问题定义与复现

我们先定义一下问题:

在页面中有一个可以横向滑动的UIScrollView(或其子类如UITableView、UICollectionView、etc), 当我们从页面左侧边缘向右滑动时:

我们期待的效果:页面跟随手势返回,UIScrollView不滚动

实际可能的表现为:仅UIScrollView横向滚动,页面无法返回。

期待的效果和实际表现不一致,这就是问题了!

接下来开始写demo来复现。

  • 1.新建一个工程
  • 2.修改Main.storyboard如下:

这是一个很简单的demo,所以直接在storyboard上改了,看起来更直观。

进入app后,看到ViewController,点击Jump VC会push到SecondViewController。

SecondViewController里,我通过代码添加了一个可横向滚动的UIScrollView,完整代码如下

/// SecondViewController.swift
import UIKit

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "SecondVC"
        self.configSubvews()
    }
    
    func configSubvews() {
        view.addSubview(scrollView)
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
        ])
    }
    
    lazy var scrollView: UIScrollView = {
        let view = UIScrollView()
        let contentView = UIView(frame: CGRect(x: 0, y: 0, width: 1000, height: 1000))
        contentView.backgroundColor = .gray
        view.addSubview(contentView)
        // 设置contentSize.width大于scrollView的width,即可横向滚动
        view.contentSize = CGSize(width: 1000, height: 1000)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
}

编译运行,你会发现在SecondViewController里,页面侧滑返回依然有效!

这和我们的设想不符合呀,而且在真实的项目里,确实是可以复现问题的,为什么在这个demo上就正常了嘞?

手势不冲突了,爷的青春结束了

其实很简单,在大部分实际项目中,我们一般都会修改navigationController.interactivePopGestureRecognizer的代理,这主要是为了解决在导航栏隐藏或者vc的rightBarButtonItem设置了值时,无法滑动返回的问题,这里不做展开。

总之,我们的demo之所以正常,是因为我们没有设置过interactivePopGestureRecognizer的delegate, 所以为了复现问题,我们来模拟真实项目,修改ViewController的代码如下:

import UIKit

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "Demo"
        // 设置代理为自身
        self.navigationController?.interactivePopGestureRecognizer?.delegate = self
    }
    
}

extension ViewController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        // 如果当前只有一个vc在栈中,侧滑返回必须禁止,否则会导致页面卡死
        return nav.viewControllers.count > 1
    }
}

这里我们简单的把interactivePopGestureRecognizer的delegate设置为ViewController自身,并且实现了gestureRecognizerShouldBegin代理。

再次编译运行,我们发现问题复现了!

问题原因

在复现的过程中,我们发现滑动返回失效的问题原因肯定和interactivePopGestureRecognizer有关。 实际上,滑动返回失效正是因为导航控制器的interactivePopGestureRecognizer和页面UIScrollView的panGestureRecognizer冲突了。

我们来梳理一下这两个手势:

1.interactivePopGestureRecognizer:导航控制器用来实现交互式返回的手势,继承自UIScreenEdgePanGestureRecognizer,是特殊的拖动手势,仅对从屏幕边缘开始的手势生效 2.UIScrollView.panGestureRecognizer,后者是UIScrollView实现滚动的手势,继承自UIPanGestureRecognizer

上图标注了这两个手势所在的视图,可以看到UIScrollView是UILayoutContainerView的子孙视图,这意味着当UIScrollView成为最终响应者时,这两个view将在一条响应链上---这意味着这两个手势将会同时收到UITouch事件,但是默认情况下两个手势最终只会有一个成功识别。

在出现滑动返回失效时,UIScrollView.panGestureRecognizer成功识别了手势,而interactivePopGestureRecognizer则识别失败。

解决方案 & 最佳实践

既然确定了问题原因--两个手势之间的冲突问题,我们要解决这个问题的话,只要让interactivePopGestureRecognizer的识别优先于UIScrollView.panGestureRecognizer即可。

网上有很多解决方案,比如继承UIScrollView重写gestureRecognizerShouldBegin方法、或者重写hitTest方法,这些方法在我看来都是把简单问题给复杂化了。

根据苹果官方文档, 我总结出两个解决方案:

* 方案1. 调用require(toFail:)方法,建立手势依赖关系

这个方法用来设置两个手势之间的依赖关系,比如gestureA.requre(toFail:gestureB),则gestureA要成功识别的前提是gestureB识别失败,简单理解就是gestureB优先识别。

// SecondViewController.swift
override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "SecondVC"
        self.configSubvews()
        if let popGesture = self.navigationController?.interactivePopGestureRecognizer {
        	// 设置popGesture优先
            self.scrollView.panGestureRecognizer.require(toFail: popGesture)
        }
    }

* 方案2. 为interactivePopGestureRecognizer的delegate增加gestureRecognizer(_:shouldBeRequiredToFailBy:) 实现;

通过实现这个代理方法,动态的设置interactivePopGestureRecognizer和panGestureRecognizer的依赖关系,对应的代码如下:

// ViewController.swift
extension ViewController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        // 如果当前只有一个vc在栈中,侧滑返回必须禁止,否则会导致页面卡死
        return nav.viewControllers.count > 1
    }
    
    // 方案2:动态的设置interactivePopGestureRecognizer和otherGestureRecognizer的依赖关系
    // 此处直接返回true,则表明interactivePopGestureRecognizer 优先
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

这两种方案都可以解决我们遇到的滑动返回失效问题,但是方案1只能解决当前页面的,如果后面还有其它页面有类似问题呢?所以我个人倾向于用方案2,“一劳永逸”解决问题🥳。

这里我用了方案2,可以看到滑动返回功能已经正常了!👏🏻

疑问

一开始我们没有修改interactivePopGestureRecognizer的代理前,滑动返回失效问题没有复现吗?

其实是因为其delegate默认为_UINavigationInteractiveTransition,从类名可以看出,这是一个系统隐藏类,它实现了-(bool)_gestureRecognizer:(id)arg1 shouldBeRequiredToFailByGestureRecognizer:(id)arg2 方法,其作用和原理和我们方案2类似,猜想里面也是设置了滑动手势优先,感兴趣的可以了解一下。

彩蛋

推荐方案2还有一个好处,和用户体验有关:

在不能横向滚动的页面,比如一个常规的仅可以垂直方向滚动的UITableView, 这时其实即使你什么都不做,滑动返回也是正常的,只不过滑动时一定要比较水平的滑动(下图红色路径),你才能触发返回;

但是你设置了interactivePopGestureRecognizer优先后,即使你稍微斜一点滑动(下图绿色路径),也可以触发滑动返回。

正例:微信聊天页面

反例:钉钉聊天页面

大家可以自己去体验一下哈

总结

1.为什么滑动返回会失效?

本质原因是有一个手势和导航控制器的滑动返回手势冲突了,通常该问题在修改了interactivePopGestureRecognizer的代理后出现。在本例中,是页面scrollView的panGestureRecognizer与interactivePopGestureRecognizer之间的冲突

2.解决方案

设置出现冲突的手势直接的依赖(优先级),可以通过手势本身的方法直接指定和另一个的依赖(方案1),也可以通过滑动返回手势的delegate动态指定依赖(方案2)

文章所写的demo地址:demo地址


👷‍♂️第一次在掘金发文,如果有什么问题和建议请直接在评论区里评论

如果觉得写的东西对你有帮助,请给我一个赞哈👍