在SwiftUI里让边缘暗角随内容动态变化,核心思路是把视觉特效从静态图层变成可读布局数据的实时函数。过去我们习惯放一个径向渐变的叠加层模拟晕影,但它无法感知内容滚动到了哪里、哪一格该被高亮。VisualEffect协议允许我们在视图渲染前拿到几何信息,再决定像素如何着色,这就让Vignette动画有了数据驱动的基础。

VisualEffect协议与几何读取原理
VisualEffect是SwiftUI 5之后提供的底层视觉定制入口,它要求实现makeShader或结合VisualEffectView拿到GeometryProxy类似的上下文。和普通overlay不同,特效层能直接观察宿主视图的帧坐标、尺寸以及外部传入的参数。理解这一点很关键:晕影不再是贴上去的图片,而是一段根据内容位置实时运行的着色逻辑。
当我们把滚动容器的偏移量作为uniform传给特效,暗角中心就可以从屏幕正中移到当前焦点项附近。例如列表向上滚动时,中心y坐标随contentOffset变化,边缘衰减半径同步缩小,使视口内靠上的内容更亮。这种动态调整如果用静态渐变实现,只能靠频繁替换图片或重画渐变停止点,开销明显更大。
下面代码展示如何定义一个接收偏移量的特效结构体,并在 body 中将其应用到可滚动内容。注意这里用VisualEffect的子类化写法,把参数通过绑定传入,保证每次滚动都触发着色更新。
import SwiftUI
struct DynamicVignetteEffect: VisualEffect {
var focusY: CGFloat
var edgeStrength: Float
func makeShader() -> Shader {
// 伪代码:将focusY与edgeStrength送入金属着色器
return Shader(
source: "vignette_shader",
uniforms: [focusY, edgeStrength]
)
}
}
struct ContentView: View {
@State private var scrollY: CGFloat = 0
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<50) { i in
Text("Item (i)")
.frame(height: 80)
}
}
.background(
GeometryReader { proxy in
Color.clear.preference(
key: ScrollOffsetKey.self,
value: proxy.frame(in: .named("scroll")).minY
)
}
)
}
.coordinateSpace(name: "scroll")
.visualEffect { content, proxy in
content.visualEffect(
DynamicVignetteEffect(
focusY: scrollY,
edgeStrength: 0.6
)
)
}
.onPreferenceChange(ScrollOffsetKey.self) { val in
scrollY = val
}
}
}
Vignette晕影的数学模型与焦点突出
经典Vignette用径向衰减函数让中心亮、四角暗,公式常写为亮度乘上1 - distance^n。在SwiftUI动态场景里,我们把distance的原点从固定中心改成由内容决定的焦点。比如当前选中卡片的中心坐标,经过坐标空间转换后成为着色器的焦点输入,这样暗角就自然包围了重点区域。
焦点突出不能只靠压暗边缘,还要在焦点附近做轻微提亮或饱和度增强。我们可以在特效里混合两层计算:一层标准晕影,一层以焦点为圆心的反向高斯亮斑。两者按内容滚动速度插值,快速滑动时亮斑扩大以防找不到位置,静止时收缩以突出单项。这种组合比单纯改透明度自然得多,也不会让非焦点内容彻底不可读。
以下片段演示用Swift计算每帧晕影权重,并标记出焦点索引对应的高亮强度。真实项目可把这段逻辑搬进着色器,但先理解参数关系很重要。
func vignetteWeight(
point: CGPoint,
focus: CGPoint,
radius: CGFloat,
power: CGFloat
) -> Float {
let dx = point.x - focus.x
let dy = point.y - focus.y
let dist = sqrt(dx*dx + dy*dy)
let norm = min(dist / radius, 1.0)
let falloff = pow(norm, power)
return Float(1.0 - falloff)
}
let focus = CGPoint(x: 200, y: scrollY + 300)
let w = vignetteWeight(
point: CGPoint(x: 200, y: 500),
focus: focus,
radius: 400,
power: 2.2
)
print("weight: (w)")
在列表与卡片中落地动态暗角
把前述特效接到真实列表,要考虑坐标空间一致性。列表项坐标在滚动后相对窗口变化,必须统一转换到特效层的本地空间,否则焦点会错位。通常我们在ScrollView上命名坐标空间,在项里读自身frame(in:)算出中心,再传给特效状态,这样晕影永远罩在正确内容外缘。
另一个常见需求是点击某卡片后,暗角缓慢聚拢到该卡。这时用withAnimation把focusY和edgeStrength做弹簧动画即可,因为特效参数本身是可动画值。相比重新布局叠加层,这种方式不触发视图树重构,只改uniform,帧率更稳。下例展示点击绑定焦点并开启动画。
struct CardList: View {
@State var focusedID: Int = 0
@State var scrollY: CGFloat = 0
var body: some View {
ScrollView {
VStack {
ForEach(0..<20) { id in
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(height: 120)
.onTapGesture {
withAnimation(.spring()) {
focusedID = id
}
}
}
}
.background(
GeometryReader { p in
Color.clear.preference(
key: YKey.self,
value: p.frame(in: .named("v")).minY
)
}
)
}
.coordinateSpace(name: "v")
.visualEffect { c, proxy in
c.visualEffect(
DynamicVignetteEffect(
focusY: scrollY + CGFloat(focusedID) * 120,
edgeStrength: 0.75
)
)
}
.onPreferenceChange(YKey.self) { v in
scrollY = v
}
}
}
经过这样的封装,晕影不再是死板遮罩,而是随内容、交互、滚动共同呼吸的视觉引导。开发者只需维护几个参数,就能在各类容器中复用同一套暗角逻辑,把用户注意力稳稳放在该看的地方。
SwiftUIVisualEffectView_Vignette修改时间:2026-08-17 21:40:39