底栏改到第三版才定下来。中间试过 Canvas,滚动列表一跑就掉帧,换成 SVG 滤镜之后基本不用再管性能。
这篇把管线和参数取值写清楚。参数部分尽量给理由,而不只是抄一组数字。
要做什么
一条底栏,整条是玻璃,均分四个标签(图标 + 字)。选中态不用色块,用一枚灰色胶囊。按下时胶囊鼓成气泡,边缘带折射;拖动时跟手,松手回弹并吸到最近的标签。
环境上有几处麻烦:跑在 Android WebView(Chromium 内核)里,没有原生渲染权限;不能上全屏 Canvas/WebGL,那会和滚动列表抢合成线程;要跟亮暗色切换走,还得留降级的余地,因为不同机型的 WebView 内核能力并不齐。
为什么用 SVG 滤镜
玻璃的观感来自折射:边缘的景物被弯折、整体染色,再叠一道高光。常见做法三种,前两种我都试过:
| 方案 | 效果 | 问题 |
|---|---|---|
backdrop-filter: blur() |
磨砂 | 是毛玻璃,不是玻璃,缺厚度 |
| Canvas/WebGL 实时采样 | 折射能算得很真 | 每帧重绘,滚动掉帧 |
SVG 滤镜 + backdrop-filter: url() |
有折射,走合成器 | 只有 Chromium 认 |
选了第三种。feDisplacementMap 本来就是拿一张图去推另一张图的像素,正好对应「边缘强、中间弱」的透镜近似。滤镜挂在合成层上,主线程基本不受影响。
分层
交互和渲染拆开,中间只传 props(圆角、厚度、着色)和动画状态:
ui/GlassDock.jsx 4 个标签 + 指示胶囊;指针手势;弹簧积分
vendor/lg/lib/
LiquidGlass.tsx 玻璃容器
displacementMap.ts 位移图 + 高光图
LiquidGlassFilter.tsx SVG 滤镜链
GlassProvider.tsx 滤镜注册
capabilities.ts 能力探测
useBackgroundLuminance 背景亮度自适应
useBorderGlow 边框反光
交互层只回答「玻璃往哪动」,渲染层只回答「玻璃长什么样」。改动画不用碰滤镜代码,反过来也一样。
渲染管线
两张贴图
元素尺寸确定后,generateLiquidGlassMaps 生成位移图和高光图,同时吐一个 maxDisplacement 用于归一化。
interface LiquidGlassMapOptions {
width: number; height: number; radius: number;
bezelWidth?: number // 棱镜宽度,默认 30
glassThickness?: number // 玻璃厚度,默认 150(越大折射越强)
refractiveIndex?: number // 默认 1.5
specularAngleDeg?: number // 高光光源角度,默认 60
specularRimWidth?: number // 高光带宽,默认 1.5px
profile?: 'convex_squircle' | …
}
位移图只处理边缘一圈:把宽 bezelWidth 的边框当成棱镜,按厚度和折射率算出每个像素往外或往里的偏移量,中间区域为 0。高光图按光源角度在边上画一条渐隐的亮带,宽度由 specularRimWidth 决定。两张图都转成 dataURL,交给 <feImage>。
滤镜链
feGaussianBlur(预模糊)
→ feImage(位移图) → feDisplacementMap(折射)
→ feColorMatrix(饱和增益)
→ feImage(高光图) → feComponentTransfer(高光强度)
→ feBlend(screen)(叠边缘高光)
预模糊和折射必须在同一条链里,拆开会跟 backdrop-filter 的嵌套规则冲突。feDisplacementMap 的 scale 我落在 30–50:低于 30 看不出弯折,高于 50 底下的内容会被撕开,滚动时尤其明显。
参数
| 属性 | 默认 | 作用 |
|---|---|---|
radius |
— | 圆角 |
bezelWidth |
30 | 棱镜宽度,即边缘折射带多宽 |
glassThickness |
150 | 玻璃厚度,折射强度主旋钮 |
refractiveIndex |
1.5 | 折射率,物理常数,一般不调 |
refractionScale |
1 | 全局折射倍率,缩放场景用 |
blur |
0 | 预模糊半径 |
saturate |
1 | 折射后的饱和度增益 |
tint |
— | 半透明着色 |
profile |
convex_squircle |
表面轮廓 |
backdropBlur |
0 | 背景模糊,与折射合并进同一个 backdrop-filter |
parallax |
false | 指针视差高光 |
底栏实际用的是一组偏薄的值:radius=32, bezelWidth=16, glassThickness=100, refractionScale=0.9, blur=0.3, saturate=1.3, backdropBlur=0.75, tint=rgba(255,255,255,0.12)。底栏高度有限,棱镜带一宽,整块面板就只剩边框了。
弹簧
CSS 的 cubic-bezier 能装出弹簧的样子,但处理不了「松手瞬间按速度决定落点」。所以自己写了个数值弹簧:
// a = k(target - x) - 2·d·√k·v
step(dt) {
const w = Math.sqrt(k)
let rem = dt
while (rem > 0.0001) {
const h = Math.min(1 / 120, rem) // 子步 ≤ 8.3ms
const a = (target - x) * k - 2 * d * w * v
v += a * h
x += v * h
rem -= h
}
}
拆子步是为了防发散。掉一帧的时候 dt 可能到 50ms 以上,直接欧拉积分会越算越飘;切到 1/120s 之后,大帧间隔下也稳。
| 弹簧 | 阻尼 d | 刚度 k | 用途 |
|---|---|---|---|
| 位置 / 按压 | 1.0 | 1000 | 胶囊位移、按压强度 |
| 缩放 | 0.6–0.7 | 250 | 气泡横纵向放大 |
| 速度 | 0.5 | 300 | 松手后的惯性衰减 |
状态机
idle ──pointerdown──▶ press 气泡 1.12×1.22,整条栏 ×1.03
press ──drag──────▶ drag 指示器跟手;按速度拉伸;整栏反推 ≤4px
drag/press ──pointerup──▶ settle 速度弹簧衰减 + 位置弹簧吸附最近标签
settle ──▶ idle (onChange 切页)
三条细节:
- 拖动时记录最近几帧的位移估速度。松手把速度交给速度弹簧、位置交给位置弹簧,落点和回弹幅度由两者叠加决定。
- 整条栏的反推限在 4px(
PUSH_MAX),既有物理反馈,又不至于把栏拖出屏幕。 - 按压分两轴形变(
PRESS_SX=1.12、PRESS_SY=1.22)。等比放大看起来像缩放,两轴分开才像被按扁。
降级
// capabilities.ts
CSS.supports('backdrop-filter', 'url(#x)')
不支持就退回半透明底 + backdrop-filter: blur(),观感变成磨砂,交互完整。另外每个玻璃实例都要生成一组滤镜和两张贴图,同屏实例我给自己定的上限是 2。
有个反例值得记一下:文件批量操作栏也试过做成液态玻璃,部分机型上滤镜没生成,背景直接消失、文字挤成一团。后来改回普通磨砂胶囊。所以现在的取舍是,玻璃只给第一眼看到的东西(底栏),功能条优先稳定性。
踩过的坑
- 滤镜 id 必须唯一。多个实例共用 id 会串图层,
GlassProvider里做了自增注册和卸载注销。 - 贴图只在尺寸、圆角变化时重建。主题切换只改 tint 和高光强度,重建一趟太贵。
- tint 亮色用
rgba(255,255,255,0.12),暗色0.10,实际值再由useBackgroundLuminance采样背景亮度微调。 - 玻璃层里别放半透明子元素。折射会和前景混叠,边缘发灰。
- dataURL 要在卸载时释放。长会话里反复进出页面,不释放内存会慢慢涨。
- 交互模型参考 Kyant0/AndroidLiquidGlass(Apache-2.0)。这里是自主的 Web 实现,没有拷贝其源码;早期原型参考过的第三方实现已全部废弃移除。许可汇总见
THIRD_PARTY_NOTICES.md。
结果
底栏常驻 2 个玻璃实例(外框 + 指示胶囊),中低端机滚动 60fps 无掉帧。首帧到可交互在 16ms 内,贴图生成放在空闲帧。不支持 SVG 背景滤镜的环境自动退成磨砂,视觉降级,交互不变。
参考
- Kyant0/AndroidLiquidGlass(Apache-2.0),底部标签栏交互模型
- 公开的液态玻璃位移贴图思路,SVG
feDisplacementMap方案 - React 19 / esbuild(MIT)