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
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)
struct Demo: View {@State private var items = Array(0..<30)@State private var isLoading = falsevar body: some View {AutoLoadMoreView(loadMore: loadMore, refreshTop: { done inrefreshTop(done)}) {LazyVStack(spacing: 12) {ForEach(items, id: \.self) { i inText("Row \(i)").frame(maxWidth: .infinity).padding().background(Color.gray.opacity(0.2))}if isLoading {ProgressView().padding()}}.padding()}}// 触底自动加载更多func loadMore() {guard !isLoading else { return }isLoading = trueDispatchQueue.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 = trueDispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {items = Array(0..<30)isLoading = falsedone()}}
}
运行与交互
- 顶部指示区:
- 未触发阈值时显示“Pull to refresh”。
- 触发后显示
ProgressView()。 - 指示区高度与实际下拉位移映射(最大约 90% 的阈值高度)。
- 你可在
AutoLoadMoreView中定制:pullThreshold(下拉触发手感)- 指示区样式(图标/文字/高度/动画)

实现要点
- 使用
onScrollOffset(in:)捕获偏移,解决 iOS 15 下某些布局中 GeometryReader 读偏移不稳定的问题。 - 仅在到顶后继续下拉(
offset.y < 0)时才可能触发刷新,避免中段误触。 - 底部“哨兵”通过读取其在命名坐标系下的
minY与容器高度的差,近似计算距离底部的像素值。
常见问题
- 看不到顶部指示区:
- 确保内容足够多,能滚动到顶部后继续下拉;或在 Demo 增加条目数。
- 刷新结束不消失:
- 记得在刷新完成后调用
done()结束状态。
- 记得在刷新完成后调用
- 触底频繁触发:
- 在
loadMore()外部加 loading 状态防抖,或增加threshold。
- 在
组件代码
// MARK: - PreferenceKey 1: 内容总高度
struct ContentHeightKey: PreferenceKey {static var defaultValue: CGFloat = 0static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {value = max(value, nextValue()) // 取最大}
}// MARK: - PreferenceKey 2: 当前滚动偏移(顶部)
struct ScrollOffsetKey: PreferenceKey {static var defaultValue: CGFloat = 0static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {value = nextValue()}
}// MARK: - PreferenceKey 3: 底部哨兵的 minY(相对滚动容器)
struct BottomSentinelMinYKey: PreferenceKey {static var defaultValue: CGFloat = .infinitystatic 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: () -> Voidlet 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 = falsevar body: some View {GeometryReader { proxy inScrollView {VStack(spacing: 0) {// 顶部刷新指示器区域(仅在下拉或刷新中显示)topRefreshIndicator.frame(height: topIndicatorHeight).opacity(topIndicatorOpacity).animation(.easeInOut(duration: 0.15), value: topIndicatorHeight)content().background( // 读取内容总高度GeometryReader { innerGeo inColor.clear.preference(key: ContentHeightKey.self,value: innerGeo.size.height)})// 底部哨兵(用于“距离底部阈值触发”)Color.clear.frame(height: 1).background(GeometryReader { g inColor.clear.preference(key: BottomSentinelMinYKey.self,value: g.frame(in: .named("scroll")).minY)})}// 使用通用工具捕获滚动偏移(y<0 为顶部下拉回弹).onScrollOffset(in: "scroll") { off inscrollOffset = off.y}}.coordinateSpace(name: "scroll").onPreferenceChange(ContentHeightKey.self) { value incontentHeight = value}.onPreferenceChange(BottomSentinelMinYKey.self) { value insentinelMinY = value}.onAppear {containerHeight = proxy.size.height}// 关键:计算是否触底.onChange(of: sentinelMinY) { _ inlet distanceToBottom = sentinelMinY - containerHeightif distanceToBottom <= threshold {loadMore()}}// 顶部下拉刷新:scrollOffset < 0 表示顶部回弹,仅在顶端触发.onChange(of: scrollOffset) { newValue inguard newValue < 0 else { return }if newValue <= -pullThreshold, !isRefreshingTop {isRefreshingTop = truerefreshTop?({// 调用方在数据更新完成后回调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)}
}
ScrollOffsetTracker.swift
import SwiftUIstruct ScrollOffset: Equatable { var x: CGFloat; var y: CGFloat }private struct ScrollOffsetPreferenceKey: PreferenceKey {static var defaultValue: ScrollOffset = .init(x: 0, y: 0)static func reduce(value: inout ScrollOffset, nextValue: () -> ScrollOffset) { value = nextValue() }
}private struct TrackScrollOffset: ViewModifier {let coordinateSpace: Stringlet onChange: (ScrollOffset) -> Voidfunc body(content: Content) -> some View {content.overlay(alignment: .topLeading) {GeometryReader { geo inlet f = geo.frame(in: .named(coordinateSpace))Color.clear.preference(key: ScrollOffsetPreferenceKey.self,value: ScrollOffset(x: -f.minX, y: -f.minY))}.frame(height: 0) // marker}.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onChange)}
}extension View {func onScrollOffset(in coordinateSpace: String, perform: @escaping (ScrollOffset) -> Void) -> some View {modifier(TrackScrollOffset(coordinateSpace: coordinateSpace, onChange: perform))}
}
