1. 嵌套滚动的本质矛盾与场景拆解
iOS开发里,ScrollView嵌套ScrollView这件事,几乎每个做内容型App的开发者都会撞上。你有一个横向分页的容器,每个分页里又有一个纵向滚动的列表;或者反过来,外层纵向滚动承载整页内容,内层横向滚动展示卡片流。这种结构在首页、个人主页、商品详情页里反复出现,看起来只是两个UIScrollView叠在一起,实际跑起来问题一大堆:手势冲突、滚动卡顿、内容偏移、代理回调互相打架。
我在实际项目里踩过最典型的坑,是一个仿社交主页的布局:顶部有可折叠的头像区域,下面接一个分页控制器,每个分页里是独立的纵向信息流。外层ScrollView负责整体纵向滚动,内层每个分页的ScrollView也响应纵向手势。结果就是,手指在内层列表上滑动时,外层偶尔会抢走手势,导致列表滚到一半突然整页往上跳;反过来,当内层列表已经滚到顶部,继续下拉时,外层又不动,用户以为卡死了。
这个问题的核心在于:UIScrollView的手势识别是竞争关系,而不是协作关系。系统默认的手势识别器(UIPanGestureRecognizer)在同一时刻只会让一个ScrollView响应。谁先识别成功,谁就独占这次触摸序列。嵌套场景下,内外层ScrollView都注册了pan手势,系统需要一套明确的优先级规则来决定谁赢。
标题里提到的“滑动处理”,本质上要解决三件事:第一,明确手势归属,让该滚的那个滚;第二,处理边界传递,内层滚到边缘后把剩余手势交给外层;第三,保证滚动过程中的视觉连贯性,不能出现跳动或回弹异常。
适合阅读这篇内容的,是已经写过基础ScrollView、但被嵌套场景折磨过的iOS开发者。如果你还在用Storyboard拖两个ScrollView然后祈祷它们别打架,那这篇就是写给你的。我会从方案选型讲到代码落地,再到实际调试中遇到的诡异问题,尽量把每个决策背后的原因说清楚。
2. 方案选型:为什么我不推荐直接嵌套
2.1 直接嵌套UIScrollView的天然缺陷
很多人第一反应是直接把两个UIScrollView叠起来,设置好contentSize和frame,然后指望系统自动处理。实测下来,这种做法在简单场景下勉强能跑,但一旦涉及纵向嵌套纵向,问题就集中爆发。
根本原因在于UIScrollView的panGestureRecognizer没有内置的优先级协商机制。当内外层都能响应纵向滑动时,系统会根据触摸起始位置和手势识别器的添加顺序做判断,但这个判断并不稳定。我遇到过同一个页面,在不同机型上表现不一致的情况:有的机型外层先响应,有的机型内层先响应。这种不确定性在生产环境里是灾难。
另一个缺陷是contentSize的联动。外层ScrollView的contentSize需要包含内层ScrollView的完整高度,但内层如果是动态内容(比如异步加载的列表),contentSize会变化,外层需要同步更新。如果更新时机不对,就会出现滚动区域忽大忽小、底部留白或者内容被截断。
还有一个容易被忽略的点:同时识别多个手势。UIScrollView默认不允许同时识别多个pan手势,但你可以通过UIGestureRecognizerDelegate的gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)方法打开。打开之后,内外层会同时滚动,用户体验更奇怪——手指一动,两层一起跑,像脱缰的野马。
2.2 三种主流方案对比
实际项目中,处理嵌套滚动主要有三条路:手势优先级控制、边界传递代理、以及用UIPageViewController或UICollectionView做容器重构。我把它们的优缺点整理成表,方便你根据场景选择。
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 手势优先级控制 | 通过UIGestureRecognizerDelegate指定哪个ScrollView优先响应 | 实现简单,改动小 | 边界传递需要额外处理,容易出现手势死锁 | 内外层滚动方向不同(横+纵) |
| 边界传递代理 | 监听内层滚动offset,到达边界时切换外层可滚动状态 | 控制精细,体验流畅 | 代码量大,状态管理复杂 | 内外层同向嵌套(纵+纵) |
| 容器重构 | 用UICollectionView或UIPageViewController替代嵌套结构 | 系统级优化,性能好 | 改造成本高,布局受限 | 新项目或可大改的页面 |
我个人的经验是:如果是横向分页嵌套纵向列表,优先考虑方案一,因为方向不同,手势冲突相对好解;如果是纵向嵌套纵向,方案二是绕不开的,必须做边界传递;如果项目还处于早期,方案三最省心,但需要产品经理接受布局调整。
2.3 为什么边界传递是纵纵嵌套的唯一解
纵向嵌套纵向的场景,比如个人主页的折叠头+信息流,内外层都在抢同一个方向的pan手势。这时候无论你怎么设置优先级,都会有一个问题:内层列表滚到顶部后,用户继续上拉,外层应该接着滚;内层列表滚到底部后,用户继续下拉,外层应该回弹。这个“接着滚”和“回弹”的动作,必须由代码显式控制,系统不会自动帮你做。
边界传递的核心逻辑是:内层ScrollView的contentOffset到达临界值时,动态切换内外层的isScrollEnabled状态。具体来说,当内层滚到顶部(offset.y <= 0)且用户继续下拉时,把内层的isScrollEnabled设为false,外层的设为true,让外层接管手势;当外层滚回顶部且用户继续上拉时,反向操作。
这个切换时机非常关键。切早了,内层还没滚到顶就被禁用,用户会觉得列表卡住;切晚了,内层已经回弹了才切换,会出现视觉跳动。我试过用scrollViewDidScroll回调来判断,但回调频率高,状态切换容易抖动。后来改用panGestureRecognizer的翻译方法(translation)来预判,效果稳定很多。
3. 核心细节:手势优先级与边界传递的实现
3.1 用UIGestureRecognizerDelegate控制手势归属
先说过渡方案,也就是内外层滚动方向不同的情况。假设外层是纵向ScrollView,内层是横向ScrollView。这种场景下,手势冲突没那么严重,因为纵向和横向的pan手势在方向上天然区分。但仍有边缘情况:用户斜着滑,系统可能同时识别两个方向。
这时候需要实现gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:),返回false,确保同一时刻只有一个ScrollView响应。然后通过gestureRecognizer(_:shouldRequireFailureOf:)或shouldBeRequiredToFailBy来指定优先级。
我通常会让内层横向ScrollView的手势优先于外层纵向。因为用户在内层区域滑动时,意图大概率是横向翻页,而不是纵向滚动页面。代码大概长这样:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return false } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { // 内层横向手势优先 if gestureRecognizer == innerScrollView.panGestureRecognizer && otherGestureRecognizer == outerScrollView.panGestureRecognizer { return true } return false }这里有个细节:shouldBeRequiredToFailBy的返回值含义是“当前手势是否需要等待另一个手势失败后才能识别”。返回true表示当前手势优先级更低。所以上面的代码实际上让外层纵向手势等待内层横向手势失败,也就是内层优先。
注意:这个方法只在两个手势识别器都添加到同一个视图层级时有效。如果内外层ScrollView不在同一个父视图下,需要把代理设置到共同的祖先视图上。
3.2 边界传递的状态机设计
纵纵嵌套的边界传递,我习惯用一个简单的状态机来管理。定义三个状态:内层滚动、外层滚动、过渡中。初始状态是内层滚动,因为用户通常先跟内容交互。
状态切换的触发条件是内层ScrollView的contentOffset.y。当offset.y <= 0且手势方向是向下(下拉)时,从内层滚动切换到外层滚动。当offset.y >= contentSize.height - frame.height且手势方向是向上(上拉)时,同样切换到外层滚动。
但这里有个陷阱:contentOffset在回弹过程中会变成负值或超过最大值,如果直接用offset判断,会在回弹动画期间反复触发切换。我的做法是结合panGestureRecognizer的state和velocity来判断,只在手势活跃期间做切换,回弹期间忽略。
具体实现时,我会给外层ScrollView添加一个pan手势监听,或者直接重写内层ScrollView的gestureRecognizerShouldBegin方法。后者更干净,因为可以在手势开始前就决定是否让内层响应。
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } let velocity = pan.velocity(in: self) let isVertical = abs(velocity.y) > abs(velocity.x) if isVertical { if contentOffset.y <= 0 && velocity.y > 0 { // 内层已到顶部,继续下拉,交给外层 return false } if contentOffset.y >= contentSize.height - bounds.height && velocity.y < 0 { // 内层已到底部,继续上拉,交给外层 return false } } return true }这段代码的逻辑是:当内层ScrollView处于边界且手势方向指向边界外时,拒绝这次手势,让手势传递给外层。实测下来,这种方式的切换非常跟手,没有延迟感。
3.3 contentSize动态更新的坑
嵌套场景下,内层ScrollView的contentSize往往是动态的。比如信息流异步加载,每加载一页,contentSize就增加。外层ScrollView如果依赖内层的高度来计算自己的contentSize,就需要在每次内层contentSize变化时同步更新。
我踩过的坑是:在内层scrollViewDidScroll里更新外层contentSize,导致布局循环触发,CPU飙升。正确的做法是监听内层的contentSize变化,用KVO或者重写layoutSubviews,在合适的时机批量更新。
另一个坑是内层ScrollView的contentInset。如果内层有顶部或底部的inset(比如适配安全区),contentOffset的边界值会变化,边界传递的判断条件也要相应调整。我一般会把inset纳入计算:
let topBoundary = -innerScrollView.contentInset.top let bottomBoundary = innerScrollView.contentSize.height - innerScrollView.bounds.height + innerScrollView.contentInset.bottom这样判断才准确,否则会出现“明明滚到顶了但代码认为还没到”的情况。
4. 完整实操:从零搭建一个可运行的嵌套滚动页面
4.1 页面结构定义与约束设置
我以一个典型的个人主页为例:顶部是一个可折叠的HeaderView,高度200pt;下面是一个分段控制器,高度44pt;再下面是内容区,用一个纵向ScrollView承载多个卡片。外层是一个纵向ScrollView,负责整体页面的滚动。
结构层级如下:
- 外层ScrollView(frame = 屏幕大小,contentSize = 计算得出)
- HeaderView(高度200)
- SegmentControl(高度44)
- 内层ScrollView(高度 = 屏幕高度 - 44,contentSize = 卡片总高度)
- 多个卡片视图
约束设置的关键点:内层ScrollView的高度不能直接用屏幕高度,因为外层滚动时,内层的位置会变化。我通常把内层的高度设为固定值(比如屏幕高度减去导航栏和分段控制器的高度),然后通过contentSize来撑开滚动区域。
外层ScrollView的contentSize需要手动计算:HeaderView高度 + SegmentControl高度 + 内层ScrollView的contentSize高度。注意,内层ScrollView的frame高度是固定的,但contentSize是动态的,外层需要的是contentSize,不是frame。
outerScrollView.contentSize = CGSize( width: view.bounds.width, height: 200 + 44 + innerScrollView.contentSize.height )这个计算要在内层contentSize变化时重新执行。我一般会封装一个updateOuterContentSize()方法,在内层数据加载完成后调用。
4.2 手势切换的核心代码实现
接下来是边界传递的核心逻辑。我选择重写内层ScrollView的gestureRecognizerShouldBegin,因为这种方式最直接,不需要额外的状态变量。
先定义一个自定义的InnerScrollView类:
class InnerScrollView: UIScrollView { weak var outerScrollView: UIScrollView? override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer, let outer = outerScrollView else { return true } let velocity = pan.velocity(in: self) let isVertical = abs(velocity.y) > abs(velocity.x) guard isVertical else { return true } let topBoundary = -contentInset.top let bottomBoundary = contentSize.height - bounds.height + contentInset.bottom // 内层到顶且继续下拉,交给外层 if contentOffset.y <= topBoundary && velocity.y > 0 { return false } // 内层到底且继续上拉,交给外层 if contentOffset.y >= bottomBoundary && velocity.y < 0 { return false } return true } }然后在ViewController里把内外层关联起来:
innerScrollView.outerScrollView = outerScrollView这段代码的效果是:当内层ScrollView处于顶部且用户继续下拉时,内层拒绝这次手势,手势会传递给外层ScrollView,外层开始滚动。同理,内层到底部继续上拉时,外层接管。
但这里有个问题:外层接管后,如果用户反向滑动(比如从下拉变成上拉),外层应该把控制权交还给内层。这个逻辑需要在外层的gestureRecognizerShouldBegin里做对称处理。
class OuterScrollView: UIScrollView { weak var innerScrollView: InnerScrollView? override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer, let inner = innerScrollView else { return true } let velocity = pan.velocity(in: self) let isVertical = abs(velocity.y) > abs(velocity.x) guard isVertical else { return true } // 外层在顶部且继续上拉,交给内层 if contentOffset.y <= -contentInset.top && velocity.y < 0 { return false } return true } }这样双向切换就完整了。实测下来,内外层之间的过渡非常顺滑,没有明显的卡顿或跳动。
4.3 滚动位置同步与视觉优化
边界传递解决了手势归属,但还有一个视觉问题:当外层滚动时,内层的位置会跟着移动,但内层的contentOffset不变。这本身没问题,但如果内层有吸顶效果(比如分段控制器悬浮),就需要额外处理。
我的做法是监听外层ScrollView的contentOffset,当滚动到分段控制器的位置时,把分段控制器从外层ScrollView中移除,添加到外层ScrollView的父视图上,实现吸顶。这个操作要在scrollViewDidScroll里做,但要注意避免重复添加和移除。
func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView == outerScrollView { let threshold: CGFloat = 200 // HeaderView高度 if scrollView.contentOffset.y >= threshold { // 吸顶 if segmentControl.superview != view { segmentControl.removeFromSuperview() view.addSubview(segmentControl) segmentControl.frame = CGRect(x: 0, y: navigationBarHeight, width: view.bounds.width, height: 44) } } else { // 恢复 if segmentControl.superview != outerScrollView { segmentControl.removeFromSuperview() outerScrollView.addSubview(segmentControl) segmentControl.frame = CGRect(x: 0, y: 200, width: view.bounds.width, height: 44) } } } }吸顶效果的实现方式有很多种,我选择这种“移出移入”的方案,是因为它不依赖Auto Layout的优先级调整,控制起来更直观。缺点是频繁的视图层级变动可能影响性能,所以我会加一个判断,只在状态真正变化时才执行。
提示:如果分段控制器下面还有内容,吸顶后需要调整内层ScrollView的contentInset.top,否则内容会被遮挡。这个inset的调整也要在吸顶切换时同步进行。
5. 常见问题与排查技巧实录
5.1 手势切换不灵敏或卡顿
这是最常见的问题,表现是内层滚到顶后,继续下拉,外层要等一小会儿才开始滚,或者干脆不滚。排查思路分三步:
第一,检查gestureRecognizerShouldBegin的返回值。在方法里加断点或日志,确认内层是否真的返回了false。有时候因为contentOffset的边界值计算错误,条件根本没满足。
第二,检查外层ScrollView的isScrollEnabled。如果外层被意外禁用,手势传过去也没用。我遇到过在内层滚动时把外层禁用,但忘记在边界时重新启用的情况。
第三,检查手势识别器的依赖关系。如果之前设置过require(toFail:),可能导致手势等待超时。用gestureRecognizer(_:shouldRequireFailureOf:)代替,并确保逻辑正确。
5.2 滚动过程中出现跳动或回弹异常
跳动通常发生在边界切换的瞬间。原因是内层ScrollView在拒绝手势后,系统会触发一次回弹动画,把contentOffset拉回边界值。如果外层同时开始滚动,两个动画叠加,视觉上就是跳动。
解决方法是在内层拒绝手势前,先把contentOffset固定到边界值,并禁用回弹:
if contentOffset.y <= topBoundary && velocity.y > 0 { contentOffset.y = topBoundary bounces = false return false }然后在手势结束后恢复bounces:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesEnded(touches, with: event) bounces = true }这个技巧我用了很多次,效果立竿见影。注意bounces的恢复时机要准确,太早恢复会在切换过程中触发回弹,太晚恢复会影响后续的正常滚动。
5.3 内层列表数据加载后外层不滚动
这个问题通常是因为外层contentSize没有更新。内层数据异步加载完成后,contentSize变大,但外层还是旧的contentSize,导致底部内容滚不到。
排查方法是打印内外层的contentSize,对比是否一致。如果外层偏小,就在内层数据加载完成的回调里手动更新:
func didLoadMoreData() { innerScrollView.layoutIfNeeded() let innerContentHeight = innerScrollView.contentSize.height outerScrollView.contentSize = CGSize( width: view.bounds.width, height: 200 + 44 + innerContentHeight ) }注意layoutIfNeeded()的调用,确保内层的contentSize已经更新到最新值。如果内层用了预估行高(estimatedRowHeight),contentSize可能在滚动过程中才最终确定,这时候需要监听内层的contentSize变化,用KVO来触发外层更新。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 内层滚到顶后外层不滚 | 手势未传递 | 在gestureRecognizerShouldBegin加日志 | 检查边界条件和返回值 |
| 切换时页面跳动 | 回弹动画叠加 | 观察contentOffset变化 | 切换前固定offset并禁用bounces |
| 外层滚动区域不足 | contentSize未更新 | 打印内外层contentSize | 数据加载后手动更新外层contentSize |
| 斜向滑动时手势混乱 | 未区分方向 | 打印velocity的x和y分量 | 在shouldBegin里判断isVertical |
| 吸顶效果闪烁 | 视图层级频繁变动 | 监听scrollViewDidScroll调用频率 | 加状态判断,只在变化时切换 |
| 内层滚动卡顿 | 外层同时响应 | 检查shouldRecognizeSimultaneouslyWith | 返回false,禁止同时识别 |
5.5 一个容易被忽略的坑:安全区适配
iPhone X之后的机型有底部安全区,内层ScrollView的contentInset.bottom通常会被设置为安全区高度。这个inset会影响边界判断,如果计算bottomBoundary时忘了加inset,内层会提前触发边界传递,导致列表还没滚到底就交给外层了。
我的习惯是在viewDidLayoutSubviews里统一更新inset和边界值:
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let safeAreaBottom = view.safeAreaInsets.bottom innerScrollView.contentInset.bottom = safeAreaBottom // 边界值会在gestureRecognizerShouldBegin里实时计算,不需要额外存储 }这样每次布局变化(比如旋转屏幕)时,inset都会自动更新,边界判断始终准确。
6. 性能优化与进阶技巧
6.1 减少不必要的布局计算
嵌套滚动页面最容易出现的性能问题是布局抖动。每次滚动都触发layoutSubviews,如果里面做了复杂的计算,帧率会明显下降。
我的优化策略是:把contentSize的计算缓存起来,只在数据变化时更新,不在滚动回调里重复计算。具体做法是用一个cachedOuterContentHeight变量,在内层数据加载完成或屏幕旋转时更新,scrollViewDidScroll里直接读取缓存值。
另外,吸顶效果的视图层级切换也要加防抖。我见过有人在scrollViewDidScroll里每次都remove和add子视图,导致CPU直接跑满。正确的做法是用一个布尔值记录当前吸顶状态,只在状态翻转时执行切换。
6.2 用CADisplayLink做滚动同步
如果内外层需要更精细的同步(比如内层的某个元素要跟随外层滚动做视差效果),可以用CADisplayLink来驱动。CADisplayLink会在每次屏幕刷新时回调,你可以在回调里读取外层的contentOffset,然后同步更新内层的某些属性。
但CADisplayLink的开销比scrollViewDidScroll大,因为它不受滚动事件的节流控制。我的建议是只在确实需要逐帧同步的场景下使用,普通滚动同步用scrollViewDidScroll就够了。
6.3 内层预加载与复用
如果内层是UICollectionView或UITableView,复用机制本身已经处理了大部分性能问题。但嵌套场景下,内层的复用可能因为外层滚动而触发额外的layout,导致cell创建频率变高。
我通常会把内层的estimatedItemSize或estimatedRowHeight设置得尽量准确,减少布局估算的次数。另外,如果内层有图片加载,确保在cell复用时有取消机制,避免滚动时大量图片请求堆积。
6.4 调试工具与技巧
Xcode的View Debugger是排查嵌套滚动问题的利器。你可以直观地看到内外层ScrollView的frame、contentSize、contentOffset,以及视图层级关系。我经常用它来确认内层是否真的被添加到了外层上,以及contentSize是否匹配。
另一个技巧是在模拟器里开启“Color Blended Layers”和“Color Offscreen-Rendered”,检查滚动过程中是否有不必要的离屏渲染。嵌套ScrollView如果设置了圆角或阴影,很容易触发离屏渲染,导致滚动掉帧。
提示:如果发现滚动时CPU占用过高,先用Time Profiler抓一份火焰图,重点看scrollViewDidScroll和layoutSubviews的调用栈。大部分性能问题都能从这里找到线索。
7. 不同场景下的方案变体
7.1 横向分页嵌套纵向列表
这种场景比纵纵嵌套简单,因为方向不同,手势冲突少。我通常直接用UIPageViewController或UICollectionView的横向分页模式做外层,每个分页里放一个纵向的UITableView或UICollectionView。
需要注意的是,横向分页的手势和纵向列表的手势在斜向滑动时可能冲突。我的处理方式是在外层分页控制器的gestureRecognizerShouldBegin里判断方向,如果纵向分量大于横向分量,就拒绝横向手势,让内层列表响应。
7.2 多层嵌套(三层以上)
三层嵌套(比如外层纵向、中层横向、内层纵向)在实际项目中很少见,但偶尔会遇到。这种场景下,边界传递的逻辑会变得非常复杂,状态机需要管理多个层级之间的切换。
我的建议是尽量避免三层嵌套,如果产品需求实在绕不开,考虑用UICollectionView的CompositionalLayout来重构。CompositionalLayout支持复杂的嵌套布局,而且系统级的手势处理比手动管理ScrollView稳定得多。
7.3 与导航栏联动的折叠效果
很多个人主页有导航栏渐变效果:页面往上滚时,导航栏从透明逐渐变不透明,标题从隐藏到显示。这个效果需要监听外层ScrollView的contentOffset,然后动态调整导航栏的alpha和标题的frame。
实现时要注意,导航栏的调整不能影响ScrollView的contentInset,否则会导致滚动区域变化,触发连锁反应。我通常把导航栏的调整放在viewDidLayoutSubviews里,用独立的变量记录滚动进度,避免在滚动回调里直接修改导航栏属性。
8. 我个人的实操体会
嵌套滚动这件事,说到底是在跟系统的手势识别机制做博弈。系统设计UIScrollView的时候,没想过有人会把两个ScrollView叠在一起用,所以很多边界情况需要开发者自己补全。
我踩过最大的坑,是在一个项目里同时用了手势优先级控制和边界传递两套逻辑,结果两套逻辑互相干扰,手势时而传给外层时而传给内层,调试了一整天才发现是代理方法冲突。后来我定了一个规矩:同一个嵌套结构里,只允许一套手势控制逻辑存在。要么全用代理优先级,要么全用边界传递,不要混用。
另一个体会是,边界传递的临界值判断一定要用contentInset修正后的值,不能直接用contentOffset。我见过太多因为忽略inset导致边界判断偏差的案例,表现就是“差一点点才切换”,用户体验很别扭。
最后分享一个小技巧:在开发阶段,给内外层ScrollView设置不同的背景色(比如外层浅灰、内层白色),这样滚动时能直观地看到哪一层在动,排查问题效率翻倍。上线前再把背景色改回去就行。
这个方案后续还可以扩展的方向是跟UIRefreshControl结合。内层列表下拉刷新时,需要确保刷新控件只在内层触发,不会误触外层。我的做法是在内层的gestureRecognizerShouldBegin里,当contentOffset.y <= topBoundary且下拉时,先检查是否处于刷新状态,如果是,则允许内层响应(让刷新控件工作),否则交给外层。这个判断逻辑稍微复杂一点,但原理和边界传递是一致的。