Highlight
Apple 在 SwiftUI 中开放了
layerEffect和alignmentGuide的组合能力,开发者可以用 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:39)layerEffect 允许从图层任意位置采样。传入一个 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:32)alignmentGuide 可以覆盖默认对齐语义。告诉布局系统:当询问底部对齐位置时,返回视图的顶部位置:
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 图片,写一个
layerEffectShader,用TimelineView(.animation)注入时间参数
2. 做歌词/字幕同步阅读器
- 做什么:做一个支持时间同步高亮和自动滚动的文本阅读器,适用于播客、有声书、外语学习
- 为什么值得做:
ScrollViewReader+onChange的组合让歌词同步滚动变得极其简单,不再需要手动计算 contentOffset - 怎么开始:给每行文本加上时间戳字段,用
PlaybackState跟踪当前时间,在onChange里调用scrollTo(_:anchor:)
3. 用 alignmentGuide 做浮动标签系统
- 做什么:在列表、卡片或表单中实现不破坏布局流的浮动提示、徽章或时间戳
- 为什么值得做:
alignmentGuide比offset更语义化,自动适配不同字体大小和屏幕尺寸 - 怎么开始:在
overlay或background里放子视图,用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