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
    }
}
相关推荐
终端安全笔记4 小时前
iOS 27 之后「策略空转」:设备升级不报错,但旧策略不再管它
android·网络·安全·ios·智能手机
9765033357 小时前
iOS 上架 4.3a 被拒【uniapp专讲】
flutter·ios·objective-c·uniapp·swift
黑化旺仔20 小时前
【OC】KVO
macos·ios·objective-c·cocoa
HouWan1 天前
SwiftUI 小技巧:如何读取 SwiftUI Font 的详细信息
ios·swiftui·apple
李游Leo1 天前
HarmonyOS 7 实战开发 05:把页面做到可上线状态
ios·harmonyos
终端安全笔记1 天前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
传奇开心果编程1 天前
【SwiftUI入门练中学】第1课 从零开始
学习·ui·ios·swiftui·swift
丿Wayne1 天前
iOS Token 刷新实战(Actor 避免并发重复刷新)
ios·swift·token·actor·swift并发
终端安全笔记2 天前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩2 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift