SwiftUI如何实现渐进式模糊效果?

Jul 1, 2025 at 17:33:23

最近在玩iOS 26 Liquid Glass,其中新的Tabbar改动是很多的。

这个Apple Music底部的Tabbar就跟之前的版本完全不同。如果我们使用SwiftUI和新的API,要想获得这样的表现是很容易的。

var body: some View {
        TabView {
            Tab("Home", systemImage: "home") {
                HomeView()
            }
            .badge(17)

            Tab("New", systemImage: "square.grid.2x2.fill") {
                NewView()
            }
            .hidden(sizeClass != .compact)
        }
        .tabBarMinimizeBehavior(.onScrollDown)
    }

留意到当ScrollView的内容长度超过Tabbar之后,Tabbar会自动加上一层背景的渐变。看起来很Cool。

我在尝试一个东西,就是实现一个类似iMessage底部聊天窗口的界面,这时候遇到了问题。

目前这种置于底部Toolbar之下的渐变模糊,只有使用.toolbar接口才能自动实现。SwiftUI没有提供额外的接口给我实现这样的渐变。

也就是说,如果我想实现iMessage这样的聊天界面,那么我最好使用.toolbar()

.toolbar {
            ToolbarItem(placement: .bottomBar) {
                Button(action: {
                    // 更多功能
                }) {
                    Image(systemName: "plus")
                        .foregroundColor(.primary)
                }
            }
        }

类似上述代码。这样的好处是我们自动地获得了底部的模糊渐变。但是当我们点了加号按钮,希望在Tabbar的下方弹起一个更多功能的面板时,不好意思,如果他是个Tabbar,那么目前没有办法让这个消息输入框能够放在这块面板的上方。

那么iMessage如何实现的呢?

以Stickers为例,首先它present了一个面板,出现在Tabbar的下方。然后,当你拉动这个面板往上滑,神奇的一幕出现了,它不仅挡住了Tabbar,甚至挡住了顶部的navigationbar!

我怀疑它可能是:

  1. 在整个App的层级上它设计了一个能浮动的View,跟App同级别,或者跟Tabbar所处的Root View同级别,只有这样才能缩放Tabbar所处的View。
  2. 这是一个特殊的能力,不对外开放的。

我目前倾向于是 #2,即使是 #1,我要实现和维护这个复杂度也不太合理,另外也不符合我想实现的视觉层级。

于是乎我放弃Tabbar实现。转而使用一个普通的View。那么我就需要解决底部渐变的问题。既然官方不提供,那就只能自己实现了。我尝试过几种不同的方案,也包括Metal Shader,但效果不佳,最后发现Design+Code有这个: Progressive Blur in SwiftUI

这个实现方案有点意思,它使用QuartzCore框架的CAFilter能力。首先创建一个"CAFilter",这个Filter接受几个参数:

  • inputRadius: 表示高斯模糊程度,数字越大越模糊
  • inputMaskImage: 用来被模糊的图片
  • inputNormalizeEdges: 为true代表要对模糊的边缘进行平滑过渡处理

这样通过调整inputRadius我们就能动态调整模糊的程度。

inputMaskImage则是一个利用CIFilter生成的,透明度从0到1的一张黑色图。这样当alpha为0时,完全不模糊,alpha为1时,进行最大模糊。

那么这个Filter对谁作用呢?UIVisualEffectView

UIVisualEffectView里有一个CABackdropLayer,这个layer可以实现实时的模糊处理。拿到这个Layer之后,对其应用上面创建的CAFilter即可获得渐进式模糊效果了。

具体的代码来自这个GitHub Repo,核心代码是这个: https://github.com/nikstar/VariableBlur/blob/main/Sources/VariableBlur/VariableBlur.swift

 class VariableBlurUIView: UIVisualEffectView {

    public init(maxBlurRadius: CGFloat = 20, direction: VariableBlurDirection = .blurredTopClearBottom, startOffset: CGFloat = 0) {
        super.init(effect: UIBlurEffect(style: .regular))

        // 我们用Objective-C runtime创建一个CAFilter,因为是私有API我们只能动态创建
        guard let CAFilter = NSClassFromString("CAFilter")! as? NSObject.Type else {
            print("[VariableBlur] Error: Can't find CAFilter class")
            return
        }
        guard let variableBlur = CAFilter.self.perform(NSSelectorFromString("filterWithType:"), with: "variableBlur").takeUnretainedValue() as? NSObject else {
            print("[VariableBlur] Error: CAFilter can't create filterWithType: variableBlur")
            return
        }

        // 这里创建一个0-1 alpha的渐变图片
        let gradientImage = makeGradientImage(startOffset: startOffset, direction: direction)

        variableBlur.setValue(maxBlurRadius, forKey: "inputRadius")
        variableBlur.setValue(gradientImage, forKey: "inputMaskImage")
        variableBlur.setValue(true, forKey: "inputNormalizeEdges")

        // 通过`UIVisualEffectView`拿到`CABackdropLayer`,然后我们针对这个layer应用上述Filter
        let backdropLayer = subviews.first?.layer
        backdropLayer?.filters = [variableBlur]
        
        // 这里去掉VisualEffectView的其他细节效果
        for subview in subviews.dropFirst() {
            subview.alpha = 0
        }
    }
	
	private func makeGradientImage(width: CGFloat = 100, height: CGFloat = 100, startOffset: CGFloat, direction: VariableBlurDirection) -> CGImage { // much lower resolution might be acceptable
		// 这里创建一个0-1 alpha的渐变图片
        let ciGradientFilter =  CIFilter.linearGradient()
//        let ciGradientFilter =  CIFilter.smoothLinearGradient()
        ciGradientFilter.color0 = CIColor.black
        ciGradientFilter.color1 = CIColor.clear
        ciGradientFilter.point0 = CGPoint(x: 0, y: height)
        ciGradientFilter.point1 = CGPoint(x: 0, y: startOffset * height) // small negative value looks better with vertical lines
        if case .blurredBottomClearTop = direction {
            ciGradientFilter.point0.y = 0
            ciGradientFilter.point1.y = height - ciGradientFilter.point1.y
        }
        return CIContext().createCGImage(ciGradientFilter.outputImage!, from: CGRect(x: 0, y: 0, width: width, height: height))!
    }
}

最后,可以实现如下渐变模糊效果,结合ScrollView可以实现实时动态模糊。虽然效果不如系统的Tabbar自带的,但已经稍微可以接近了。

Tags: