iOS 首页复杂嵌套滚动技术方案:头部固定 + 网格滚动 + Tab 吸顶 + 每 Tab 独立下拉刷新

你会学到什么:京东/美团首页那种"头部固定、中间滚、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 对吸顶的破坏链:

  1. reloadData() → invalidateLayout → prepareLayout 重算所有 item 位置
  2. collectionViewLayout.prepare() 内部会调用 setContentOffset:animated:NO 把 contentOffset 拉回到 section 0 起始位置
  3. section 0 的网格 cell 重新进入可见区域
  4. section 1 的 supplementary header(TabBar)因为 section 0 存在,不再满足吸顶条件
  5. 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 完全滚出屏幕。

吸顶时框架做了什么:

  1. mainTableView.contentOffset 锁定在 gridHeight,强制不再上滚
  2. pinSectionHeader 的 frame 从 (0, gridHeight, width, tabBarHeight) 变为 (0, 0, width, tabBarHeight)
  3. 列表的 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 数量

三个体验细节:

  1. 缺省页不抢跑 :hasLoaded = false 时不显示空态,避免请求中闪一下"暂无数据"
  2. "没有更多"懒显示 :接口返回 totalPage=1 时,加载完不立刻提示,等用户真的上拉了才提示------不然每页 10 条数据的接口,用户一进来就被告知到底了
  3. 定位变化即刷新:地图选点后,清空当前列表 + HUD + 从第 1 页重拉,坐标随请求下发,距离字段后端计算

八、总结

阶段 方案 死因 本质问题
1 TableView 嵌套 CollectionView 手势冲突 没有滚动协调器
2 Section Header 吸顶 reload 重置 offset 吸顶机制与动态刷新矛盾
3 TabBar Overlay 手动吸顶 单容器撑不起独立刷新 结构上限不够
最终 JXPagingListRefreshView ------ 框架帮你搞定了协调

一句话方法论 :当"吸顶 + 嵌套滚动 + 多列表独立刷新"三个需求同时出现时,不要在原生控件组合里硬凑,直接上成熟框架。剩下的精力,花在框架的三个关键约定上(scrollCallback 保存回调、listContainer 原生联动、自动请求与 MJRefresh 解耦),以及真正属于业务的数据层设计上。

这就是方案好的原因------不是因为用了什么高级 API,而是踩过三次坑后知道哪些路根本走不通,然后站在了已经解决了同样问题的肩膀上。

相关推荐
CoderYanger1 小时前
Java EE 进阶:4.Spring Web MVC 入门(第一弹)
java·开发语言·前端·spring·面试·java-ee·mvc
用户2788607285671 小时前
fallback 会盖掉平台提示词:system、空数组和 systemAppend 的差别
前端·后端
peach1 小时前
大模型和传统编程:同为程序,差异不在硬件,在"逻辑从哪来"
前端
ALONE阿龙太原微码1 小时前
微信小程序包体积优化:2MB 到 400KB 的实操记录
前端
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(十一)
前端·后端
创了么OPC1 小时前
小程序能收款了,对公户还没开?独立开发者的交付闭环踩坑②
前端·微信小程序
IT_陈寒1 小时前
Vue动态组件缓存的坑你踩过了吗?
前端·人工智能·后端
寂寞狼牙冷2 小时前
封装fetch替换axios
前端
用户461169657282 小时前
网站 HTTPS 不稳定?证书排查指南
前端