在 SwiftUI 里做模糊材质并不难,难的是让遮罩真正听内容的话。当遮罩来自固定形状时,内容亮度、位置或占比一旦变化,边缘要么太硬,要么透明区域完全错位。Core Image 的 CILuminanceToAlpha 滤镜可以按像素亮度生成 alpha 通道,正好和 SwiftUI 的 mask 机制形成互补。下面的实现会把 UIVisualEffectView 作为材质层,把经过亮度转换后的图像作为遮罩,并用动画状态驱动亮度源变化。

一、CILuminanceToAlpha 与 VisualEffect 的配合逻辑
CILuminanceToAlpha 属于 Core Image 的颜色处理滤镜。它不会直接把彩色图像变成黑白图,而是读取每个像素的亮度值,再用该值重写像素的 alpha 通道。亮度越高的区域 alpha 越接近 1,亮度越低的区域 alpha 越接近 0。颜色通道本身对最终的遮罩效果影响很小,因此在实际使用中通常会传入灰度渐变、文字形状或亮度差异明显的源图,避免彩色信息干扰判断。
SwiftUI 中有多种方式呈现模糊材质,比如 .background(.ultraThinMaterial),但这种方式对遮罩的控制粒度较粗。采用 UIViewRepresentable 包装 UIVisualEffectView 后,可以继续使用 SwiftUI 的 mask 修饰符,把任意 SwiftUI 视图作为遮罩层。尤其是当我们把 LuminanceAlphaMaskView 这样一个实时生成 alpha 图的视图放进 mask 时,材质显隐完全由源图亮度决定。
动态调整的关键在于源图需要随内容状态变化。内容状态可以是动画进度、滚动偏移、音频频谱或用户手势。只要这些状态能够映射成亮度分布的几何变化,比如中心点移动、半径缩放或亮度范围收窄,遮罩就会在每一帧重新计算 alpha,呈现出随内容自然变化的效果。
二、封装 VisualEffectView 与亮度遮罩视图
第一层封装是 VisualEffectView。它内部生成 UIVisualEffectView,并暴露一个 UIVisualEffect 属性,方便在 SwiftUI 中切换模糊样式。第二层封装是 LuminanceAlphaMaskView,它接受一个 UIImage,在 updateUIView 中用 Core Image 执行亮度到透明度的转换,并把结果交给 UIImageView。
这里有一个容易忽略的性能细节:CIContext 的创建成本不低。如果每帧都在 updateUIView 里重新 CIContext(options: nil),当动画连续触发时会产生不必要开销。解决方案是在 Coordinator 中持有一个共享的 CIContext,代表层只创建一次,后续帧复用。
import SwiftUI
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
struct VisualEffectView: UIViewRepresentable {
let effect: UIVisualEffect
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: effect)
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = effect
}
}
struct LuminanceAlphaMaskView: UIViewRepresentable {
let image: UIImage
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let view = UIImageView()
view.contentMode = .scaleAspectFill
view.clipsToBounds = true
return view
}
func updateUIView(_ uiView: UIImageView, context: Context) {
guard let ciImage = CIImage(image: image) else {
uiView.image = nil
return
}
let filter = CIFilter.luminanceToAlpha()
filter.inputImage = ciImage
guard let outputImage = filter.outputImage else {
uiView.image = nil
return
}
let ciContext = context.coordinator.ciContext
if let cgImage = ciContext.createCGImage(outputImage, from: outputImage.extent) {
uiView.image = UIImage(cgImage: cgImage)
}
}
final class Coordinator {
let ciContext = CIContext(options: nil)
}
}
上述代码中 makeCoordinator 返回一个内部类,类里保存 ciContext。updateUIView 每次更新都会读取输入图片,创建 CIFilter.luminanceToAlpha(),设置输入图后取出输出图。由于滤镜对象的生命周期很短,适合高频更新,而上下文对象生命周期很长,避开重复创建。
三、用动画状态驱动亮度源变化
仅有滤镜还不够,关键是让亮度源动起来。这里使用一个 progress 变量控制径向渐变的中心位置。通过 UIGraphicsImageRenderer 绘制一张灰度径向渐变图,暗部在边缘,亮部在中心。每次 progress 改变时,绘制出的灰度图也会变化,接着被 LuminanceAlphaMaskView 转换为 alpha 遮罩,最终改变模糊材质的可见区域。
父视图用一个线性渐变背景打底,再叠加一个半透明图标,最上层放一个圆角模糊面板。模糊面板被亮度遮罩裁切后,只会在渐变中心附近显示材质,边缘区域则透出背景。这样动画推进时,模糊区域会跟随亮度中心移动。要实现自动播放,可以在 onAppear 中使用 withAnimation 和 repeatForever。
struct DynamicLuminanceMaskView: View {
var progress: CGFloat
var body: some View {
let source = Self.makeLuminanceSource(progress: progress)
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
.mask(
LuminanceAlphaMaskView(image: source)
)
.animation(.easeInOut(duration: 0.5), value: progress)
}
static func makeLuminanceSource(progress: CGFloat) -> UIImage {
let size = CGSize(width: 420, height: 420)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let colors = [
UIColor(white: 0.12, alpha: 1).cgColor,
UIColor(white: 0.96, alpha: 1).cgColor
] as CFArray
let colorSpace = CGColorSpaceCreateDeviceGray()
let gradient = CGGradient(colorsSpace: colorSpace,
colors: colors,
locations: [0, 1])!
let center = CGPoint(x: size.width * progress,
y: size.height * 0.5)
ctx.cgContext.drawRadialGradient(
gradient,
startCenter: center,
startRadius: 0,
endCenter: center,
endRadius: size.width * 0.45,
options: [.drawsAfterEndLocation]
)
}
}
}
struct ContentView: View {
@State private var progress: CGFloat = 0.2
var body: some View {
ZStack {
LinearGradient(colors: [.blue, .purple, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.ignoresSafeArea()
Image(systemName: "sparkles")
.resizable()
.scaledToFit()
.frame(width: 240, height: 240)
.foregroundStyle(.white)
.opacity(0.9)
.offset(x: progress * 120 - 60)
DynamicLuminanceMaskView(progress: progress)
.frame(width: 340, height: 340)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.shadow(radius: 24)
}
.onAppear {
withAnimation(.easeInOut(duration: 2.8).repeatForever(autoreverses: true)) {
progress = 0.85
}
}
}
}
如果遮罩需要跟随滚动而不是时间动画,可以把滚动偏移量传入 progress 或直接换算成亮度中心。SwiftUI 在 iOS 17 提供的 visualEffect 修饰符很适合读取几何信息,例如拿到 GeometryProxy 后,将 frame(in: .global).minY 映射到 0 到 1 之间,再驱动同一个亮度遮罩。这样内容在滚动时,遮罩会根据滚动位置动态调整,而不是固定在屏幕中心。
需要注意的是,visualEffect 修饰符本身不产生模糊材质,它只提供几何上下文。真正的材质仍然来自 UIVisualEffectView 或 SwiftUI 的 Material 背景。两者可以组合:视觉几何数据负责驱动亮度源,UIKit 材质视图负责渲染被遮罩后的模糊效果。
四、性能优化与常见问题
动态遮罩在动画过程中每帧都会触发图像生成和滤镜处理,因此控制分辨率非常重要。示例中把源图固定在 420x420,足够表现平滑渐变,又不会给 CPU 和 GPU 带来太大压力。如果直接使用全屏截屏作为源图,每帧都走 CIImage 和 CIContext 会明显掉帧。可以先对输入图进行降采样,或限制遮罩的更新频率。
另一个常见问题是边缘过硬。CILuminanceToAlpha 对亮度变化非常直接,如果源图本身是硬边形状,遮罩边缘也会非常锐利。需要柔边时,可以在亮度源上先做轻微的高斯模糊,或者对输出后的遮罩图再执行一次模糊。也可以在绘制渐变时使用更平滑的过渡,而不是从纯黑瞬间跳到纯白。
还需要注意 SwiftUI 的 mask 只读取视图的 alpha 通道,不关心颜色。所以 LuminanceAlphaMaskView 输出图像的颜色部分即使不理想,也不会影响遮罩结果。但如果把同一个视图放在普通 overlay 中,就可能看到颜色异常。调试时可以先不用 mask,直接把亮度图显示出来,确认 alpha 分布是否符合预期,再套回材质层。
最后,如果动画需要非常高的帧率,建议把滤镜处理放到后台队列,生成 CGImage 后再回主线程更新 UIImageView。对于大多数 UI 场景,420x420 的输入配合缓存 CIContext,在主线程同步处理已经足够流畅。
SwiftUIVisualEffectLuminanceToAlpha修改时间:2026-09-28 13:44:08