前言
看标题就知道,这是一个老生常谈的问题了,网上有很多相关的文章和讨论。但是我发现大部分文章都只是给出一个解决方案,并没有解释为什么会冲突,更不要说解决冲突的原理了。
正好我最近有接触到这一块,所以在这里总结一下,希望可以讲清楚以下问题:
- 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地址
👷♂️第一次在掘金发文,如果有什么问题和建议请直接在评论区里评论
如果觉得写的东西对你有帮助,请给我一个赞哈👍