底栏改到第三版才定下来。中间试过 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。

有个反例值得记一下:文件批量操作栏也试过做成液态玻璃,部分机型上滤镜没生成,背景直接消失、文字挤成一团。后来改回普通磨砂胶囊。所以现在的取舍是,玻璃只给第一眼看到的东西(底栏),功能条优先稳定性。

踩过的坑

  1. 滤镜 id 必须唯一。多个实例共用 id 会串图层,GlassProvider 里做了自增注册和卸载注销。
  2. 贴图只在尺寸、圆角变化时重建。主题切换只改 tint 和高光强度,重建一趟太贵。
  3. tint 亮色用 rgba(255,255,255,0.12),暗色 0.10,实际值再由 useBackgroundLuminance 采样背景亮度微调。
  4. 玻璃层里别放半透明子元素。折射会和前景混叠,边缘发灰。
  5. dataURL 要在卸载时释放。长会话里反复进出页面,不释放内存会慢慢涨。
  6. 交互模型参考 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)