实现方式:
- 顶部仅在到顶后继续下拉才触发的刷新。
- 滚到底部临界点后自动触发"加载更多"。
对应文件
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
}
}