WWDC Quick Look 💓 By SwiftGGTeam
Compose advanced graphics effects with SwiftUI

Compose advanced graphics effects with SwiftUI

观看原视频

Highlight

Apple 在 SwiftUI 中开放了 layerEffectalignmentGuide 的组合能力,开发者可以用 Metal Shader 实现像素级流体扭曲效果,用语义化对齐参考线实现浮动视图定位,全程无需离开 SwiftUI。

核心内容

复杂效果不是魔法,是管道的组合

做高级 UI 效果时,开发者常有一个错觉:Apple 自家 App 用了什么黑科技?这场 Session 用一个播客 App 的设计过程告诉我们,答案是把简单 API 像管道一样串起来。

演讲者从一个简陋的逐字稿视图出发,目标是做成 Apple Music 实时歌词页的效果:背景是流动的封面艺术,前景是随播放时间同步滚动的歌词,每行歌词旁还有一个浮动的时间戳。他没有引入 UIKit 或 Core Animation,而是把现有数据——封面图、播放时间、逐字稿文本——通过一系列 SwiftUI API 逐步转换。

这个”创意管道”的核心思想是:每个 SwiftUI modifier 都是一个处理阶段,数据从一端流入,被转换后流向下一阶段。单个阶段本身很简单,连接起来就能产生复杂效果。

Shader 让 GPU 替你画像素

04:18)封面图需要先模糊处理,避免和前景文字争抢注意力:

Image("CoverArt")
    .blur(radius: 30)

04:42)模糊之后,要在 GPU 上运行一个程序来决定每个像素的颜色,这个程序就是 Shader(着色器)。SwiftUI 提供了三种 Shader 效果:

  • colorEffect:逐像素变换颜色,比如把彩色图转成黑白
  • distortionEffect:把像素位置映射到新位置,做几何变形
  • layerEffect:提供整个视图的图层,可以采样相邻像素或任意区域

07:09)对于流体扭曲效果,layerEffect 最灵活。用法是在视图上添加 .layerEffect modifier,传入一个 Metal Shader 函数:

GeometryReader { proxy in
    CoverArtView()
        .layerEffect(
            ShaderLibrary.backgroundWarp(),
            maxSampleOffset: .zero
        )
}
.ignoresSafeArea()

对应的 Metal Shader 函数从最简单的”原样返回”开始:

[[stitchable]] half4 backgroundWarp(
    float2 position, SwiftUI::Layer layer
) {
    return layer.sample(position);
}

07:39layerEffect 允许从图层任意位置采样。传入一个 float2 偏移量,Shader 就能从偏移后的位置取色:

[[stitchable]] half4 backgroundWarp(
    float2 position, SwiftUI::Layer layer,
    float2 offset
) {
    return layer.sample(position + offset);
}

SwiftUI 侧传入偏移参数:

.layerEffect(
    ShaderLibrary.backgroundWarp(
       .float2(.init(x: proxy.size.width, y: 0))
    ),
    maxSampleOffset: .zero
)

但统一偏移只会产生固定的像素位移,缺少有机感。

08:37)演讲者引入了一张 NoiseTexture(噪点纹理),这是一张预计算的平滑随机值图片。在 SwiftUI 侧传入视图尺寸和噪点纹理:

.layerEffect(
    ShaderLibrary.backgroundWarp(
        .float2(proxy.size),
        .image(Image("NoiseTexture"))
    ),
    maxSampleOffset: .zero
)

08:55)Metal 侧用 UV 坐标采样噪点纹理,把红绿通道的值映射成像素偏移:

[[stitchable]] half4 backgroundWarp(
    float2 position, SwiftUI::Layer layer,
    float2 size, texture2d<half> noiseTex
) {
    constexpr sampler s(address::repeat, filter::linear);
    float2 uv = position / size;

    half4 n = noiseTex.sample(s, uv);
    float2 offset = (float2(n.r, n.g) - 0.5) * 200.0;

    return layer.sample(position + offset);
}

关键点:

  • position / size 把绝对像素坐标转成 0~1 的 UV 坐标
  • sampler 设置 address::repeat 让纹理可以平铺
  • 噪点纹理的红绿通道各提供一个随机值,组合成二维偏移
  • 偏移量缩放到 200 像素范围,产生明显的扭曲感

10:22)效果还可以更进一步。用 Domain Warping(域扭曲)技术做两次噪点采样:第一次采样得到初始偏移,第二次在偏移后的位置再采样一次:

half4 n = noiseTex.sample(s, uv);

float2 q = float2(n.r, n.g);
n = noiseTex.sample(s, uv + q);

float2 offset = (float2(n.r, n.g) - 0.5) * 200.0;

return layer.sample(position + offset);

两次采样的叠加产生了流动的有机 blob 效果,像液体在缓慢蠕动。

TimelineView 给 Shader 注入时间

11:37)Shader 是无状态的,它记不住上一帧的画面,输出只取决于当前输入参数。要让它动起来,必须传入一个随时间变化的值。

TimelineView(.animation) 每帧触发一次,提供当前时间戳。把这个时间戳传给 Shader,加到噪点采样位置上:

@State private var startDate = Date.now

TimelineView(.animation) { timeline in
    let elapsed = timeline.date.timeIntervalSince(startDate)
    CoverArtView()
        .layerEffect(
            ShaderLibrary.backgroundWarp(
                .float2(proxy.size),
                .image(Image("NoiseTexture")),
                .float(elapsed)
            ),
            maxSampleOffset: .zero
        )
}

Metal 侧把时间加到 UV 坐标上,噪点图案就开始流动了。这和 SwiftUI 的 transaction-based 动画完全不同——Shader 不依赖状态变化触发的动画系统,而是靠外部持续输入的时间参数驱动。

时间同步的逐字稿滚动

12:15)逐字稿视图从基础的 LazyVStack 开始:

ScrollView {
    LazyVStack(alignment: .leading, spacing: 12) {
        ForEach(sampleTranscript) { line in
            Text(line.text)
                .font(.title)
                .fontWeight(.bold)
        }
    }
}

12:33)接入播放状态后,当前行加粗显示,其余行淡出。用 ScrollViewReader 监听当前行变化,自动滚动到屏幕中央:

@State private var playback = PlaybackState()

ScrollViewReader { scrollProxy in
    ScrollView {
        LazyVStack(alignment: .leading, spacing: 12) {
            ForEach(sampleTranscript) { line in
                Text(line.text)
                    .transcriptLineStyle(isCurrent:
                        line.id == playback.currentLineIndex
                    )
            }
        }
    }
    .onChange(of: playback.currentLineIndex, { _, i in
        scrollProxy.scrollTo(i, anchor: .center)
    })
}

alignmentGuide 实现语义化浮动定位

13:53)每行歌词左下角要显示一个时间戳,但只有当前行才可见。用 overlay 把子视图叠加在文字上:

Text(line.text)
    .overlay {
        Text(line.formattedTimestamp)
    }

默认 overlay 使用中心对齐,时间戳会盖在文字中央。改成 .bottomLeading

Text(line.text)
    .overlay(alignment: .bottomLeading) {
        Text(line.formattedTimestamp)
    }

这样时间戳的底部左边缘会对齐到文字的底部左边缘,但目标是让时间戳的顶部贴合文字的底部

14:32alignmentGuide 可以覆盖默认对齐语义。告诉布局系统:当询问底部对齐位置时,返回视图的顶部位置:

Text(line.text)
    .overlay(alignment: .bottomLeading) {
        Text(line.formattedTimestamp)
            .alignmentGuide(.bottom) { $0[.top] }
    }

关键点:

  • $0[.top] 返回子视图的顶部边界
  • 布局系统以为这是底部对齐点,于是把子视图的顶部钉在父视图的底部
  • 不需要知道任何具体尺寸,完全语义化,自动适配 Dynamic Type 和多行文本

详细内容

Shader 的三种类型对比

类型输入输出适用场景
colorEffect像素位置 + 原颜色新颜色黑白滤镜、颜色替换
distortionEffect像素位置新采样位置几何变形、倾斜效果
layerEffect像素位置 + 整个图层新颜色模糊、流体扭曲、需要邻域采样的效果

layerEffect 最灵活,代价是需要把视图渲染到离屏纹理,性能开销比另外两种大。

Domain Warping 的完整 Shader

[[stitchable]] half4 backgroundWarp(
    float2 position, SwiftUI::Layer layer,
    float2 size, texture2d<half> noiseTex
) {
    constexpr sampler s(address::repeat, filter::linear);
    float2 uv = position / size;

    half4 n = noiseTex.sample(s, uv);

    float2 q = float2(n.r, n.g);
    n = noiseTex.sample(s, uv + q);

    float2 offset = (float2(n.r, n.g) - 0.5) * 200.0;

    return layer.sample(position + offset);
}

关键点:

  • constexpr sampler s(address::repeat, filter::linear) 创建纹理采样器,repeat 模式让噪点纹理可以无缝平铺
  • uv + q 是 Domain Warping 的核心:第一次采样结果 q 被加到 UV 坐标上,第二次采样在扭曲后的坐标空间进行
  • 红绿通道 (n.r, n.g) 各提供一个 -0.50.5 的随机值,乘以 200 后变成 -100100 像素的偏移范围
  • layer.sample(position + offset) 从偏移后的位置取色,产生像素位移

完整的时间驱动 Shader 动画

SwiftUI 侧:

@State private var startDate = Date.now

TimelineView(.animation) { timeline in
    let elapsed = timeline.date.timeIntervalSince(startDate)
    GeometryReader { proxy in
        CoverArtView()
            .layerEffect(
                ShaderLibrary.backgroundWarp(
                    .float2(proxy.size),
                    .image(Image("NoiseTexture")),
                    .float(elapsed)
                ),
                maxSampleOffset: .zero
            )
    }
    .ignoresSafeArea()
}

Metal 侧需要在参数列表中加入 float time,并在采样时把时间加到 UV 坐标上:

[[stitchable]] half4 backgroundWarp(
    float2 position, SwiftUI::Layer layer,
    float2 size, texture2d<half> noiseTex,
    float time
) {
    constexpr sampler s(address::repeat, filter::linear);
    float2 uv = position / size + time * 0.1;
    // ... 其余逻辑不变
}

关键点:

  • TimelineView(.animation) 每帧回调,提供最新时间戳
  • timeIntervalSince(startDate) 计算经过的秒数
  • Shader 里把时间加到 UV 坐标上,噪点图案随时间平移,产生流动感
  • maxSampleOffset 应该根据实际最大偏移量设置,不能写 .zero,否则 SwiftUI 会裁剪掉偏移到视图边界外的像素

alignmentGuide 的工作机制

SwiftUI 的对齐系统可以看作一根针同时穿过父视图和子视图的对齐点。默认情况下,overlay 使用中心对齐,针穿过两个视图的中心。

改成 .bottomLeading 后,针穿过两个视图的底部左边缘。但我们需要的是子视图的顶部贴合父视图的底部。

alignmentGuide(.bottom) { $0[.top] } 做了一个语义替换:当布局系统问”你的底部在哪里”时,子视图回答”我的顶部”。于是针穿过子视图的顶部,把它拉到了父视图底部的位置。

核心启发

1. 给现有 App 加动态背景

  • 做什么:把 App 里的静态封面图、头像或 banner 换成时间驱动的 Shader 流体效果
  • 为什么值得做TimelineView + layerEffect 只需要几十行代码,视觉冲击力远超静态图
  • 怎么开始:找一张 NoiseTexture 图片,写一个 layerEffect Shader,用 TimelineView(.animation) 注入时间参数

2. 做歌词/字幕同步阅读器

  • 做什么:做一个支持时间同步高亮和自动滚动的文本阅读器,适用于播客、有声书、外语学习
  • 为什么值得做ScrollViewReader + onChange 的组合让歌词同步滚动变得极其简单,不再需要手动计算 contentOffset
  • 怎么开始:给每行文本加上时间戳字段,用 PlaybackState 跟踪当前时间,在 onChange 里调用 scrollTo(_:anchor:)

3. 用 alignmentGuide 做浮动标签系统

  • 做什么:在列表、卡片或表单中实现不破坏布局流的浮动提示、徽章或时间戳
  • 为什么值得做alignmentGuideoffset 更语义化,自动适配不同字体大小和屏幕尺寸
  • 怎么开始:在 overlaybackground 里放子视图,用 alignmentGuide 覆盖默认对齐语义

4. 把传感器数据喂给 Shader

  • 做什么:用陀螺仪或加速度计数据替代时间参数,让 Shader 效果响应设备运动
  • 为什么值得做:演讲者提到”输入可以是陀螺仪数据而不是音频”,这套管道模型天然支持任意数据源
  • 怎么开始:用 CoreMotion 获取设备姿态,把 roll/pitch 值传给 Shader 的 float2 参数,控制扭曲方向

5. 复用 Domain Warping 做其他视觉效果

  • 做什么:用同样的两次噪点采样技术做水波纹、热浪扭曲、液体表面效果
  • 为什么值得做:Domain Warping 是通用技术,换一张不同的噪点纹理、调整缩放系数,就能得到完全不同的视觉风格
  • 怎么开始:下载 Apple 提供的示例项目,修改 offset 的乘数和 uv 的时间系数,观察效果变化

关联 Session

  • SwiftUI 新特性 — SwiftUI 的基础布局和新 modifier 总览
  • Lazy stacks — 高效处理大量数据列表的懒加载技术
  • RealityKit — 如果要把 Shader 效果扩展到 3D 空间,RealityKit 提供了对应的材质系统
  • Swift — Swift 语言新特性,了解 constexpr 等底层概念有助于理解 Shader 代码
  • Xcode 27 — 新版 Xcode 对 Shader 开发和预览的支持改进

评论

GitHub Issues · utterances