最近在玩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!
我怀疑它可能是:
- 在整个App的层级上它设计了一个能浮动的View,跟App同级别,或者跟Tabbar所处的Root View同级别,只有这样才能缩放Tabbar所处的View。
- 这是一个特殊的能力,不对外开放的。
我目前倾向于是 #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自带的,但已经稍微可以接近了。