iOS 嵌套滚动(Nested Scroll)实现原理
一、页面结构
┌─────────────────────────────────┐
│ outerScrollView(外层纵向滚动) │
│ ┌───────────────────────────┐ │
│ │ HeaderView(头部区域) │ │
│ │ 背景图 / 头像 / 统计 / 关注 │ │
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ TabBar(动态/回答/文章) │ │ ← 吸顶位置
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ horizontalScrollView │ │
│ │ ┌─────┐┌─────┐┌─────┐ │ │
│ │ │ TV0 ││ TV1 ││ TV2 │ │ │ ← 3 个内层 TableView
│ │ └─────┘└─────┘└─────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
outerScrollView:负责整体纵向滚动,承载 Header + TabBar + 内容区TabBar:Tab 切换栏,需要吸顶innerTableView(TV0/TV1/TV2):3 个内层列表,通过横向 ScrollView 实现 Tab 切换stickyOffset:headerHeight - safeAreaTop,即外层滚动到此位置时 TabBar 刚好吸顶
二、核心难题
外层 ScrollView 和内层 TableView 都是纵向滚动,默认情况下 iOS 只会让其中一个响应手势。我们需要实现:
- 向上滑:先滚外层(Header 收起),Tab 吸顶后无缝切换到滚内层列表
- 向下滑:先滚内层列表回到顶部,再无缝切换到滚外层(Header 露出)
- 同一个手势中无缝过渡,不需要松手再重新滑
三、解决方案:手势同时识别 + 实时 Clamp
3.1 手势同时识别
// 子类化外层 ScrollView
@interface NestedOuterScrollView : UIScrollView <UIGestureRecognizerDelegate>
@end
@implementation NestedOuterScrollView
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
@end
为什么要子类化?
UIScrollView 要求
UIScrollView 要求
panGestureRecognizer.delegate 必须是自身,直接设置为其他对象会 crash。通过子类化,UIScrollView 自身就是 delegate,重写的方法自然生效。返回 YES 后,一次手指拖动会同时驱动外层和内层两个 ScrollView。3.2 实时 Clamp(scrollViewDidScroll: 核心逻辑)
两个 ScrollView 同时被手势驱动,但每一帧只有一个应该真正滚动,另一个应该被"钉住"。
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// ===== 外层 ScrollView =====
if (scrollView == outerScrollView) {
if (offsetY >= stickyOffset) {
// 外层到达吸顶 → 锁死外层,允许内层
scrollView.contentOffset = CGPointMake(0, stickyOffset);
innerCanScroll = YES;
} else {
if (innerTV.contentOffset.y > 0) {
// 【关键】内层还有内容没滚到顶
// → 外层也锁死在吸顶,优先让内层先滚
scrollView.contentOffset = CGPointMake(0, stickyOffset);
} else {
// 内层已在顶部 → 外层自由滚动
innerCanScroll = NO;
}
}
}
// ===== 内层 TableView =====
if (scrollView == innerTableView) {
if (!innerCanScroll) {
// 外层还没到吸顶,钉住内层
scrollView.contentOffset = CGPointZero;
} else if (scrollView.contentOffset.y <= 0) {
// 内层到顶了,交还给外层
innerCanScroll = NO;
scrollView.contentOffset = CGPointZero;
}
}
}
四、向上滑(看更多内容)的流程
手指向上拖动 ↑
│
├─ 阶段1: 外层 offset < stickyOffset, innerCanScroll = NO
│ 外层自由滚动(Header 收起),内层被 clamp 在 0
│
├─ 过渡点: 外层 offset 到达 stickyOffset(Tab 吸顶)
│ 外层被 clamp 在 stickyOffset,innerCanScroll = YES
│
└─ 阶段2: innerCanScroll = YES
外层锁定,内层自由滚动(浏览列表内容)
同一个手势,无需松手,从外层滚动无缝过渡到内层滚动。
五、向下滑(回到顶部)的流程
手指向下拖动 ↓(此时外层在吸顶位置,内层有内容偏移)
│
├─ 阶段1: innerTV.contentOffset.y > 0
│ 外层被 clamp 在 stickyOffset(不动)
│ 内层自由向下滚动(列表内容回到顶部)
│
├─ 过渡点: innerTV.contentOffset.y 到达 0
│ innerCanScroll = NO,内层被 clamp 在 0
│
└─ 阶段2: innerCanScroll = NO, innerTV.contentOffset.y == 0
外层自由向下滚动(Header 开始露出)
同一个手势,无需松手,从内层滚动无缝过渡到外层滚动。
六、关键设计要点总结
| 要点 | 说明 |
|---|---|
| 手势同时识别 | 通过子类化 UIScrollView 实现,不能直接改 panGestureRecognizer.delegate |
| 只用一个标志位 | innerCanScroll,不需要 outerCanScroll |
| 外层向下判断 | 外层 offset < stickyOffset 时,先看内层是否到顶,没到顶则外层也锁住 |
| clamp 而非阻止 | 不是阻止事件传递,而是让两个都收到事件,实时 clamp 不该动的那个 |
| 无缝过渡 | 同一个手势中,在一帧内完成角色切换,用户无感知 |
七、踩坑记录
Crash: UIScrollView's built-in pan gesture recognizer must have its scroll view as its delegate
不能直接设置
不能直接设置
outerScrollView.panGestureRecognizer.delegate = viewController,必须通过子类化 UIScrollView,在子类内重写手势代理方法。向下滑时内层内容直接归零
原因:外层 else 分支只看自己的 offset < stickyOffset 就把
修复:增加
原因:外层 else 分支只看自己的 offset < stickyOffset 就把
innerCanScroll = NO。修复:增加
innerTV.contentOffset.y > 0 判断,内层没到顶时外层也锁住。互斥标志位方案(outerCanScroll + innerCanScroll)的问题
在同一手势中无法实现无缝过渡,必须松手后重新滑。去掉
在同一手势中无法实现无缝过渡,必须松手后重新滑。去掉
outerCanScroll,只用 innerCanScroll + 实时 clamp 即可。