前言
在 iOS 开发中,微博、简书、知乎等 App 常见的吸顶分页列表如何实现?
- 顶部 Header 随滚动隐藏
- Tab 吸顶在顶部
- 各 Tab 可独立下拉刷新 / 上拉加载
- SwiftUI 声明式 API
基于 JXPagingView + JXSegmentedView + MJRefresh ,我封装了 MJPagingView ,只需一行代码即可实现上述功能。
一、核心功能
- ✅ 微博/简书式吸顶分页
- ✅ Header + Tab + 多列表
- ✅ 两种刷新模式支持
- ✅ UITableView / UIScrollView 子列表
- ✅ SwiftUI 声明式 API
- ✅ 状态实时同步
二、整体架构
MJPagingView 提供两种子列表实现:
- MJPagingView :子列表为 UITableView(适合 Feed 流)
- MJPagingScrollView :子列表为 UIScrollView + SwiftUI(适合瀑布流、卡片栅格)
┌─────────────────────────────┐
│ Header View │
│ (随滚动隐藏/吸顶) │
├─────────────────────────────┤
│ Segment Tab Bar │
│ (吸顶在顶部) │
├─────────────────────────────┤
│ │
│ List Content │
│ (各 Tab 独立列表) │
│ │
└─────────────────────────────┘
三、快速开始
3.1 定义数据模型
struct FeedItem: Identifiable, Hashable {
let id: Int
var title: String
var subtitle: String
var likes: Int
}
struct MJPagingPageData<Item: Identifiable & Hashable>: Identifiable, Hashable {
var id: String { title }
var title: String
var items: [Item]
var hasMore: Bool
init(title: String, items: [Item] = [], hasMore: Bool = true) {
self.title = title
self.items = items
self.hasMore = hasMore
}
}
3.2 最简用法
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 200
) {
// Header 视图
Text("我是 Header")
} cellContent: { item in
// Cell 视图
FeedCell(item: item)
}
四、完整配置示例
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 200,
tabBarHeight: 48,
pinSectionHeaderVerticalOffset: 44,
refreshMode: .perTabOnly(onRefresh: refreshTab, onLoadMore: loadMoreTab),
automaticallyBeginsMainRefresh: false,
style: .plain,
separatorStyle: .singleLine
) {
HeaderView()
} cellContent: { item in
FeedCell(item: item)
}
4.1 参数说明

4444.png
五、刷新模式
5.1 方式一:各 Tab 独立下拉 + 上拉
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 200,
refreshMode: .perTabOnly(
onRefresh: { index, done in
Task {
await fetchPage(tab: index, page: 1, replacing: true)
done()
}
},
onLoadMore: { index, done in
Task {
let hasMore = await fetchPage(tab: index, page: nextPage, replacing: false)
done(hasMore)
}
}
)
) {
HeaderView()
} cellContent: { item in
FeedCell(item: item)
}
5.2 方式二:整页下拉 + 各 Tab 仅上拉
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 200,
refreshMode: .mainPullListLoadMore(
onMainRefresh: { done in
Task {
for index in pages.indices {
await fetchPage(tab: index, page: 1, replacing: true)
}
done()
}
},
onListLoadMore: { index, done in
Task {
let hasMore = await fetchPage(tab: index, page: nextPage, replacing: false)
done(hasMore)
}
}
),
automaticallyBeginsMainRefresh: true
) {
HeaderView()
} cellContent: { item in
FeedCell(item: item)
}
六、Header 与 Tab 配置
6.1 自定义 Header
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 200
) {
VStack(alignment: .leading, spacing: 8) {
Text("全局数据统计")
.font(.title)
Text("总点赞:\(totalLikes)")
HStack {
ForEach(pages) { page in
Text("\(page.title): \(page.items.count)")
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom))
.foregroundStyle(.white)
} cellContent: { item in
FeedCell(item: item)
}
七、MJPagingScrollView:ScrollView 子列表
当子列表不是 TableView(如瀑布流、卡片栅格)时,使用 MJPagingScrollView:
MJPagingScrollView(
tabs: $tabs,
selectedIndex: $selectedIndex,
headerHeight: 200,
refreshMode: .perTabOnly(onRefresh: refreshTab, onLoadMore: loadMoreTab)
) {
HeaderView()
} tabContent: { index in
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(items[index]) { item in
CardView(item: item)
}
}
}
}
八、完整示例代码
struct ContentView: View {
@State private var pages: [MJPagingPageData<FeedItem>] = [
MJPagingPageData(title: "推荐"),
MJPagingPageData(title: "关注"),
MJPagingPageData(title: "热门"),
]
@State private var selectedIndex = 0
@State private var totalLikes = 0
var body: some View {
NavigationStack {
MJPagingView(
pages: $pages,
selectedIndex: $selectedIndex,
headerHeight: 180,
tabBarHeight: 48,
refreshMode: .perTabOnly(
onRefresh: refreshTab,
onLoadMore: loadMoreTab
)
) {
VStack(alignment: .leading) {
Text("总点赞:\(totalLikes)")
.font(.headline)
Text("当前 Tab:\(pages[selectedIndex].title)")
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.8))
.foregroundStyle(.white)
} cellContent: { item in
FeedRowView(item: item)
}
.navigationTitle("吸顶分页")
.navigationBarTitleDisplayMode(.inline)
}
.task {
await bootstrap()
}
}
private func refreshTab(index: Int, done: @escaping () -> Void) {
Task {
await fetchPage(tab: index, page: 1, replacing: true)
totalLikes = pages.flatMap(\.items).reduce(0) { $0 + $1.likes }
done()
}
}
private func loadMoreTab(index: Int, done: @escaping (Bool) -> Void) {
Task {
let hasMore = await fetchPage(tab: index, page: nextPage(tab: index), replacing: false)
totalLikes = pages.flatMap(\.items).reduce(0) { $0 + $1.likes }
done(hasMore)
}
}
private func nextPage(tab: Int) -> Int {
max(pages[tab].items.count / 15, 0) + 1
}
private func bootstrap() async {
for index in pages.indices {
await fetchPage(tab: index, page: 1, replacing: true)
}
totalLikes = pages.flatMap(\.items).reduce(0) { $0 + $1.likes }
}
@discardableResult
private func fetchPage(tab: Int, page: Int, replacing: Bool) async -> Bool {
try? await Task.sleep(for: .milliseconds(600))
let start = (page - 1) * 15
let newItems = (start ..< start + 15).map { i in
FeedItem(
id: tab * 10_000 + i,
title: "\(pages[tab].title) #\(i + 1)",
subtitle: "MJRefresh 演示",
likes: Int.random(in: 0 ... 20)
)
}
await MainActor.run {
if replacing {
pages[tab].items = newItems
} else {
pages[tab].items.append(contentsOf: newItems)
}
pages[tab].hasMore = page < 4
}
return page < 4
}
}
九、技术实现:SwiftUI 与 UIKit 嫁接原理
9.1 什么是 UIViewControllerRepresentable
SwiftUI 要使用 UIKit 的视图(如 UITableView、JXPagingView),需要通过 UIViewControllerRepresentable 进行"嫁接"。
┌─────────────────────┐
│ SwiftUI │
│ MJPagingView │
│ (UIViewController │
│ Representable) │
└──────────┬──────────┘
│
│ makeUIViewController
│ updateUIViewController
▼
┌─────────────────────┐
│ UIKit │
│ MJPagingViewController │
│ (UIViewController) │
└─────────────────────┘
9.2 核心协议
struct MJPagingView<Item, Header, CellContent>: UIViewControllerRepresentable {
// SwiftUI 创建 UIKit 视图时调用
func makeUIViewController(context: Context) -> MJPagingViewController<Item, CellContent> {
MJPagingViewController(...)
}
// SwiftUI 状态更新时调用
func updateUIViewController(_ controller: MJPagingViewController<Item, CellContent>, context: Context) {
controller.applyUpdate(...)
}
}
9.3 数据流向
┌─────────────────────────────────────────────────────────────┐
│ SwiftUI 世界 │
│ │
│ @State pages: [MJPagingPageData] │
│ │ │
│ │ $pages (Binding) │
│ ▼ │
│ MJPagingView.makeUIViewController() │
│ │ │
│ ▼ │
│ MJPagingViewController │
│ │ │
│ ▼ │
│ JXPagingView (UIKit) │
│ │
└─────────────────────────────────────────────────────────────┘
9.4 状态同步机制
SwiftUI 的 @State 变化时:
- SwiftUI 调用 updateUIViewController
- 将新数据传给 MJPagingViewController
- Controller 调用 applyUpdate 同步状态
- 内部使用 Diffable Data Source 判断是 reconfigure (更新内容)还是 reloadSnapshot (完整刷新)
if itemIDs == previousIDs {
// 仅更新内容,不重新创建 Cell
reconfigureAllItems()
} else {
// 完整刷新动画
reloadSnapshot(animating: true)
}
9.5 生命周期协调
final class Coordinator {
var selectedIndex: Binding<Int>
}
// 在 SwiftUI 端可以通过 Coordinator 访问 UIKit 端的状态
func makeCoordinator() -> Coordinator {
Coordinator(selectedIndex: $selectedIndex)
}
9.6 两种刷新模式原理

d5aaa9df81ba62d865e60c7c8bf8f43b.png
十、注意事项
10.1 子列表不要设置 tableHeader
子列表的顶部区域由外层 header 提供,不要在 Cell 内容中重复设置。
10.2 listContainerType 使用 scrollView
使用 .scrollView 避免 MJRefresh 与 CollectionView 缓存冲突。
10.3 刷新模式切换
切换刷新模式会触发完整的视图重建,确保状态正确同步。
十一、总结
MJPagingView 封装的核心价值:
- 一行代码 :从复杂的 UIKit 吸顶分页逻辑,到简洁的 SwiftUI 声明式 API
- 两种子列表 :支持 UITableView 和 UIScrollView,满足不同布局需求
- 两种刷新模式 :灵活应对产品需求变化
- 状态同步 :基于 Diffable Data Source,保证数据一致性
- 吸顶偏移 :优雅处理透明导航栏场景
链接🔗