SwiftUI 吸顶分页封装:MJPagingView 一行搞定微博/简书式列表

前言

在 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 变化时:

  1. SwiftUI 调用 updateUIViewController
  2. 将新数据传给 MJPagingViewController
  3. Controller 调用 applyUpdate 同步状态
  4. 内部使用 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 封装的核心价值:

  1. 一行代码 :从复杂的 UIKit 吸顶分页逻辑,到简洁的 SwiftUI 声明式 API
  2. 两种子列表 :支持 UITableView 和 UIScrollView,满足不同布局需求
  3. 两种刷新模式 :灵活应对产品需求变化
  4. 状态同步 :基于 Diffable Data Source,保证数据一致性
  5. 吸顶偏移 :优雅处理透明导航栏场景
    链接🔗
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容