本文聚焦一个核心问题:如何在 LVGL 中复现 Apple Watch 上的气泡列表界面?
本实现仅供学习和参考,不保证与 Apple Watch 的行为完全一致,但力求在核心交互和视觉效果上达到相似的用户体验。
以下是在真实硬件上运行的最终效果:

拆解 WatchOS 26 气泡应用布局#
气泡排列布局#
整体采用蜂窝错列布局:偶数行 3 个、奇数行 4 个。

行为与动画#
行为与动画的核心表现可以拆成:
- 中心区域最大,边缘逐渐缩小。
- 边缘图标不仅变小,还向中心聚拢。
- 拖动惯性与回弹:拖动时图标跟随手势移动。
- 点击局部反馈:被按图标缩小并暗化,邻近图标向按压点轻微牵引。

复现思路#
复现这套效果时,可以先从最基础的布局开始:先搭一个容器 lv_obj_t *container,把所有气泡都放在这个容器里,再按照"偶数行 3 个、奇数行 4 个"的方式把图标排出来。布局只要先保证整体是蜂窝错列的,后面的动画和缩放才有基础。
接下来处理视觉层次。中心区域的图标保持最大,越靠边缘就越小,并且在接近边界时再做一点往内收的处理,这样边缘不会显得太散。只要把"位置决定大小"这件事做好,整体观感就会接近手表桌面的那种层次感。
最后再补交互动画。按下时让图标有轻微缩小和变暗,拖动时跟随手势移动,松手后根据速度决定是回弹还是继续惯性滑动。点击和拖拽要分开判断,避免手指扫过图标时误触。只要把布局、缩放、边缘收缩和输入动画这四部分串起来,基本就能复现出这类气泡列表的主要效果。
bubble_tick_timer#
这个定时器是整个系统的心脏,负责每帧推进状态更新、物理积分和视觉求解。它以固定的时间步长(比如 16 ms)运行,确保动画平滑且与系统时钟同步。
整体算法流程#
这个组件本质上是一个离散时间系统,每一帧都在执行:"状态更新 → 几何求解 → 视觉提呈"。

这张图里有三个关键闭环:
- 输入闭环:用户动作如何改写状态。
- 物理闭环:速度与位移如何逐帧收缩。
- 渲染闭环:状态如何映射到对象属性。
布局核心:索引映射到蜂窝错列#
行模式#
网格采用交替行容量:
- 偶数行 3 个
- 奇数行 4 个
该模式让视觉更接近手表桌面,而不是规则矩阵。
映射算法#
给定 index,顺序扣减每行容量,直到落入当前行:
- 得到
row col = index - consumed
得到 row 与 col 后,再用固定行距和列距转换到局部坐标。
如果上面这两句话不够直观,可以把它理解为"按行发号(0-based index)":
- 第 0 行有 3 个号:0, 1, 2
- 第 1 行有 4 个号:3, 4, 5, 6
- 第 2 行有 3 个号:7, 8, 9
- 第 3 行有 4 个号:10, 11, 12, 13

于是任意 index 的定位方式就是:
- 从第 0 行开始,看这一行能不能"容纳"这个 index。
- 如果不能,就减去该行容量,继续看下一行。
- 第一次能容纳的那一行就是
row,剩下的偏移量就是col。
最后一步才是几何映射:把 (row, col) 转成像素坐标。
row决定纵向层级(乘以ROW_PITCH_Y)col决定横向位置(乘以ROW_PITCH_X)- 再减去该行半宽,保证每行都以中心对齐
这里实际用了两套坐标语义:
- 算法求解阶段:采用"容器中心为原点"的局部坐标系,气泡坐标表示的是气泡中心,允许出现负值。
- LVGL 提交阶段:转换为"容器左上角为原点"的标准 LVGL 坐标系,
lv_obj_set_pos()需要的是气泡左上角坐标。
结合实现看,row_layout_to_pixel() 的核心逻辑可以概括为:
bubble_count = (row % 2 == 0) ? 3 : 4;
row_half_span = (bubble_count - 1) / 2.0f;
x = (col - row_half_span) * ROW_PITCH_X;
y = (row - row_center) * ROW_PITCH_Y;c这里的关键是 (col - row_half_span):
col是"从左到右"的列坐标。row_half_span是这一行的"半宽列数"。- 相减后,坐标就从"左对齐坐标系"变成"中心对齐坐标系"。
row_center = (min_row + max_row) / 2.0f;
row_half_span = (bubble_count - 1) / 2.0f;
pos_x = (col - row_half_span) * ROW_PITCH_X;
pos_y = (row - row_center) * ROW_PITCH_Y;c
举个直观例子:
- 若该行
bubble_count = 3,则row_half_span = 1;col=0/1/2 会变成 −1/0/1。 - 若该行
bubble_count = 4,则row_half_span = 1.5;col=0/1/2/3 会变成 −1.5/−0.5/0.5/1.5。
然后再乘以 ROW_PITCH_X,就得到以行中心为原点、左右对称分布的最终 x 坐标。这样奇偶行都不会出现"整行偏左或偏右"。
这里有两个工程上的收益:
- 任何索引都可映射,天然支持按需扩容。
- 排布规则由函数定义而不是静态表,后续改 3-4 为 4-5 或其他拓扑的改动成本很低。
垂直动态居中#
row_center 不是常量。系统会根据"当前有图标资源的最小行与最大行"计算中点,确保图标集合整体居中。
这一步避免了图标只填部分索引时出现"重心漂移"。
视觉模型:距离场驱动缩放#
我将视觉模型划分为两个距离场:

中心红色区域是 Center Region,中间过渡带是 Fringe Region。
气泡的缩放由它们到边界的距离驱动:
- 中心区域(
distance <= 0):scale = max_scale - 过渡带(
0 < distance <= fringe_width):scale 从max_scale线性过渡到min_scale - 边缘区域(
distance > fringe_width):scale = min_scale
边界形状#
边界不是矩形,而是带圆角的近似圆角矩形:
X_RADIUS与Y_RADIUS定义中心有效区。CORNER_RADIUS处理角部曲率。fringe_width控制从最大缩放过渡到最小缩放的带宽。
视觉求解的实际顺序#
一帧中每个图标的求解顺序为:
- 网格坐标 → 基础 x/y
- 叠加全局 offset
- quick culling(先丢掉不可见气泡)
- 计算
distance_to_edge - distance → scale
- 边界压缩与平移补偿
- 二次可见性判定(结合缩放后的半径)
顺序不能随意交换,尤其是"先 quick culling 再做复杂距离计算"可以节省大量计算开销。
边缘压缩与平移补偿#
气泡只做缩放会产生两个副作用:
- 边缘气泡虽然变小了,但中心气泡也没变小,导致边缘气泡看起来更稀疏。
- 边缘气泡观感会比边中区域更突兀。
所以在缩放前后增加两步修正:
- Boundary compaction:按到边界距离压缩坐标增量。
- Compact translation:沿法向或角平分方向做补偿位移。
这两步让边缘既收缩又不塌陷,保持"往中心聚拢"的视觉势能。
这里的核心思想不是"两个对象之间的距离"直接变小,而是"对象到边界的距离"变小。
更具体地说,先把图标看成落在一个理想坐标系里的点,然后对这个点做一次向内的非线性映射:
- 点越靠近中心,基本不动。
- 点越接近边界,越被压向内侧。
- 点进入边缘过渡带后,压缩强度逐步变大。
所以最后看到的结果是:
- 边缘图标彼此间隙看起来更小。
- 边缘图标和中心图标之间的视觉距离也会缩短。
- 但它不是简单地把所有图标统一往中心平移,而是"按位置不同,压缩程度不同"。
换言之,它是一个空间非线性映射:输入坐标越接近边界外侧,压缩系数越大。

可以把它理解为这个过程:

在实现上,这个压缩不是全局统一缩放,而是分两层做的:
apply_boundary_compaction():先把超出边界附近的坐标"折叠"一点,避免边缘点过于外扩。apply_compact_translation():再根据点所在位置和边界距离,向中心方向补一段平移,让视觉间距更紧凑。
具体的压缩流程可以拆成 5 步:
- 先求边界距离:对每个图标先计算
distance_from_edge,这个值越大,说明图标越靠近外圈。 - 再算压缩强度:把
distance_from_edge映射成压缩系数,中心区域几乎不动,进入 fringe 后开始线性增强,超过 fringe 后继续按更强的力度向内收。 - 先做单轴折叠:分别对 x 与 y 调用
apply_boundary_compaction(),把超过X_RADIUS/Y_RADIUS的部分折叠到边界内侧,避免图标沿水平或垂直方向"撑出去"。 - 再做方向补偿:调用
apply_compact_translation(),根据当前点所在象限、是否位于角落、以及离边界有多远,沿法向或角平分方向补一段位移。 - 最后限幅并提交:把补偿量限制在
compact_limit内,然后写回 x/y,供后续lv_obj_set_pos()使用。
对应到代码,可以把它理解为下面这个顺序:
distance_from_edge = calc_distance_to_edge(wb, x, y);
apply_boundary_compaction(&x, X_RADIUS, fringe_width);
apply_boundary_compaction(&y, Y_RADIUS, fringe_width);
apply_compact_translation(wb, &x, &y, distance_from_edge);
/* 最终用于 lv_obj_set_pos() 的坐标 */c其中,apply_boundary_compaction() 解决的是"图标离边界太近时坐标怎么折叠回去"的问题,apply_compact_translation() 解决的是"折叠以后还显得松散,怎么把视觉间距补紧"的问题。两者配合起来,边缘图标既不会显得散,也不会一股脑挤成一团。
输入算法:点击与拖拽如何共存#
状态机#
输入分为三类事件:
pressed:记录起点和候选图标pressing:累计位移与速度released:判定点击或进入惯性
对应到 LVGL,就是三个事件回调:
LV_EVENT_PRESSEDLV_EVENT_PRESSINGLV_EVENT_RELEASED
在这三个回调里只做状态更新,不直接做复杂物理积分;物理积分放在定时器统一执行。
点击判定#
点击不是只看 released 时命中,而是同时满足:
- 位移不超过阈值
TAP_MOVE_TOLERANCE - 抬起命中图标与按下候选一致
这样可避免"拖动途中扫过图标"触发误点击。
再加一层工程保护:
- release 时先缓存
pending_click数据。 - 在
LV_EVENT_CLICKED里只转发一次自定义 payload。
这样可以避免输入链路中可能出现的重复点击分发。
命中优先级#
命中检测时,若多个图标都覆盖当前触点,选择 scale 更大的图标。
理由很直接:在视觉上更大的图标更靠前,用户预期也应优先命中它。

物理算法:阻尼、弹簧、吸附的协同#
定时器以固定步长推进离散系统,每帧更新 offset 和 velocity。
实现上的关键是把它看作"显式迭代求解器":
- 根据上一帧状态预测新状态。
- 对新状态施加约束(限幅、吸附、超界压缩)。
- 速度过小则截断为 0,避免无限小抖动。
当状态变化量低于阈值时,系统进入静止状态,不再持续重绘。
X 方向#
X 方向更强调"回中":
- 速度先阻尼衰减。
- 再叠加指向中心的弹簧项。
offset_x与max_x_offset限幅。
表现是:横向可以拨动,但最终会自然回到中心。
这本质上是一个一维阻尼弹簧系统:
v(t+1) = d·v(t) + k·(x_target - x(t))
x(t+1) = x(t) + v(t+1)其中 d 为阻尼系数,k 为回中刚度。

Y 方向#
Y 方向更强调"内容滚动感":
- 拖动时允许 overscroll,并进行橡皮筋压缩。
- 释放后向最近 ratchet step 吸附。
- 超界区域使用更强的回弹项。
这里组合了两种力:
- 弹簧力,负责纠正偏离。
- 阻尼力,负责消除振荡。
并通过 dead zone 决定更偏向"软吸附"还是"强回弹"。
Y 方向之所以更复杂,是因为它同时承担:
- 滚动感
- 越界橡皮筋感
- 离散步进吸附感
三个目标互相冲突,所以采用分段策略而不是单一方程。
按压动画:局部形变而非全局变换#
按压反馈包含三部分:
- 被按图标缩小并暗化。
- 邻近图标向按压点轻微牵引。
- 松手后按不同速度做进出动画。
关键点在于:这是对局部节点施加扰动,不会破坏整体布局求解。 因此反馈明显,但不会让用户感觉"整个网格在晃"。
在实现上,这一段并不复杂,本质是"按压进度"驱动的插值更新。
按下后为什么会"变色 + 变小"#
按下命中图标后,组件会记录 pressed_icon_index,并把 press_anim_target 设为 1。定时器每一帧把 press_anim_progress 逐步逼近目标值,因此你会看到一个平滑的按下动画,而不是瞬间切换。
当某个图标是当前按压对象时,会做两件事:
- 缩小:用插值把当前 scale 从 1.0 过渡到
PRESS_SCALE,再写回对象尺寸。 - 变暗:按同一份进度计算暗化强度,分别作用到气泡背景色和图标图片层的 image recolor 不透明度。
所以视觉上就是"按下去会微微塌下去",同时颜色变深一点,手感会更像实体按钮。
周围对象是怎么"靠过去"的#
被按图标先算出中心点,其他图标再根据自己到这个中心点的距离决定是否参与牵引:
- 距离超过影响半径,不移动。
- 距离越近,牵引系数越大。
- 位移方向是"当前图标指向按压中心"的单位向量。
- 最终位移还会乘上按压进度,保证按下和松手阶段都平滑。
这就是为什么你看到的是局部区域轻微收拢,而不是整屏一起移动。
松手后如何恢复#
松手或判定为拖拽后,会把 press_anim_target 设回 0。定时器继续插值:按压图标的缩放和暗化逐步消失,邻近图标的牵引位移也同步回到 0。整个回弹过程不需要单独写一套反向动画,只要目标值回 0 即可自然恢复。
对应到 LVGL,最终落地仍然是几类稳定的属性更新:
lv_obj_set_size()/lv_obj_set_pos()lv_obj_set_style_bg_color()- image recolor + 不透明度(
lv_obj_set_style_image_recolor_opa())
即使动画看起来复杂,底层依然是同一套插值驱动的几何和样式刷新。
性能思想:只在需要时刷新#
算法再好,如果每帧无差别重绘,也会浪费性能。
因此实现采用脏标记策略:
- 状态变化时 mark_refresh。
refresh_if_needed才执行对象更新。
同时在图标可见性计算中先做快速裁剪,再做昂贵计算。这让组件在静止状态接近零更新成本,在交互状态保持稳定帧率。
建议把性能策略分成两层:
- 状态层:changed 与 mark_refresh。
- 图标层:先 quick culling,再做精确判定。
这样可以把"每帧全量求解"压缩为"必要图标求解 + 必要帧提呈"。
设计与实现的权衡解释#
fx 定点数的使用#
实际算法中为了避免浮点数运算,使用了定点数(fx)进行计算,最终提呈给 LVGL 的坐标和尺寸需要转换回整数。上述描述中为了表达清楚,直接用了"坐标"、"半宽"等术语,但实际代码里是通过定点数计算后再转换成整数像素值的。
为什么自己维护一个定时器?#
不使用 LVGL 内置动画系统,而是自维护一个定时器,原因如下:
-
强耦合联动太多。 按压缩放、邻居牵引、边缘压缩、惯性回弹、吸附都在互相影响。如果用 LVGL 自带动画,通常要给很多对象分别挂动画,彼此同步和中断会很复杂。
-
交互要可打断、可即时接管。 手指一按下/拖动/松手,状态要立刻切换。统一定时器里可以直接改
offset/velocity/press_anim_target,下一帧立刻生效,不用管理一堆动画的启动、停止、重定向。 -
物理和动画是同一个"时间轴"。 惯性、弹簧、吸附、按压进度都在同一 tick 里推进。这样每帧只有一个真值来源,行为更可控,也更容易调参。
-
定点数与可预测性。 本组件大量用 fixed-point 计算。自己推进能保证数值路径一致,避免不同动画通道带来的节奏偏差。
-
性能和维护成本。 几十上百个气泡同时参与时,用统一刷新(含裁剪)通常更稳。否则每个对象各自动画,调度和状态管理成本会明显上升。
参考文章#
Deconstructing the Iconic Apple Watch Bubble UI

