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进入可见区域 → 触发回调 → 更新状态 → 子组件动画

详细步骤

  1. 初始状态
@State private var isVisible = false // 初始为 false
  • Section 渲染时,isVisible = false
  • 所有进度条的 animatedProgress = 0(宽度为0,不可见)
  1. 监听滚动
.onScrollVisibilityChange(threshold: 0.5) { visible in
    if visible && !isVisible {
        isVisible = true
    }
}
  • 系统自动监听 Section 在 ScrollView 中的可见比例
  • threshold: 0.5 = 当 Section 至少 50% 进入屏幕时
  • visible = true 表示达到阈值
  1. 状态传递
MuscleBreakdownRow(breakdown: breakdown, shouldAnimate: isVisible)
  • isVisible 变为 true 后
  • 通过参数传递给所有子组件 MuscleBreakdownRow
  • 每个 Row 都收到 shouldAnimate = true
  1. 触发动画
.onChange(of: shouldAnimate) { _, newValue in
    if newValue {  // true
        withAnimation(.easeOut(duration: 0.8)) {
            animatedProgress = CGFloat(breakdown.intensity) / 10
        }
    }
}
  • 子组件监听 shouldAnimate 参数变化
  • 从 false → true 时触发
  • 执行 0.8 秒缓出动画
  • 进度条从 0 增长到目标值
  1. 防止重复触发
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
                }
            }
        }
    }
}