滚动视觉最危险的实现,是用 GeometryReader 读取每一行 frame,把 offset 写进 @State,再根据状态改变 frame。滚动引发几何变化,几何变化触发状态更新,状态更新重做布局,布局又改变几何;一个装饰效果变成了反馈环。SwiftUI 已经提供更窄的入口:scrollTransition 给出相对可见区域的 phase,visualEffect 在不改变布局的前提下使用几何。

本文面向正在做卡片视差、边缘淡出、缩放列表和滚动标题的开发者。目标不是展示最多 modifier,而是把滚动内容身份、布局与视觉采样分开,并建立可以用 Instruments 证伪的性能假设。

版本边界

scrollTransitionScrollTransitionPhase 与本文使用的二维 visualEffect 从 iOS 17、macOS 14 等对齐版本起稳定可用。Apple 的 Scroll views 文档入口汇总容器与滚动行为;Beyond scroll views介绍 scroll target、position 与 transition;WWDC24 自定义视觉效果展示 phase 驱动的旋转、视差以及 geometry-based visual effect。

稳定核验基线为 Xcode 26.6。WWDC26 的 Dive into lazy stacks and scrolling 属于 Xcode 27 / iOS 27 Beta 周期;本文只把其中关于动态子视图数量、prefetch 和 geometry feedback 的建议列为预览补充,不声称 Beta 行为已成为稳定契约,也不使用 Beta 专属 API。

ScrollTransition 是可见性相位,不是 content offset

ScrollTransitionPhasetopLeadingidentitybottomTrailing。Apple 文档强调,视图在可见区域内处于 identity,此时效果通常不应改变外观;接近两侧边缘时才应用差异。phase.value 提供连续值,可用于比例映射,但它不是需要持久化的业务状态。

struct StoryCard: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    let story: Story

    var body: some View {
        if reduceMotion {
            card
        } else {
            card
                .scrollTransition(.interactive, axis: .vertical) { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1 : 0.72)
                        .scaleEffect(phase.isIdentity ? 1 : 0.94)
                        .offset(y: CGFloat(phase.value) * 18)
                }
        }
    }

    private var card: some View {
        StoryCardContent(story: story)
            .containerRelativeFrame(.vertical, count: 1, spacing: 16)
    }
}

.interactive 让效果跟随滚动位置;其他 configuration 可以让 phase 变化动画化。卡片的布局尺寸没有随 scale 或 offset 改变,滚动容器仍用原始几何排版。Reduce Motion 分支保留完整卡片并移除空间运动,而不是让 transition 以零时长反复计算。

Transition configuration 要跟交互语义匹配。用户手指直接控制的边缘效果通常用 interactive,让视觉和位置保持同步;希望元素越过阈值后自然收束时才考虑 animated 配置。不要给同一张卡同时叠加多个互不协调的 spring,也不要让 topLeading 和 bottomTrailing 使用相反的信息层级。进入与离开可以有方向差异,但 identity 必须是稳定、清晰、可操作的最终状态。

phase.value 适合映射连续视觉参数,不适合当成固定物理距离。容器尺寸、轴向和系统实现都可能改变它与像素的关系,应先 clamp 再映射到产品允许范围。颜色、模糊和缩放也要有上限;极端 overscroll 下不应出现负尺寸、完全透明的可操作元素或不可读对比度。

滚动几何保持在视觉路径与写回状态反馈环的对比

图 1:理想路径只生成视觉输出;写回 State 会重新进入布局并形成反馈;本站原创 1400×800 程序化 SVG。

VisualEffect:需要实际 frame 时仍不写回状态

Phase 适合进入和离开可见区;若颜色或缩放需要由精确位置计算,visualEffect 提供 GeometryProxy,返回值仍是 VisualEffect,不影响祖先与后代布局。

StoryCardContent(story: story)
    .visualEffect { content, proxy in
        let minY = proxy.frame(in: .scrollView).minY
        let progress = min(max(minY / 500, -1), 1)

        return content
            .hueRotation(.degrees(Double(progress) * 12))
            .offset(x: progress * -10)
    }

WWDC24 Session 将它用于基于视图位置改变 hue、scale、opacity 和 blur。计算应是纯函数:frame 进入,effect 出来。不要在 closure 中启动任务、修改 observable 模型或把每帧位置写到日志。

坐标空间必须明确。.scrollView 表达相对最近滚动容器;.global 会让窗口位置、安全区和多窗口布局进入公式。除非效果确实相对屏幕,否则优先使用 scroll view 或命名空间,并对旋转、分屏和 safe area 测试。

视觉位置不能破坏交互合同

offsetscaleEffect 改变用户看到的位置,却不应该让操作逻辑变得不可预测。幅度很大的卡片位移会让相邻命中区域看似重叠;缩得过小仍可聚焦,却难以看清焦点边框。滚动期间若按钮需要可操作,完整命中、视觉边界和 accessibility frame 必须一起在设备上验证。纯装饰层应关闭 hit testing,避免透明效果截获拖动手势。

VoiceOver 顺序来自语义树,不会因为卡片在屏幕上旋转或偏移而自动重排。效果不能把视觉上的第一项移动到语义序列末尾。键盘和 Switch Control 用户也需要可见 focus,卡片获得焦点时可以收敛到 identity 外观,避免焦点环跟被扭曲的内容分离。

Reduce Motion 不等于只把 .interactive 改成 .animated。视差、景深缩放和连续旋转应移除;必要的进入状态可改用轻量 opacity 或静态强调。触摸用户没有 hover,所有因滚动才浮现的标题和操作必须在点击、聚焦或稳定位置得到等价呈现。

内容身份先于视觉效果

流畅滚动依赖稳定 identity。ForEach(items, id: \.self) 在元素 Hashable 内容变化时可能改变身份;运行时生成 UUID 则让每次更新都像全新列表。使用模型的持久 ID,让 SwiftUI 能复用行状态和视图存储。排序与过滤也应在进入 body 热路径前准备,而不是每一行重复执行。

不要让同一数据元素根据状态返回不定数量的顶层子视图。例如一个 cell 有时返回一行、有时返回十个 sibling,lazy stack 的尺寸估算与预取会更困难。把变化包在具有稳定身份的容器内,并让展开状态产生可预测布局。WWDC26 Beta Session 对动态子视图数量的警告值得关注,但最终行为仍需在稳定 SDK 复核。

Lazy 不是自动更快

普通 ScrollView 会急切求值内容;大集合通常搭配 LazyVStackLazyHStack,只在需要时创建附近内容。Beyond scroll views 明确区分了两者。但 lazy 容器需要估算未加载内容尺寸:行高度剧烈变化、滚动期间由 onAppear 改布局、图片没有预留尺寸,都会让估算与真实值不断修正。

小而固定的集合可能用普通 Stack 更简单。Lazy 的收益取决于项目数量、每项构建成本、目标设备和访问模式。不能从名称推断性能;必须用同一数据集记录创建、滚动和跳转。

数据分页也要与 View 生命周期解耦。把网络请求直接绑在每行 onAppear,卡片因重排或回收再次出现时可能重复加载。使用接近尾部的稳定 sentinel、请求幂等和明确加载状态,让“需要下一页”成为数据事件,而不是视觉 effect 的副作用。占位行应预留接近真实内容的几何,失败时提供可操作重试,并保持列表 ID 不变。

程序化跳转尤其需要估算稳定。scrollPosition 指向持久 ID 时,过滤后目标可能不存在;先更新数据再验证目标,不能让无效 ID 在状态与容器间反复同步。深链进入很远位置要分别测试冷数据、已缓存数据和 Dynamic Type,因为行高差异会影响 lazy 容器准备范围。

图片、模糊与离屏合成

滚动时最昂贵的往往不是 phase 算术,而是每行图片解码、巨大 blur、shadow、mask 和 layer effect。为远端图片提供确定比例和占位,避免下载完成改变行高。限制图片解码尺寸,不要把原始大图交给每个小卡片。

Blur 与阴影会扩大采样区域,多个透明层可能触发额外合成。滚动 transition 中让 blur 从很大半径连续变化尤其值得测量。先用 opacity、transform 等简单属性实现层级,再逐项加入效果并记录差异。所谓“GPU 属性”也不保证免费,像素面积和层数仍决定成本。

不要让滚动事件驱动整个页面

确实需要业务级滚动信息时,例如“阅读到 80%”或“当前章节”,应降采样成少量有语义的事件。使用 scroll geometry/visibility API 时先映射为离散值,再在值真正变化时写状态;不要保存每一个 offset。章节从 2 变成 3 值得更新导航,minY120.1 变成 119.8 通常不值得。

Apple 的 SwiftUI 性能指南 特别列举了 GeometryReader 或 geometry change 驱动子视图重复布局的因果链。遇到问题应在 Cause & Effect Graph 中确认“哪个几何事件导致哪些 view body 更新”,再缩小依赖或增加阈值。

性能验证流程

先定义可重复动作:固定数据冷启动,匀速滚过同一段,快速反向一次,再跳到指定 ID。使用 Release 构建和目标真机记录 SwiftUI update、Time Profiler 与 hitch;修改一个变量后重复。建议依次对照:无效果、只有 opacity/transform、加入 blur、加入图片、普通 Stack 与 Lazy Stack。

检查四类证据:view body 是否过长;更新是否由 offset 状态高频触发;行身份是否反复创建;主线程是否在解码或格式化。帧率下降只是症状,不能直接证明是 scrollTransition

测试场景还应覆盖慢速精读、快速甩动、方向反转、程序化跳转、数据插入和返回恢复。只测匀速向下会漏掉预取取消、图片任务竞争和身份错误。记录目标设备、系统、Release 构建、数据数量和媒体缓存状态;没有这些条件,就不要把一次帧率读数发布为跨设备结论。

离屏后检查是否仍有 Timeline、Timer 或异步图片任务持续工作。页面消失时取消页面拥有的任务,恢复时根据数据状态重建;不要全局终止其他页面共享的缓存。对比 trace 时一次只移除一类效果,才能判断问题来自 blur、图像解码、布局还是状态写回。

常见失败包括:identity phase 仍改变 scale;使用全局坐标导致分屏错位;每帧写 State;行 ID 不稳定;onAppear 改变行高度;图片无尺寸占位;Reduce Motion 仍保留视差和大幅缩放;用 Simulator 主观感觉代替真机 trace。

滚动视觉的高水准不来自运动幅度,而来自内容在用户手势下仍然稳定。把 geometry 保留在 VisualEffect 路径,把业务更新降采样成语义事件,再用相同场景验证,效果才不会以布局抖动和输入延迟为代价。

Sources