你会学到什么:京东/美团首页那种"头部固定、中间滚、Tab 吸顶、每个 Tab 各刷各的"效果,iOS 端到底怎么实现。
我踩了三次坑------TableView 嵌套 CollectionView 手势冲突、Section Header 吸顶被 reload 打穿、Overlay 手动吸顶撑不起独立刷新------每一次的死因、本质问题、正确方案的嵌套滚动协调原理,全部写在这里。
如果你正在做类似需求,或者好奇为什么"一个滚动视图套另一个"在 iOS 上这么难调,这篇就是为你准备的。
最终实现效果如图所示: 
一、需求要点
先把交互拆成最原子的约束,每一条都不可妥协:
| # | 约束 | 含义 |
|---|---|---|
| 1 | 头部定位栏固定不滚动 | 滚到哪里都看到地址 |
| 2 | 网格 5x3 跟随页面滚动 | 滚出屏幕就看不到 |
| 3 | TabBar 滚到顶部后吸顶 | 再怎么滚都钉在顶部 |
| 4 | 5 个 Tab 各自独立下拉刷新 | 刷新头在 TabBar 正下方,各自列表刷各自的 |
| 5 | 5 个 Tab 各自独立上拉加载 | 滚到底各自触发,互不干扰 |
| 6 | 切 Tab 秒显旧数据 | 无缓存才请求 |
| 7 | 整个页面一个手指滚动 | 不能有手势冲突 |
其中第 4 条是硬门槛------它直接决定了"每个 Tab 必须拥有独立的 scrollView",从根上排除了单 collectionView 方案。
二、入坑三次:三个方案为什么挂
入坑 1:UITableView 嵌套 UICollectionView
sql
mainTableView (外层滚动)
├── row 0: HeaderCell (定位栏 + 网格)
├── row 1: TabBarCell
└── row 2: ListContainerCell
└── innerCollectionView (内层滚动 + MJRefresh)
死因 :iOS 垂直嵌套滚动视图,手势识别器都想接管:
- 手指下滑 → 外层 tableView 先收到
-scrollViewShouldScrollToTop,内层还没滚就被外层吃掉了 - 上拉加载要滚到底,内层
mj_footer等外层滚完才触发,逻辑上不可能 - 内层
contentInset被外层的contentInsetAdjustmentBehavior干扰,safeArea 适配全乱
核心认知:UIKit 没有原生"滚动协调器",嵌套两个垂直滚动视图 = 手写 300 行协调代码 + 修无止境的边界 case。这个方向直接拉黑。
入坑 2:UICollectionView Section Header 吸顶
yaml
collectionView (一个滚动视图)
├── section 0: 网格 cell (1 row)
└── section 1:
├── supplementary header: TabBar (pinned) ← sectionHeadersPinToVisibleBounds = true
└── items: 列表卡片 (双列)
吸顶本身是 iOS 9+ 原生支持的,效果也丝滑。死在了数据刷新上:
swift
// 点击 Tab 切换列表
gridView.reloadData() // ❌ 整个 collectionView 重新布局
// 或
gridView.reloadSections([1]) // ❌ 仍然触发 layout invalidation
reloadData 对吸顶的破坏链:
reloadData()→invalidateLayout→prepareLayout重算所有 item 位置collectionViewLayout.prepare()内部会调用setContentOffset:animated:NO把contentOffset拉回到 section 0 起始位置- section 0 的网格 cell 重新进入可见区域
- section 1 的 supplementary header(TabBar)因为 section 0 存在,不再满足吸顶条件
- TabBar 瞬间从顶部掉回网格下方 → 用户看到 TabBar 跳了一下
改成 reloadSections 也一样------只要触发布局失效,pinned header 的状态就会重置。
核心认知 :sectionHeadersPinToVisibleBounds 和"频繁刷新吸顶区域数据"是天生矛盾------任何会导致 layout invalidation 的操作(reloadData / reloadSections / insertItems / deleteItems)都会重置吸顶状态。这个方案适合"数据一次加载完就不再变"的场景,动态列表直接 pass。
入坑 3:TabBar Overlay + 手动吸顶
TabBar 脱离 collectionView,作为独立 overlay 盖在上面,scrollViewDidScroll 里手动算位置:
swift
// overlay 吸顶公式
let gridHeight = gridSectionCellHeight
let offsetY = gridView.contentOffset.y
let tabBarY = max(0, gridHeight - offsetY) // 顶部时在 gridHeight,滚完网格后到 0
tabBarView.snp.updateConstraints { $0.top.equalToSuperview().offset(tabBarY) }
吸顶平稳了,reloadData 也不影响 TabBar 了。但架构上走不通:
- 单 collectionView → 5 个 Tab 共用一份列表数据,独立下拉刷新满足不了
- TabBar overlay 盖在列表上,吸顶后列表 item 会显示在 TabBar 后面,需要手动给列表加
contentInset.top = tabBarHeight,切 Tab 切换列表时还要重置 inset,边界 case 越修越多 - 下拉刷新头出现在哪?overlay TabBar 盖在 collectionView 上,
mj_header只能挂在 collectionView 顶部,被 TabBar 盖住
核心认知:overlay 手动吸顶适合"固定一个 Tab"的场景,多 Tab + 独立刷新这个需求集,结构上撑不住。
三、正确方案:JXPagingListRefreshView
为什么是它
JXPagingView 的核心设计是**"嵌套滚动协调器"**------它把"头部滚不滚、列表什么时候接棒滚动"这个协调逻辑从开发者手里拿走了,变成框架内部机制。
选 JXPagingListRefreshView 这个子类而不是普通 JXPagingView,原因只有一个:每个列表容器天然支持各自挂 MJRefresh,下拉刷新头出现在 TabBar 正下方,正好命中硬约束第 4 条。
最终结构
objectivec
HomeViewController (容器 VC)
│
├── headerView ─────────── 定位栏(放在 pagingView 外面,天然固定不滚动)
│
└── JXPagingListRefreshView ─────────── 框架核心,内部有两层滚动
│
├── mainTableView (外层滚动)
│ ├── tableHeaderView ── 网格入口 (跟随滚动,滚出屏幕)
│ ├── pinSectionHeader ── TabBar (滚到顶部自动吸顶)
│ └── 最后一个 section 是 listContainerView
│
└── listContainerView ── 5 个独立列表容器(JXSegmentedListContainerView)
├── index 0: Tab0 列表 ── 独立 UICollectionView + mj_header + mj_footer
├── index 1: Tab1 列表 ── 独立 UICollectionView + mj_header + mj_footer
├── index 2: Tab2 列表 ── 独立 UICollectionView + mj_header + mj_footer
├── index 3: Tab3 列表 ── 独立 UICollectionView + mj_header + mj_footer
└── index 4: Tab4 列表 ── 独立 UICollectionView + mj_header + mj_footer
嵌套滚动协调原理(重点)
你可能好奇:mainTableView 和 listContainerView 里的 5 个 scrollView,到底谁滚、什么时候滚?JXPagingView 的协调机制是三级状态机:
markdown
状态 1: 头部还没滚完(mainTableView.contentOffset.y < gridHeight)
→ 手指滚动 → mainTableView 滚,列表 scrollView 的 panGestureRecognizer 被禁用
→ TabBar 在网格下方,不吸顶
状态 2: 头部滚完,TabBar 吸顶(mainTableView.contentOffset.y >= gridHeight)
→ mainTableView.contentOffset 锁定在 gridHeight,不再上滚
→ 手指滚动 → 当前可见的列表 scrollView 滚
→ TabBar 钉在顶部不动
状态 3: 列表滚到顶(列表 scrollView.contentOffset.y <= 0)
→ 反向手指下滑 → 把滚动机会还给 mainTableView
→ mainTableView 往下滚,TabBar 从吸顶状态松绑
这个状态机是框架内部实现的,我们不需要写一行协调代码。但它依赖一个关键回调------这是我们踩过的最大的坑:
四、三个必踩的坑(每个都写了错误示范 + 正确写法)
坑 1:listViewDidScrollCallback 必须保存并触发
这是 JXPagingView 协调嵌套滚动的神经中枢 。每个列表 View 实现 JXPagingViewListViewDelegate 时,必须把框架传过来的 callback 存下来,并在 scrollViewDidScroll 里触发:
swift
// ❌ 错误示范(我最初的代码)
func listViewDidScrollCallback(callback: @escaping (UIScrollView) -> Void) {
// 什么都不做,注释写着"JXPagingView 内部处理"
}
结果:JXPagingView 收不到列表滚动事件,无法判断当前列表滚没滚完,mainTableView 和列表 scrollView 无法交接滚动权 → 页面空白、列表冻住,且无任何报错。
swift
// ✅ 正确写法
final class ServiceListPageView: UIView, JXPagingViewListViewDelegate {
/// JXPagingView 滚动协调回调(必须保存,否则嵌套滚动失效)
private var scrollCallback: ((UIScrollView) -> Void)?
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollCallback?(scrollView) // ← 每次滚动都通知框架
}
// MARK: - JXPagingViewListViewDelegate
func listView() -> UIView { self }
func listScrollView() -> UIScrollView { gridView }
func listViewDidScrollCallback(callback: @escaping (UIScrollView) -> Void) {
scrollCallback = callback // ← 保存框架传来的回调
}
}
原理 :JXPagingView 靠这个 callback 感知列表的 contentOffset,才能驱动上面那个三级状态机。剪断 callback,整个嵌套滚动就瘫痪了,而且因为 delegate 方法不是可选的,编译器不会给你任何警告。
坑 2:Tab 切换必须走 segmentedView.listContainer 原生联动
拿到框架后第一反应是手动切列表:
swift
// ❌ 错误示范(我最初的代码)
func tabBarView(_ view: TabBarView, didSelect index: Int) {
// 手动强刷容器,列表视图重建时滚动手势未挂好
pagingView.listContainerView.defaultSelectedIndex = index
pagingView.listContainerView.reloadData()
}
结果:reloadData() 会销毁旧的列表实例、创建新的,新实例的 scrollCallback 还没被框架设置好 → 新 Tab 页面空白。
正确做法是让 JXSegmentedView 和列表容器关联,点击 Tab 的切换完全由框架接管:
swift
// ✅ 在 setupUI 里建立原生关联
tabBarView.segmentedView.listContainer = pagingView.listContainerView
业务侧只需在 delegate 里做数据层切换:
swift
// ✅ 业务层只做数据,不碰 UI 容器
func tabBarView(_ view: TabBarView, didSelect index: Int) {
listViewModel.switchTab(index: index) // 切换缓存
if listViewModel.listItems.isEmpty {
listViewModel.refreshData() // 无缓存静默请求
}
}
坑 3:切 Tab 自动拉数据,不要借道 MJRefresh
新 Tab 没有缓存时想自动拉数据,第一反应是触发下拉刷新动画:
swift
// ❌ 错误示范
if listViewModel.listItems.isEmpty {
listPageViews[index].beginHeaderRefreshing()
}
beginHeaderRefreshing 依赖 scrollView 已完成布局、mj_header 已添加到 scrollView。刚切换的列表可能还没上屏,调用会静默失效------不报错、也不请求,用户看到的就是空白页。
正确做法:直接请求数据,不借道刷新控件。MJRefresh 只留给用户手动下拉用,两条链路解耦:
swift
// ✅ 正确
if listViewModel.listItems.isEmpty {
listViewModel.refreshData() // 静默请求,不依赖 MJRefresh 状态机
}
五、吸顶实现原理(pinSectionHeader)
pinSectionHeader 是 JXPagingView 提供的 TabBar 吸顶能力,内部实现并不黑箱,理解它有助于排查问题:
less
mainTableView 的结构:
├── section 0: tableHeaderView (网格) ─── 高度 gridHeight
├── section 1:
│ ├── pinSectionHeader (TabBar) ─── 高度 tabBarHeight
│ └── items: [listContainerView cell] ─── 高度 = mainTableView.bounds.height
吸顶触发条件 :mainTableView.contentOffset.y >= gridHeight,即 tableHeaderView 完全滚出屏幕。
吸顶时框架做了什么:
mainTableView.contentOffset锁定在gridHeight,强制不再上滚pinSectionHeader的 frame 从(0, gridHeight, width, tabBarHeight)变为(0, 0, width, tabBarHeight)- 列表的
scrollEnabled从false变为true,滚动权交给列表 scrollView
松绑条件 :列表 scrollView 滚到顶(contentOffset.y <= 0)且用户继续下滑,框架把滚动权还给 mainTableView,TabBar 回到 section 1 顶部。
六、下拉刷新为什么天然在 TabBar 下方
每个 Tab 的列表 scrollView 是 listContainerView 内部创建的,它的 frame 从 TabBar 底部开始:
arduino
listContainerView cell (mainTableView 的最后一行)
└── 每个列表 scrollView 的 frame:
origin.y = tabBarHeight ← 从 TabBar 底部开始
height = mainTableView.bounds.height
mj_header 挂在这个 scrollView 上,下拉时刷新头从 origin.y = tabBarHeight 的位置出现------正好就是TabBar 正下方。
这是 JXPagingListRefreshView 的结构设计决定的,不需要我们手动算刷新头位置。
七、数据层:每个 Tab 独立缓存
UI 靠框架搞定了,数据层保证切 Tab 秒显:
swift
/// 单个 Tab 的数据缓存
struct TabCache {
var items: [ServiceItemModel] = [] // 当前数据
var currentPage: Int = 1 // 已加载页码
var totalPage: Int = 1 // 总页数
var hasLoaded: Bool = false // 是否已完成首次加载
}
/// ViewModel 内按 Tab 索引持有
private var tabCaches: [TabCache] // 长度 = tabs 数量
三个体验细节:
- 缺省页不抢跑 :
hasLoaded = false时不显示空态,避免请求中闪一下"暂无数据" - "没有更多"懒显示 :接口返回
totalPage=1时,加载完不立刻提示,等用户真的上拉了才提示------不然每页 10 条数据的接口,用户一进来就被告知到底了 - 定位变化即刷新:地图选点后,清空当前列表 + HUD + 从第 1 页重拉,坐标随请求下发,距离字段后端计算
八、总结
| 阶段 | 方案 | 死因 | 本质问题 |
|---|---|---|---|
| 1 | TableView 嵌套 CollectionView | 手势冲突 | 没有滚动协调器 |
| 2 | Section Header 吸顶 | reload 重置 offset | 吸顶机制与动态刷新矛盾 |
| 3 | TabBar Overlay 手动吸顶 | 单容器撑不起独立刷新 | 结构上限不够 |
| 最终 | JXPagingListRefreshView | ------ | 框架帮你搞定了协调 |
一句话方法论 :当"吸顶 + 嵌套滚动 + 多列表独立刷新"三个需求同时出现时,不要在原生控件组合里硬凑,直接上成熟框架。剩下的精力,花在框架的三个关键约定上(scrollCallback 保存回调、listContainer 原生联动、自动请求与 MJRefresh 解耦),以及真正属于业务的数据层设计上。
这就是方案好的原因------不是因为用了什么高级 API,而是踩过三次坑后知道哪些路根本走不通,然后站在了已经解决了同样问题的肩膀上。