复刻 Apple Watch 蜂窝主屏:一个会「弯」的图标列表
在线示例: QiuYe UI · HoneycombView。「Apple Watch 主屏」示例可以在两种模式间切换、轻点图标打开应用;「参数调试」示例能叠加显示满尺寸区域与小圆点渐近线,边读边拖动着看会更直观。
Apple Watch 的主屏有一种很特别的质感。几十个圆形图标按蜂窝密排,正中的最大,越往外越小、越挤,到屏幕边缘缩成一圈小圆点——整片图标像是贴在一颗球的表面上,你拖动的不是一张平面,而是在转动这颗球。手指按上去,它立刻停下;甩一下,它滑出去,碰到尽头再软软地弹回来。
这篇文章记录 QiuYe UI 中 HoneycombView 组件的实现。它看上去只是"一个会缩放的列表",真正做起来要回答好几个问题:
- 图标怎么排,才是蜂窝而不是方格?
- "越往外越小"具体是多小?缩小之后,间距怎么办?
- 边缘的图标为什么不会被屏幕边缘切掉一半?
- 既然不用浏览器滚动,拖动、惯性、橡皮筋、吸附要自己写到什么程度?
- 每一帧都要重新计算几十个图标的位置和大小,怎么才不卡?
一、先拆开:它由几件事组成
把整个效果拆开,其实是一条很直的流水线:每个图标有一个固定的虚拟坐标(它在一张无限大平面上的位置),加上当前的平移量,再经过两次几何映射,得到它在屏幕上的位置和缩放。
所有交互只改平移量这两个数字,所有视觉都由映射函数从平移量算出来。这样拆有一个很实际的好处:拖动、惯性、回弹、吸附、键盘聚焦,最终都只是"让平移量变成某个值",彼此不需要知道对方的存在。
组件提供两种模式,分别对应 watchOS 的两代主屏:
两种模式共用同一套映射和物理,区别只在排布、边界和吸附目标上。
二、六边形密排:螺旋与宽窄行
圆形最紧凑的排法是六边形密排:每个圆周围正好贴着六个邻居。设相邻图标中心距为 d = itemSize + gap,那么同一行内间距是 d,行距是 d·√3/2,相邻两行左右错开 d/2。
螺旋:从正中一圈圈向外
free 模式让第 0 个图标落在正中,第 1 圈 6 个、第 2 圈 12 个……第 k 圈 6k 个。生成环形最省事的办法是六边形的轴向坐标 (q, r):从某一圈的一个角出发,沿六条边各走 k 步,正好绕一圈(做法来自 Red Blob Games 那篇经典的六边形网格指南)。
轴向坐标到屏幕坐标只需要一次线性变换:x = d·(q + r/2)、y = d·√3/2·r。数据顺序就是螺旋顺序,常用的应用放在数组前面,它们自然就在正中。
宽窄行:上下滚动的网格
vertical 模式不需要螺旋,而是宽行 columns 个、窄行 columns - 1 个交替,各行水平居中。这样窄行的每个图标正好落在宽行两个图标之间的缝里,左右边界是直的,适合只上下滚动的列表。watchOS 10 的网格首行是 3 个,所以 vertical 模式从窄行开始。
三、球面感:越往外越小,间距一起收紧
watchOS 10 的网格里,图标并不是一律满尺寸:正中最大,向外逐渐变小。对照真机截图测量(把视口边缘记为距离 1):距中心约 0.5 处缩放约 0.86,约 0.85 处约 0.65。
第一反应可能是"按到中心的距离算一个缩放值,直接 scale 上去"。但这样只缩小了图标,图标之间的间距没变——外圈的小图标之间会留着满尺寸时的间隙,看起来稀稀拉拉,完全没有"球面"的感觉。
关键在于:位置也必须跟着缩放一起收紧。如果某一处的图标缩小到 0.7,那么那一处的"空间"也应该被压缩到 0.7。换成数学语言,屏幕上的径向位置应该是缩放沿径向的积分:
domeRadius 对 ρ 求导恰好就是 domeScale,所以任何一处相邻两个图标之间的屏幕距离,都等于虚拟距离乘以那里的缩放——图标变小多少,间距就收紧多少,比例始终一致。k 就是组件的 curvature(球面感),默认 0.4,代入上面两个测量点分别得到 0.86 和 0.69,和截图对得上。
ρ 用的是椭圆距离(x / 半宽 与 y / 半高 的模),这样视口不是正方形时,层次依然沿着视口的形状铺开。ρ > 1 之后缩放保持 1 - k 不再变化,剩下的事交给下一层:边缘缩放带。
四、边缘缩放带:缩成小圆点,而不是被切掉
球面感只负责"整体层次",真正的戏剧性在边缘:最外一圈图标迅速缩小,最后变成贴着屏幕边缘内侧的一圈小圆点。注意"内侧"——Apple Watch 上你永远看不到一个被屏幕边缘切掉一半的图标。
两个区域
把视口分成两块:中间的满尺寸区域(视口每边向内收 edgeWidth),和外面一圈缩放带。满尺寸区域内什么都不做;跑到区域外的图标,按它"越界了多远"来压缩位置、缩小尺寸。
Apple Watch 的屏幕是圆角矩形,所以满尺寸区域也做成和视口同心的圆角矩形。这样角落里的图标会比边中点的图标更早开始缩小,缩放带沿着屏幕的圆角一起弯——这是"像贴在球上"的另一半来源。
求一个点到圆角矩形的距离有个很省事的写法:圆角矩形等于"一个收缩了圆角半径的核心矩形,再向外膨胀半径 r"。所以只要把点夹到核心矩形上得到最近点,距离减去 r 就是到圆角矩形的距离,方向就是法线方向:
lookupEdge 把"越界距离"变成"在缩放带里走了多远",edgeProfile 再把"走了多远"变成缩放。这两个函数是整个组件最需要讲清楚的部分。
缩放曲线:从真机截图里量出来
第一版用了一个很常见的软限制:o' = W·tanh(o / W),越界距离 o 被压进宽度 W 的带子里,缩放取它的导数 sech²。数学上很漂亮,效果却不对:满尺寸图标外面很快就是一排小圆点,而小圆点之间沿着边的方向仍隔着满尺寸的间距,整圈边缘显得稀疏。
后来换了思路,不再从公式出发,而是对着 Apple Watch 真机截图量(图标直径记为 S):
- 距屏幕边缘约 0.7S 的图标,缩放约 0.8–0.86;
- 约 0.25S 处缩放约 0.1;
- 最外一圈是贴着边缘内侧的小圆点,不会被裁掉;
- 缩放大致随屏幕上到边缘的距离线性变化。
最后一条是关键:缩放应该写成屏幕位置的函数,而不是越界距离的函数。设 x 为图标中心在缩放带里走过的比例(0 是满尺寸区域边缘,1 是距视口边缘 0.18·edgeWidth 的渐近线),缩放 s(x) 前半段二次缓入,后半段线性降到 0:
接下来要决定:越界距离 u 的图标,屏幕上应该走到 v 的哪里?这里用的是和球面感同一个思想——空间压缩多少,图标就缩小多少。写成微分方程就是:
也就是说,在缩放带里的某一处,虚拟坐标每前进 1px,屏幕上只前进 s(v) px,而那里的图标恰好也缩小到 s(v)。相邻图标沿径向的间隙随之收紧,小图标之间不会出现满尺寸的空洞。
这个方程还顺带保证了"永远不被裁掉":靠近渐近线时 s(v) 线性趋近 0,1 - v 按指数衰减,v 无限接近 1 却永远到不了。越界再远的图标,也只是在渐近线附近缩成更小的点,挤在边缘内侧。
方程没有好用的解析解,所以在模块加载时用 RK2(改进欧拉法)积分出一张 512 段的查找表,覆盖 8 倍缩放带宽度的越界距离,每帧线性插值。整个组件共享一张表,成本可以忽略。
收尾:只留一圈小圆点
越界很远的图标会在渐近线上叠成好几层。第一版缩放到 0.03 才完全消失,free 模式的螺旋向四个方向延伸,好几圈图标同时挤在边缘,统计下来可见图标里有 21 个小于 0.25——密密麻麻,有点密集恐惧。真机截图里最外只有一圈约 0.15–0.2 的小圆点。
最终的处理是缩放 0.24 开始淡出、0.16 以下完全隐藏,边缘只剩一圈干净的小点。另外缩放小于 0.5 的图标关闭命中(pointer-events: none):太小的东西不该响应点击。
谁盖住谁
图标按 DOM 顺序绘制,而螺旋排布里外圈排在后面,于是重叠时边缘的小图标叠在内侧大图标上面,看着别扭。视觉上应该是越大的越靠前。绘制时按缩放写 z-index = round(scale × 40),量化成 40 个整数档,缩放细微变化不会改写层级。根节点的 contain: layout paint 已经建立了层叠上下文,这些层级不会泄漏到页面的其他部分。
五、边界与铺满:按「屏幕上的外沿」反推
能拖多远?最朴素的做法是"让最外圈图标的中心刚好能进入满尺寸区域"。但有了球面感和缩放带,这个规则会在边缘留下一大片空白:外圈图标早就缩小了,按中心停靠时它的外沿离屏幕边缘还很远。
真机上,vertical 网格最外列外沿距屏幕边缘约 0.19S,首行顶边距顶端约 0.3S。所以边界改成按屏幕上的外沿来定:沿每个轴从中心向外逐像素试,经过完整的 mapToScreen 映射后,找到第一个"外沿到达距边缘 0.18·itemSize(水平)/ 0.28·itemSize(垂直)"的虚拟位置,作为平移边界。两种模式用同一条规则,拖到尽头时最外一圈正好铺到边缘。
vertical 模式还要决定列数。因为越往外图标越小、越挤,按满尺寸计算放不下的那一列,算上球面感之后往往放得下。所以列数取"最外列的屏幕外沿不超出目标"的最大值,再把列距在 0.9–1.1 倍之间微调,让外沿正好停在目标处。
中间也试过直接把列距拉开(最多 1.6 倍)来铺满宽度,结果中间显得稀疏、蜂窝比例被破坏,首行还被推进缩放带缩成了小点。真机靠的是"更多列 + 外侧缩小",而不是拉大间距。
上下对称
vertical 模式吸附时让某一行停在首行停靠位置 -stopY。起初 stopY 由"首行顶边距顶端 0.28S"反算出来,是个任意值。问题是各行间隔是行距 h,只有 2·stopY 恰好是 h 的整数倍时,各行才关于视口中心对称——否则整片网格永远偏上一截。
解法是把 stopY 取到最接近的半行距整数倍:
首行停靠位置因此最多偏移 h/4,换来的是每个吸附位置都上下对称。
六、手势:自己写一套滚动物理
这个组件没有用浏览器原生滚动,也没有用 Motion 的 drag。原因很简单:原生滚动只有一个方向上的线性位移,没法表达"平移量经过非线性映射才变成画面";Motion 的 drag 又是直接拖动元素本身。这里需要的是两个 MotionValue(offsetX、offsetY),由自己处理 Pointer Events 来驱动,于是两种模式、拖动、惯性、橡皮筋与吸附可以共用一套物理。
按下:随时可以接住
按下的第一件事是 stop() 掉两个 MotionValue 上正在进行的动画,从屏幕上的当前值继续。惯性滑到一半、回弹到一半、吸附到一半,手指一按就停在那里——这是"跟手"的底线。
按在图标上时,图标立刻缩小到 0.9 作为按压反馈。移动超过 6px 才认定为拖动,此时取消按压态、捕获指针;没超过就松手,则是一次点击。拖动过的那次松手会吞掉随后的 click,不会误开应用。
越界:橡皮筋
拖出边界时,画面不应该 1:1 跟着走,而是越拉越沉:
反函数容易被忽略。设想画面正在越界后回弹,手指在半路按住:此时屏幕上的越界量是经过橡皮筋压缩之后的值。如果直接把它当作新的拖动起点,下一次 pointermove 会再压缩一遍,画面就会猛地往回跳一下。所以按下时先用反函数还原出"手指本来拖了多远",从那里继续。
松手:速度与惯性
速度取最近 100ms 内的指针采样。如果松手前 50ms 内指针没动过,视为静止松手,速度为 0——慢慢拖到某处停住再松开,画面就该停在那里,而不是被一个旧的速度带走。
惯性用 Motion 的 inertia 动画,参数从 iOS UIScrollView 的常规减速率推出来:
速度每毫秒保留 99.8%,等价于时间常数 τ = -1 / ln(0.998) ≈ 500ms 的指数衰减。Motion 的 inertia 滑行总距离是 power × velocity,初速度是 power × velocity / τ,要让初速度等于松手速度(px/s 换成 px/ms),power 就是 τ / 1000 ≈ 0.5。min / max 设为平移边界,滑出边界后由弹簧接管回弹。
注意第 7 行那个看起来多余的 + 1。inertia 只用起点和速度计算落点,目标值根本不参与;但 Motion 在起止关键帧相同时会直接跳过动画。如果松手时把当前值当作目标传进去,惯性和越界回弹都不会发生,画面就僵在原地。所以传一个不同的占位目标。
吸附:先投影,再落位
默认开启的 snap 会让画面停在一个"整齐"的位置:free 模式让某个图标停在正中,vertical 模式让某一行停在顶端。问题是停在哪一个?
答案是先估算"如果不吸附,它会滑到哪",再选离这个落点最近的吸附位置。估算用的是 Apple 在 WWDC 上给过的动量投影公式:
算一下会发现,它给出的距离约是 0.499 × velocity,和上面 inertia 的滑行距离 power × velocity ≈ 0.4995 × velocity 几乎相同。也就是说,吸附与不吸附两种情况下,"手感上会滑多远"是一致的,吸附只是在那附近挑一个整齐的位置。
选定目标后,用一个接近临界阻尼的弹簧(bounce: 0.12)带着松手速度过去。带上速度很重要:用户甩得很快时,弹簧会顺着这股劲继续走,而不是先刹停再重新起步。
滚轮与触控板
桌面上还要处理滚轮。用非 passive 的 wheel 监听,把增量累加到一个目标值上,再用短弹簧(约 0.32s)跟随——Windows 滚轮逐格跳动的手感也能被抹平。目标值夹在边界内;两个方向都已经到边界时不 preventDefault,页面可以继续往下滚,不会把用户困在组件里。
开启吸附时,滚动停下 150ms 后才吸附:触控板的惯性滚动也会持续发出 wheel 事件,要等它真正结束。
一个只在 iPhone 上出现的坑
在 iPhone 上测试时,从图标上开始拖动只能移动一下就断开,而从黑色空白处开始拖就一切正常。桌面浏览器完全复现不了。
在 Chromium 的触屏模拟里记录事件顺序,问题一目了然:
触屏的 pointerdown 会把指针隐式捕获到手指下的元素(图标里的 SVG path)。拖动开始后根节点接管捕获,原元素的 lostpointercapture 冒泡到根节点,被当成了"拖动结束"。鼠标没有隐式捕获,所以桌面端永远不会暴露这个问题。修复只有一行:
另外,按下之后在非 passive 的 touchmove 里 preventDefault,并设置 -webkit-touch-callout: none 关闭长按菜单——iOS 上仅靠 touch-action: none 有时仍会被系统接管手势。
七、性能:每帧几十个元素,绕开 React
图标数量通常在 30–100 个,拖动时每一帧都要重算所有图标的位置、缩放、透明度和层级。这件事绝不能走 React 渲染。
直接写样式,只写变化的值
组件订阅 offsetX、offsetY 和入场进度这三个 MotionValue,任何一个变化时,用 Motion 的 frame.render 安排一次绘制。同一帧内重复安排的同一个回调只会执行一次,所以无论三个值在一帧里变了几次,都只算一遍。绘制时直接改写 DOM:
每个元素缓存上次写入的 transform / opacity / z-index / pointer-events(坐标保留 2 位、缩放 4 位小数),值不变就不碰 DOM。完全淡出的图标设为 visibility: hidden 之后连 transform 也跳过。所有图标都绝对定位在视口中心,只改 translate3d + scale,再加上 will-change: transform,整个拖动过程只走合成。
真正的瓶颈:样式重算
即便如此,第一版在鼠标长按来回拖动时依然越拖越卡,CPU 接近 100%。在 120Hz 的有界面 Chromium 里录 trace:每帧主线程 6.7ms(预算 8.3ms),其中样式重算 3.4ms——比 JS 计算本身贵得多。
为了找原因,在同一个页面里插入 51 个测试元素,每帧只改 transform,对比不同结构下的样式重算开销:
两个结论:
- 内层不能依赖外层样式。 内层的
rounded-[inherit]让外层每次改 transform 都连带内层一起做完整的样式重算——重算元素数直接翻倍。行内样式的变化不命中匹配缓存,每个元素都要对整站样式表重新级联一遍。 - 每帧移动的元素用 div,不用 button。
button带着一整套表单控件的 UA 样式,重算大约贵 4 倍。
于是结构改成三层:无类名的定位 div(每帧写 transform)> 可聚焦的 button(按压缩放、焦点框)> 图标内容。Shadow DOM 只能再省 0.07ms,不值得为此引入。改完后每帧样式重算从 3.36ms 降到 0.38ms,主线程从 6.7ms 降到 3.6ms,连续拖动 90 秒没有随时间变慢。
排查中还有一个插曲:用 Playwright 的
page.mouse按住拖动测试时,它的拖放拦截每次移动都会增删监听器,耗时随时间增长,看起来就像组件"越拖越慢"。改用 CDP 的Input.dispatchMouseEvent后这个现象消失了。测性能时,先确认测量工具本身没有在拖后腿。
will-change: transform 也做过对照:去掉之后每帧都要重绘图标内容,帧率掉到 60fps 左右,所以保留。
八、入场、键盘与减少动态效果
入场:从中心散开
挂载时,图标从中心向外依次展开,这是 watchOS 从表盘回到主屏时的"散开"。实现上是一个 0 → 1 的进度值(0.8s,线性),每个图标按到中心的距离分配延迟,位置从 55% 处展开,缩放与透明度同步升起。它只是平移量之外的又一个 MotionValue,同样进 paint 里算,不需要另一套动画系统。
服务端渲染的 HTML 里图标先是 opacity: 0,首帧在 useLayoutEffect 里同步绘制,避免水合前所有图标堆在视口中心的一瞬。
键盘
- 根节点
role="group",每个图标是带aria-label的button,Tab 依次聚焦。 - 方向键在六个邻居中选方向最接近的一个:计算每个邻居相对当前图标的方向与按键方向的点积,取最大者。
- 聚焦时(仅
:focus-visible,鼠标点击不触发)把图标平移进满尺寸区域,键盘可以一路走到最外圈。完全淡出的图标是visibility: hidden,无法聚焦,所以先取消隐藏再聚焦,随后的绘制会把它移进视野。 overflow: hidden的容器在聚焦时仍可能被浏览器偷偷滚动,scroll事件里把scrollTop / scrollLeft归零,免得与平移量叠加。
减少动态效果
开启"减少动态效果"时不做入场散开,吸附与回弹不带过冲。拖动仍然 1:1 跟手——直接操作是用户自己的动作,不属于会引起不适的自动运动。
九、打开应用:组件只交出一个元素
轻点图标"长成"整个应用的转场,并不在组件内部。组件只做一件事:onItemSelect 的第三个参数交出图标的定位元素,它的 getBoundingClientRect() 就是图标静止时在屏幕上的矩形。
为什么不交出按钮本身?因为按钮有按压反馈:松手触发 click 时,它还在从 0.9 的按压缩放中恢复,量出来的矩形偏小,转场落地时就会"跳大"一下。定位元素不含按压缩放,量到的永远是准的。
转场本身是一个典型的容器变形(Container Transform):一个进度值驱动浮层从图标矩形长到全屏,图标字形作为共享元素连续迁移到应用名称上方,背后的蜂窝以图标为中心放大、隐去。具体做法在容器变形过渡那篇文章的「策略 C:启动式缩放——图标长成应用」一节里有完整拆解。
十、用法
组件通过 QiuYe UI registry 分发,依赖只有 motion:
视口的尺寸和圆角由 className 决定,组件会读取根节点的 border-radius,让缩放带沿着圆角弯曲:
几个常用参数:
renderItem 返回纯视觉内容即可,按 itemSize 设计,缩放由组件统一处理;不要在里面再嵌套按钮。
十一、验收清单
- 几何: 正中图标最大、向外渐小;相邻图标不重叠、无空洞;边缘只有一圈贴着内侧的小圆点,没有被切掉一半的图标。
- 层级: 重叠处总是大的盖住小的。
- 铺满: 拖到尽头时最外一圈正好铺到边缘;
vertical模式每个吸附位置都上下对称。 - 跟手: 拖动 1:1;惯性、回弹、吸附途中按下立刻停住,越界状态下接住不跳变。
- 松手: 慢拖后静止松手停在原地;甩动后滑行,越界回弹;吸附落点与甩动力度相符。
- 点击: 轻点打开,拖动后松手不打开。
- 触屏: 从图标上开始拖动也能一直跟随(隐式指针捕获)。
- 滚轮: 桌面平滑滚动,到边界后页面可以继续滚动。
- 键盘与减少动态效果: Tab 与方向键可用,聚焦的图标会被移进视野;减少动态效果时没有入场与过冲。
- 性能: 连续拖动一分钟以上,帧间隔没有随时间上升;Performance 面板里样式重算远小于帧预算。
十二、结语
回头看,HoneycombView 里真正决定"像不像"的,是一条反复出现的原则:空间压缩多少,图标就缩小多少。球面感是它,边缘缩放带也是它,只是一个用解析积分、一个用数值积分。只缩图标不缩空间,就会得到一张稀疏、廉价的鱼眼;两者同步,才像一颗被手指转动的球。
另一半功夫在"接得住":任何时刻按下都能停住,越界中途接住不跳变,惯性和吸附对"会滑多远"的判断一致,触屏和鼠标的行为一致。这些细节单独看都不起眼,但少了任何一个,手指都会立刻察觉——毕竟 Apple Watch 上这块屏幕,本来就是拿来用手指转的。