学习猫学习猫
目录
前言
最终实现效果如图所示:
一、需求要点
二、入坑三次:三个方案为什么挂
入坑 1:UITableView 嵌套 UICollectionView
入坑 2:UICollectionView Section Header 吸顶
入坑 3:TabBar Overlay + 手动吸顶
三、正确方案:JXPagingListRefreshView
为什么是它
最终结构
嵌套滚动协调原理(重点)
四、三个必踩的坑(每个都写了错误示范 + 正确写法)
坑 1:listViewDidScrollCallback 必须保存并触发
坑 2:Tab 切换必须走 segmentedView.listContainer 原生联动
坑 3:切 Tab 自动拉数据,不要借道 MJRefresh
五、吸顶实现原理(pinSectionHeader)
六、下拉刷新为什么天然在 TabBar 下方
七、数据层:每个 Tab 独立缓存
八、总结

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

你会学到什么:京东/美团首页那种"头部固定、中间滚、Tab 吸顶、每个 Tab 各刷各的"效果,iOS 端到底怎么实现。

我踩了三次坑——TableView 嵌套 CollectionView 手势冲突、Section Header 吸顶被 reload 打穿、Overlay 手动吸顶撑不起独立刷新——每一次的死因、本质问题、正确方案的嵌套滚动协调原理,全部写在这里。

如果你正在做类似需求,或者好奇为什么"一个滚动视图套另一个"在 iOS 上这么难调,这篇就是为你准备的。

最终实现效果如图所示: home_jxpaging_effect.png

一、需求要点

先把交互拆成最原子的约束,每一条都不可妥协:

#约束含义
1头部定位栏固定不滚动滚到哪里都看到地址
2网格 5x3 跟随页面滚动滚出屏幕就看不到
3TabBar 滚到顶部后吸顶再怎么滚都钉在顶部
45 个 Tab 各自独立下拉刷新刷新头在 TabBar 正下方,各自列表刷各自的
55 个 Tab 各自独立上拉加载滚到底各自触发,互不干扰
6切 Tab 秒显旧数据无缓存才请求
7整个页面一个手指滚动不能有手势冲突

其中第 4 条是硬门槛 ——它直接决定了"每个 Tab 必须拥有独立的 scrollView",从根上排除了单 collectionView 方案。


二、入坑三次:三个方案为什么挂

入坑 1:UITableView 嵌套 UICollectionView

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 吸顶

collectionView (一个滚动视图)
├── section 0: 网格 cell (1 row)
└── section 1:
    ├── supplementary header: TabBar (pinned)  ← sectionHeadersPinToVisibleBounds = true
    └── items: 列表卡片 (双列)

吸顶本身是 iOS 9+ 原生支持的,效果也丝滑。死在了数据刷新上:

// 点击 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里手动算位置:

// 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 条。

最终结构

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 的协调机制是三级状态机:

状态 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里触发:

// ❌ 错误示范(我最初的代码)
func listViewDidScrollCallback(callback: @escaping (UIScrollView) -> Void) {
    // 什么都不做,注释写着"JXPagingView 内部处理"
}

结果:JXPagingView 收不到列表滚动事件,无法判断当前列表滚没滚完,mainTableView 和列表 scrollView 无法交接滚动权 → 页面空白、列表冻住,且无任何报错。

// ✅ 正确写法
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 原生联动

拿到框架后第一反应是手动切列表:

// ❌ 错误示范(我最初的代码)
func tabBarView(_ view: TabBarView, didSelect index: Int) {
    // 手动强刷容器,列表视图重建时滚动手势未挂好
    pagingView.listContainerView.defaultSelectedIndex = index
    pagingView.listContainerView.reloadData()
}

结果:reloadData()会销毁旧的列表实例、创建新的,新实例的scrollCallback还没被框架设置好 → 新 Tab 页面空白。

正确做法是让JXSegmentedView和列表容器关联,点击 Tab 的切换完全由框架接管:

// ✅ 在 setupUI 里建立原生关联
tabBarView.segmentedView.listContainer = pagingView.listContainerView

业务侧只需在 delegate 里做数据层切换:

// ✅ 业务层只做数据,不碰 UI 容器
func tabBarView(_ view: TabBarView, didSelect index: Int) {
    listViewModel.switchTab(index: index)    // 切换缓存
    if listViewModel.listItems.isEmpty {
        listViewModel.refreshData()           // 无缓存静默请求
    }
}

坑 3:切 Tab 自动拉数据,不要借道 MJRefresh

新 Tab 没有缓存时想自动拉数据,第一反应是触发下拉刷新动画:

// ❌ 错误示范
if listViewModel.listItems.isEmpty {
    listPageViews[index].beginHeaderRefreshing()
}

beginHeaderRefreshing依赖 scrollView 已完成布局、mj_header已添加到 scrollView。刚切换的列表可能还没上屏,调用会静默失效 ——不报错、也不请求,用户看到的就是空白页。

正确做法:直接请求数据,不借道刷新控件。MJRefresh 只留给用户手动下拉用,两条链路解耦:

// ✅ 正确
if listViewModel.listItems.isEmpty {
    listViewModel.refreshData()  // 静默请求,不依赖 MJRefresh 状态机
}

五、吸顶实现原理(pinSectionHeader)

pinSectionHeader是 JXPagingView 提供的 TabBar 吸顶能力,内部实现并不黑箱,理解它有助于排查问题:

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 底部开始:

listContainerView cell (mainTableView 的最后一行)
└── 每个列表 scrollView 的 frame:
    origin.y = tabBarHeight  ← 从 TabBar 底部开始
    height = mainTableView.bounds.height

mj_header挂在这个 scrollView 上,下拉时刷新头从origin.y = tabBarHeight的位置出现——正好就是TabBar 正下方。

这是 JXPagingListRefreshView 的结构设计决定的,不需要我们手动算刷新头位置。


七、数据层:每个 Tab 独立缓存

UI 靠框架搞定了,数据层保证切 Tab 秒显:

/// 单个 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 页重拉,坐标随请求下发,距离字段后端计算

八、总结

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

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

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

来源:掘金 · hunter_076

评论
登录后参与讨论
暂无数据
还没有评论,来说点什么吧