当前位置: 首页 > news >正文

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))}
}
http://www.dtcms.com/a/576886.html

相关文章:

  • 【面试】分布式事务与分布式锁:核心原理与工程实践
  • 大连制作网站企业优化网站性能
  • 搜索引擎索引权威指南:抓取、收录与排名的基础
  • 电脑关机重启时显示rundll32 内存不能为read解决方法
  • 【P7】docker镜像发布和部署
  • 电脑启动时报 0xc000000e —— 原因解析与多种修复策略
  • 网站建设需求范文包装回收网站建设
  • 使用 Apache Jena 构建 Java 知识图谱
  • ICLR 2025 | 告别“非黑即白”!X-CLR引入“相似度图谱”,让模型读懂万物关联!
  • 【图像处理基石】什么是alpha matting?
  • 面试后查缺补漏--cmake,makefiles,g++,gcc(自写精华版)
  • 使用房屋价格预测的场景,展示如何从多个影响因素计算权重和偏置的梯度
  • 企业网站的首页设计模板天津seo方案
  • 微服务之OpenFeign、hystrix熔断降级、loadbalancer负载均衡
  • 【微服务】(4) 负载均衡
  • 【Qt】Qt实践记录3——UDP通信
  • 考研408--计算机网络--day3--
  • 从云原生部署到智能时序分析:基于 Kubernetes 的 Apache IoTDB 集群实战与 TimechoDB 国产化增强特性深度解析
  • LLaVA-NeXT 学习笔记
  • 投资融资理财网站模板网站搭建福州公司
  • OpenStack创建实例一直处于创建且未分配IP问题解决
  • C++的诗行:一文掌握内存管理中 new/delete 接口正确调用与常见场景适配
  • 谷歌网站 百度做网站对服务器什么要求高
  • Smartproxy 企业级解决方案
  • 图像分类深度学习
  • 自监督骨干(DINOv2)用于内镜分割与跟踪的全面实现分析
  • 6.基础--SQL--DDL表操作-创建查询
  • 《算法闯关指南:优选算法--位运算》--34.判断字符是否唯一,35.丢失的数字
  • 四川建设网网站首页网站开发 周期
  • linux怎么检查磁盘是否有坏道