SwiftUI 列表封装实战:一行代码搞定下拉刷新 + 上拉加载

前言

在 SwiftUI 开发中,你是否遇到过这些痛点?

  • List 性能在大数据量下表现不佳
  • 原生 Refreshable 功能有限,自定义困难
  • 上拉加载更多需要自己实现,逻辑繁琐
  • Section Header/Footer 吸顶效果难以控制
  • SwiftUI 与 UIKit 混编时状态同步麻烦
    基于这些问题,我封装了 MJTableView —— 基于 UITableView + MJRefresh + UIHostingConfiguration ,通过 Diffable Data Source 的 reconfigure 保证 SwiftUI 状态实时同步。

核心功能

  • ✅ 流畅的下拉刷新
  • ✅ 智能的上拉加载
  • ✅ 优雅的 Section 吸顶
  • ✅ 灵活的多 Cell 支持
  • ✅ SwiftUI 式的声明式 API
  • ✅ 自动状态同步

一、单 Section 列表:MJTableView

1.1 最简用法

MJTableView(items: $items, onRefresh: 
refresh, onLoadMore: loadMore) { item in
    FeedCell(item: item)
}

1.2 完整配置示例

MJTableView(
    items: $items,
    hasMore: hasMore,
    supplementary: supplementary,
    style: .plain,
    separatorStyle: .singleLine,
    rowHeight: nil,
    showsVerticalScrollIndicator: true,
    onRefresh: refresh,
    onLoadMore: loadMore
) { item in
    FeedCell(item: item)
}

1.3 附属视图配置(链式调用)

let supplementary = MJTableSupplementary()
    .tableHeader(height: 200) {
        BannerView()
    }
    .sectionHeader(height: 44) {
        SectionTitleView(title: "推荐")
    }
    .sectionFooter(pinToBottom: true) {
        Text("共 \(items.count) 条")
    }
    .tableFooter(height: 56) {
        FooterView()
    }

关键参数说明:

  • pinToBottom: true :Section Footer 贴在列表最底部(不足一屏时自动留白)
  • height: nil :自动计算高度( systemLayoutSizeFitting )

二、多 Section 列表:MJSectionedTableView

2.1 定义 Section 类型

enum FeedSection: Hashable {
    case hot
    case feed
}

2.2 构建分区数据

@State private var sections: 
[MJTableSection<FeedSection, Item>] = [
    MJTableSection(id: .hot, items: 
    hotItems) {
        .sectionHeader { HotHeaderView() }
    },
    MJTableSection(id: .feed, items: 
    feedItems) {
        .sectionHeader { FeedHeaderView
        () }
    }
]

2.3 使用多 Section 列表

MJSectionedTableView(sections: $sections) 
{ section, item in
    switch section {
    case .hot: HotCell(item)
    case .feed: FeedCell(item)
    }
}

三、多种 Cell 类型支持

3.1 定义多类型 Row

enum Row: Identifiable, Hashable, 
MJTableCellTypable {
    case post(Post)
    case banner(String)
    case ads(Ad)
    
    var id: String { /* ... */ }
    
    var mjCellReuseID: String {
        switch self {
        case .post: return "PostCell"
        case .banner: return "BannerCell"
        case .ads: return "AdsCell"
        }
    }
    
    var mjRowHeight: CGFloat? {
        switch self {
        case .banner: return 120
        case .ads: return 80
        default: return nil
        }
    }
}

3.2 使用多 Cell 类型

MJTableView(
    items: $rows,
    cellReuseID: { $0.mjCellReuseID },
    rowHeightForItem: { $0.mjRowHeight }
) { row in
    switch row {
    case .post(let p): PostCell(p)
    case .banner(let t): BannerCell(t)
    case .ads(let a): AdsCell(a)
    }
}

四、下拉刷新与上拉加载

MJTableView(
    items: $items,
    hasMore: hasMore,
    onRefresh: { end in
        Task {
            await fetchData()
            end()
        }
    },
    onLoadMore: { end in
        Task {
            let more = await loadMoreData
            ()
            end(more)
        }
    }
) { item in
    FeedCell(item: item)
}

五、状态同步:SwiftUI 响应式更新

封装内部通过 Diffable Data Source 实现状态同步:

if itemIDs == previousIDs {
    reconfigureAllItems()
} else {
    reloadSnapshot(animating: true)
}

配合 configurationUpdateHandler 实现 Cell 级别的实时更新:

cell.configurationUpdateHandler = { [weak 
self] cell, _ in
    guard let item = self?.item(for: id) 
    else { return }
    cell.contentConfiguration = 
    UIHostingConfiguration {
        CellView(item: item)
    }
}

六、技术实现细节

6.1 核心架构

  • UIViewControllerRepresentable :桥接 UIKit 和 SwiftUI
  • UIHostingConfiguration :将 SwiftUI View 嵌入 UITableViewCell
  • UITableViewDiffableDataSource :高效的数据更新
  • MJRefresh :下拉刷新和上拉加载

6.2 关键技术点 1. 避免 UITableViewController 尺寸为 0

final class MJTableViewController<Item, 
CellContent: View>: UIViewController, 
UITableViewDelegate {
    let tableView: UITableView
    
    init(...) {
        self.tableView = UITableView
        (frame: .zero, style: style)
        super.init(nibName: nil, bundle: 
        nil)
        // 使用 Auto Layout 嵌入 SwiftUI
    }
}
  1. 自定义吸顶条避免缝隙
private let stickySectionHeaderBar = 
UIView()
private var stickySectionHeaderHosting: 
UIHostingController<AnyView>?
private var 
stickySectionHeaderHeightConstraint: 
NSLayoutConstraint?
  1. Section Footer 贴底实现
private func layoutPinnedBottomFooterIfNeeded() {
    guard usesPinnedSectionFooter, let container = pinnedBottomContainer 
    else { return }
    
    let visibleHeight = tableView.bounds.
    height
        - tableView.adjustedContentInset.
        top
        - tableView.adjustedContentInset.
        bottom
    let spacerHeight = max(0, 
    visibleHeight - 
    contentHeightWithoutBottom - 
    footerContentHeight)
    
    pinnedBottomSpacer.frame = CGRect(x: 
    0, y: 0, width: width, height: 
    spacerHeight)
    sectionView.frame = CGRect(x: 0, y: 
    spacerHeight, width: width, height: 
    sectionHeight)
}

七、避坑指南

7.1 MJRefresh 控制台警告

问题 : NSBundle (null) initWithPath failed

原因 :MJRefresh 默认用系统语言(如 zh-Hans-CN )去 MainBundle 找 .lproj ,路径为空。

解决方案 :在 App.init 中调用配置:

MJRefreshConfigurator.setup()

7.2 Section Header 吸顶缝隙

问题 :系统 Section Header 与 MJRefresh/contentInset 叠加产生顶部缝隙

解决方案 :封装内部使用自定义吸顶条,自动处理边界情况。

八、完整示例代码

struct MJTableViewDemoView: View {
    @State private var items: [FeedItem] 
    = []
    @State private var hasMore = true
    @State private var nextPage = 1
    
    private var supplementary: 
    MJTableSupplementary {
        MJTableSupplementary()
            .tableHeader {
                DemoTableHeaderView
                (itemCount: items.count)
            }
            .sectionHeader(height: 44) {
                DemoSectionHeaderView()
            }
            .sectionFooter(pinToBottom: 
            true) {
                DemoSectionFooterView
                (itemCount: items.count, 
                hasMore: hasMore)
            }
            .tableFooter(height: 52) {
                DemoTableFooterView()
            }
    }
    
    var body: some View {
        NavigationStack {
            MJTableView(
                items: $items,
                hasMore: hasMore,
                supplementary: 
                supplementary,
                onRefresh: refresh,
                onLoadMore: loadMore
            ) { item in
                FeedRowView(item: item)
            }
        }
        .task {
            guard items.isEmpty else { 
            return }
            await fetchPage(page: 1, 
            replacing: true)
        }
    }
    
    private func refresh(end: @escaping 
    () -> Void) {
        Task {
            await fetchPage(page: 1, 
            replacing: true)
            end()
        }
    }
    
    private func loadMore(end: @escaping 
    (Bool) -> Void) {
        Task {
            let more = await fetchPage
            (page: nextPage, replacing: 
            false)
            end(more)
        }
    }
}

九、总结

MJTableView 的核心设计理念:

  1. 性能优先 :基于 UITableView,大数据量依然流畅
  2. API 友好 :SwiftUI 声明式风格,学习成本低
  3. 功能完备 :下拉刷新、上拉加载、Section 吸顶一应俱全
  4. 扩展性强 :支持多 Cell 类型、自定义复用池、独立行高
    如果你也在 SwiftUI 中饱受列表性能问题困扰,不妨试试这个封装!
    Gitee 🔗
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容