ScrollView|使用 onScrollVisibilityChange 创建懒加载动画
了解如何使用 iOS 18 上新的 onScrollVisibilityChange 修饰器,轻松创建懒加载动画。
在 iOS 17 与 iOS 18 中,苹果新增了多个与 ScrollView 相关的修饰器,旨在让开发者无需使用 GeometryReader + PreferenceKey 实现复杂的滚动监听逻辑。
| 修饰器 / API | 可用系统(iOS) | 作用简介 | 典型用途 |
|---|---|---|---|
.onScrollGeometryChange(for:of:action:) |
iOS 18+ | 监听滚动几何信息变化(偏移量、内容尺寸、可见区域等) | 视差效果、滚动动画、顶部折叠头图 |
.onScrollVisibilityChange |
iOS 18+ | 监听某个子视图是否出现在可视区域内 | 懒加载、滚动触发动画、曝光统计 |
.onScrollTargetVisibilityChange |
iOS 18+ | 监听带有 scroll ID 的目标视图是否可见 | 列表项曝光追踪、自动滚动控制 |
.scrollPosition(_:anchor:) |
iOS 17+ | 绑定或控制滚动位置,可与 ID 绑定实现精确滚动 | 定位到特定内容、返回顶部按钮 |
.scrollTargetBehavior(_:) |
iOS 17+ | 定义滚动的目标停留行为 | 分页滚动、居中对齐、自定义滚动惯性 |
.scrollTransition(_:axis:transition:) |
iOS 17+ | 定义元素在滚动中出现/消失时的动画过渡 | 滚动淡入淡出、缩放动画 |
.scrollClipDisabled(_:) |
iOS 17+ | 禁止 ScrollView 对内容进行裁剪 | 允许内容超出滚动边界绘制 |
组合示例
- 滚动视差头图 →
onScrollGeometryChange+scrollClipDisabled(true) - 懒加载区块动画 →
onScrollVisibilityChange+withAnimation - 自动分页视图 →
scrollTargetBehavior(.paging)+scrollPosition - 曝光统计与交互追踪 →
onScrollTargetVisibilityChange
onScrollVisibilityChange 创建懒加载动画
用户滚动页面 → Section进入可见区域 → 触发回调 → 更新状态 → 子组件动画
详细步骤
- 初始状态
@State private var isVisible = false // 初始为 false- Section 渲染时,isVisible = false
- 所有进度条的 animatedProgress = 0(宽度为0,不可见)
- 监听滚动
.onScrollVisibilityChange(threshold: 0.5) { visible in
if visible && !isVisible {
isVisible = true
}
}- 系统自动监听 Section 在 ScrollView 中的可见比例
- threshold: 0.5 = 当 Section 至少 50% 进入屏幕时
- visible = true 表示达到阈值
- 状态传递
MuscleBreakdownRow(breakdown: breakdown, shouldAnimate: isVisible)- isVisible 变为 true 后
- 通过参数传递给所有子组件 MuscleBreakdownRow
- 每个 Row 都收到 shouldAnimate = true
- 触发动画
.onChange(of: shouldAnimate) { _, newValue in
if newValue { // true
withAnimation(.easeOut(duration: 0.8)) {
animatedProgress = CGFloat(breakdown.intensity) / 10
}
}
}- 子组件监听 shouldAnimate 参数变化
- 从 false → true 时触发
- 执行 0.8 秒缓出动画
- 进度条从 0 增长到目标值
- 防止重复触发
if visible && !isVisible { // 只在第一次可见时执行
isVisible = true
}- 用 !isVisible 确保只触发一次
- 即使用户来回滚动,动画只播放一次
完整示例代码
import SwiftUI
// MARK: - 数据模型
struct ProgressItem: Identifiable {
let id = UUID()
let title: String
let value: Int // 0-10
}
// MARK: - 主视图
struct LazyAnimationDemo: View {
let items = [
ProgressItem(title: "胸部", value: 9),
ProgressItem(title: "肩部", value: 7),
ProgressItem(title: "肱三头肌", value: 6),
ProgressItem(title: "腹肌", value: 5)
]
var body: some View {
ScrollView {
VStack(spacing: 40) {
// 占位内容,用于演示滚动效果
VStack {
Text("向下滚动查看懒加载动画")
.font(.title2)
.padding()
}
.frame(height: 600)
.background(Color.gray.opacity(0.1))
// 带懒加载动画的进度条区域
ProgressSection(items: items)
}
.padding()
}
}
}
// MARK: - 进度条区域(监听可见性)
private struct ProgressSection: View {
let items: [ProgressItem]
@State private var isVisible = false // 控制动画触发
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("肌群参与度分析")
.font(.headline)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 12) {
ForEach(items) { item in
ProgressRow(item: item, shouldAnimate: isVisible)
}
}
}
.onScrollVisibilityChange(threshold: 0.5) { visible in
// 当区域至少50%可见时触发
if visible && !isVisible {
isVisible = true // 只触发一次
}
}
}
}
// MARK: - 单个进度条(执行动画)
private struct ProgressRow: View {
let item: ProgressItem
let shouldAnimate: Bool
@State private var animatedProgress: CGFloat = 0 // 动画进度值
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// 标题和数值
HStack {
Text(item.title)
.font(.subheadline)
Spacer()
Text("\(item.value)/10")
.font(.subheadline)
.fontWeight(.semibold)
}
// 进度条
GeometryReader { geometry in
ZStack(alignment: .leading) {
// 背景
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 6)
// 前景(动画部分)
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [.orange, .red],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geometry.size.width *
animatedProgress, height: 6)
}
}
.frame(height: 6)
}
.onChange(of: shouldAnimate) { _, newValue in
if newValue {
// 当 shouldAnimate 变为 true 时执行动画
withAnimation(.easeOut(duration: 0.8)) {
animatedProgress = CGFloat(item.value) / 10
}
}
}
}
}
Comments ()