SwiftUI 组件开发: 自定义下拉刷新和加载更多(iOS 15 兼容)

实现方式:

  • 顶部仅在到顶后继续下拉才触发的刷新。
  • 滚到底部临界点后自动触发"加载更多"。

对应文件

  • ScrollOffsetTracker.swift
    • 通用滚动偏移捕获工具(Geometry + PreferenceKey),兼容 iOS 15。
  • SwiftUIDemo/LoadMoreView.swift
    • 组件:AutoLoadMoreView<Content: View>,内部集成"顶部下拉刷新 + 底部加载更多"。

通用偏移捕获工具

  • 提供修饰器:onScrollOffset(in: String, perform: (ScrollOffset) -> Void)
  • 必须与 ScrollView.coordinateSpace(name:) 配合使用。
  • 回调中 offset.y < 0 表示在顶部发生了回弹式下拉。

组件 API

swift 复制代码
struct AutoLoadMoreView<Content: View>: View {
    // 触底阈值(距离底部 <= threshold 触发)
    let threshold: CGFloat = 60

    // 顶部下拉阈值(到顶后继续下拉,偏移绝对值达到该值触发)
    let pullThreshold: CGFloat = 50

    // 到达底部触发
    let loadMore: () -> Void

    // 顶部下拉刷新回调(带完成回调,由调用方结束刷新)
    let refreshTop: ((_ done: @escaping () -> Void) -> Void)?

    // 内容构建
    let content: () -> Content
}
  • 顶部刷新结束时机由调用方掌控:完成数据更新后调用 done()
  • 底部"加载更多"无去重功能,调用方需自行防抖/状态管理。

使用示例(Demo)

swift 复制代码
struct Demo: View {
    @State private var items = Array(0..<30)
    @State private var isLoading = false

    var body: some View {
        AutoLoadMoreView(loadMore: loadMore, refreshTop: { done in
            refreshTop(done)
        }) {
            LazyVStack(spacing: 12) {
                ForEach(items, id: \.self) { i in
                    Text("Row \(i)")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.gray.opacity(0.2))
                }
                if isLoading {
                    ProgressView().padding()
                }
            }
            .padding()
        }
    }

    // 触底自动加载更多
    func loadMore() {
        guard !isLoading else { return }
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            items += Array(items.count..<items.count + 30)
            isLoading = false
        }
    }

    // 顶部下拉刷新(调用 done() 结束刷新)
    func refreshTop(_ done: @escaping () -> Void) {
        guard !isLoading else { done(); return }
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
            items = Array(0..<30)
            isLoading = false
            done()
        }
    }
}

运行与交互

  • 顶部指示区:
    • 未触发阈值时显示"Pull to refresh"。
    • 触发后显示 ProgressView()
    • 指示区高度与实际下拉位移映射(最大约 90% 的阈值高度)。
  • 你可在 AutoLoadMoreView 中定制:
    • pullThreshold(下拉触发手感)
    • 指示区样式(图标/文字/高度/动画)

实现要点

  • 使用 onScrollOffset(in:) 捕获偏移,解决 iOS 15 下某些布局中 GeometryReader 读偏移不稳定的问题。
  • 仅在到顶后继续下拉(offset.y < 0)时才可能触发刷新,避免中段误触。
  • 底部"哨兵"通过读取其在命名坐标系下的 minY 与容器高度的差,近似计算距离底部的像素值。

常见问题

  • 看不到顶部指示区:
    • 确保内容足够多,能滚动到顶部后继续下拉;或在 Demo 增加条目数。
  • 刷新结束不消失:
    • 记得在刷新完成后调用 done() 结束状态。
  • 触底频繁触发:
    • loadMore() 外部加 loading 状态防抖,或增加 threshold

组件代码

swift 复制代码
// MARK: - PreferenceKey 1: 内容总高度
struct ContentHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue()) // 取最大
    }
}

// MARK: - PreferenceKey 2: 当前滚动偏移(顶部)
struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// MARK: - PreferenceKey 3: 底部哨兵的 minY(相对滚动容器)
struct BottomSentinelMinYKey: PreferenceKey {
    static var defaultValue: CGFloat = .infinity
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// MARK: - 底部加载更多容器
struct AutoLoadMoreView<Content: View>: View {
    let threshold: CGFloat = 60 // 距离底部阈值
    let pullThreshold: CGFloat = 50 // 顶部下拉阈值
    let loadMore: () -> Void
    let refreshTop: ((_ done: @escaping () -> Void) -> Void)? // 顶部刷新回调(带完成回调)
    let content: () -> Content
    
    @State private var contentHeight: CGFloat = 0
    @State private var scrollOffset: CGFloat = 0
    @State private var containerHeight: CGFloat = 0
    @State private var sentinelMinY: CGFloat = .infinity
    @State private var isRefreshingTop: Bool = false
    
    var body: some View {
        GeometryReader { proxy in
            ScrollView {
                VStack(spacing: 0) {
                    // 顶部刷新指示器区域(仅在下拉或刷新中显示)
                    topRefreshIndicator
                        .frame(height: topIndicatorHeight)
                        .opacity(topIndicatorOpacity)
                        .animation(.easeInOut(duration: 0.15), value: topIndicatorHeight)
                        
                    content()
                        .background( // 读取内容总高度
                            GeometryReader { innerGeo in
                                Color.clear
                                    .preference(key: ContentHeightKey.self,
                                              value: innerGeo.size.height)
                            }
                        )
                    // 底部哨兵(用于"距离底部阈值触发")
                    Color.clear
                        .frame(height: 1)
                        .background(
                            GeometryReader { g in
                                Color.clear
                                    .preference(
                                        key: BottomSentinelMinYKey.self,
                                        value: g.frame(in: .named("scroll")).minY
                                    )
                            }
                        )
                }
                // 使用通用工具捕获滚动偏移(y<0 为顶部下拉回弹)
                .onScrollOffset(in: "scroll") { off in
                    scrollOffset = off.y
                }
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(ContentHeightKey.self) { value in
                contentHeight = value
            }
            .onPreferenceChange(BottomSentinelMinYKey.self) { value in
                sentinelMinY = value
            }
            .onAppear {
                containerHeight = proxy.size.height
            }
            // 关键:计算是否触底
            .onChange(of: sentinelMinY) { _ in
                let distanceToBottom = sentinelMinY - containerHeight
                if distanceToBottom <= threshold {
                    loadMore()
                }
            }
            // 顶部下拉刷新:scrollOffset < 0 表示顶部回弹,仅在顶端触发
            .onChange(of: scrollOffset) { newValue in
                guard newValue < 0 else { return }
                if newValue <= -pullThreshold, !isRefreshingTop {
                    isRefreshingTop = true
                    refreshTop?({
                        // 调用方在数据更新完成后回调
                        isRefreshingTop = false
                    })
                }
            }
        }
    }
    
    // MARK: - 顶部刷新指示视图
    private var topIndicatorHeight: CGFloat {
        if isRefreshingTop { return 44 }
        return min(max(-scrollOffset, 0), pullThreshold * 0.9)
    }
    private var topIndicatorOpacity: Double { topIndicatorHeight > 0 ? 1 : 0 }
    private var topRefreshIndicator: some View {
        HStack(spacing: 8) {
            if isRefreshingTop {
                ProgressView().progressViewStyle(.circular)
            } else {
                Image(systemName: "arrow.down.circle")
                    .font(.system(size: 16, weight: .semibold))
            }
            Text(isRefreshingTop ? "Refreshing..." : "Pull to refresh")
                .font(.footnote)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity)
    }

    // MARK: - Bridge to native refreshable (only triggers at top)
    private func handleTopRefresh() async {
        // 若已在自定义刷新中,直接等待一会
        if isRefreshingTop {
            try? await Task.sleep(nanoseconds: 300_000_000)
            return
        }
        isRefreshingTop = true
        refreshTop?({})
        // 等待调用方完成(此处简易等待以结束系统菊花),可按需改为回调通知
        try? await Task.sleep(nanoseconds: 800_000_000)
        isRefreshingTop = false
    }
}
相关推荐
2501_915918414 小时前
Fiddler抓包工具详解,HTTP/HTTPS抓包、代理设置与调试技巧一站式教程(含实战案例)
http·ios·小程序·https·fiddler·uni-app·webview
库奇噜啦呼6 小时前
【iOS】UICollectionView
macos·ios·cocoa
qixingchao7 小时前
iOS Swift 线程开发指南
ios·swift
AirDroid_cn7 小时前
在 iOS 18 离线徒步地图,如何存储和调用?
ios
2501_915909067 小时前
iOS 发布 App 全流程指南,从签名打包到开心上架(Appuploader)跨平台免 Mac 上传实战
android·macos·ios·小程序·uni-app·cocoa·iphone
2501_9159184121 小时前
HTTP抓包工具推荐,Fiddler使用教程、代理设置与调试技巧详解(含HTTPS配置与实战案例)
http·ios·小程序·https·fiddler·uni-app·webview
mjhcsp1 天前
C++ 贪心算法(Greedy Algorithm)详解:从思想到实战
c++·ios·贪心算法
Digitally1 天前
如何在iPhone 17/16/15上显示电池百分比
ios·cocoa·iphone