试用 LVGL Pro,一套完整的工具包,助您高效构建、测试、分享和交付 UI!
LVGL
教程

解析 Apple Watch Bubble 组件:布局、交互与算法实现

深入解析如何在 LVGL 中复现 Apple Watch 气泡列表界面——涵盖蜂窝错列布局算法、距离场缩放、物理模拟与按压动画。

Sab1eSab1e25 分钟阅读
解析 Apple Watch Bubble 组件:布局、交互与算法实现

本文聚焦一个核心问题:如何在 LVGL 中复现 Apple Watch 上的气泡列表界面?

本实现仅供学习和参考,不保证与 Apple Watch 的行为完全一致,但力求在核心交互和视觉效果上达到相似的用户体验。

以下是在真实硬件上运行的最终效果:

LVGL Apple Watch 气泡组件在真实硬件上运行的实时演示
最终效果:Apple Watch 风格的气泡网格在 LVGL 真实硬件上运行

拆解 WatchOS 26 气泡应用布局#

气泡排列布局#

整体采用蜂窝错列布局:偶数行 3 个、奇数行 4 个。

蜂窝错列气泡网格,偶数行 3 个、奇数行 4 个
蜂窝错列布局——偶数行 3 个,奇数行 4 个

行为与动画#

行为与动画的核心表现可以拆成:

  1. 中心区域最大,边缘逐渐缩小。
  2. 边缘图标不仅变小,还向中心聚拢。
  3. 拖动惯性与回弹:拖动时图标跟随手势移动。
  4. 点击局部反馈:被按图标缩小并暗化,邻近图标向按压点轻微牵引。
气泡行为与动画总览,展示中心缩放与边缘聚拢效果
核心视觉表现:中心缩放、边缘收拢、惯性与按压反馈

复现思路#

复现这套效果时,可以先从最基础的布局开始:先搭一个容器 lv_obj_t *container,把所有气泡都放在这个容器里,再按照"偶数行 3 个、奇数行 4 个"的方式把图标排出来。布局只要先保证整体是蜂窝错列的,后面的动画和缩放才有基础。

接下来处理视觉层次。中心区域的图标保持最大,越靠边缘就越小,并且在接近边界时再做一点往内收的处理,这样边缘不会显得太散。只要把"位置决定大小"这件事做好,整体观感就会接近手表桌面的那种层次感。

最后再补交互动画。按下时让图标有轻微缩小和变暗,拖动时跟随手势移动,松手后根据速度决定是回弹还是继续惯性滑动。点击和拖拽要分开判断,避免手指扫过图标时误触。只要把布局、缩放、边缘收缩和输入动画这四部分串起来,基本就能复现出这类气泡列表的主要效果。

bubble_tick_timer#

这个定时器是整个系统的心脏,负责每帧推进状态更新、物理积分和视觉求解。它以固定的时间步长(比如 16 ms)运行,确保动画平滑且与系统时钟同步。

整体算法流程#

这个组件本质上是一个离散时间系统,每一帧都在执行:"状态更新 → 几何求解 → 视觉提呈"。

算法流程图:状态更新、几何求解与视觉提呈的循环
三个关键闭环:输入、物理与渲染

这张图里有三个关键闭环:

  1. 输入闭环:用户动作如何改写状态。
  2. 物理闭环:速度与位移如何逐帧收缩。
  3. 渲染闭环:状态如何映射到对象属性。

布局核心:索引映射到蜂窝错列#

行模式#

网格采用交替行容量:

  1. 偶数行 3 个
  2. 奇数行 4 个

该模式让视觉更接近手表桌面,而不是规则矩阵。

映射算法#

给定 index,顺序扣减每行容量,直到落入当前行:

  1. 得到 row
  2. col = index - consumed

得到 row 与 col 后,再用固定行距和列距转换到局部坐标。

如果上面这两句话不够直观,可以把它理解为"按行发号(0-based index)":

  1. 第 0 行有 3 个号:0, 1, 2
  2. 第 1 行有 4 个号:3, 4, 5, 6
  3. 第 2 行有 3 个号:7, 8, 9
  4. 第 3 行有 4 个号:10, 11, 12, 13
蜂窝网格中 0~13 号索引到行列的映射示意图
任意 index 通过逐行扣减容量找到对应 (row, col)

于是任意 index 的定位方式就是:

  1. 从第 0 行开始,看这一行能不能"容纳"这个 index。
  2. 如果不能,就减去该行容量,继续看下一行。
  3. 第一次能容纳的那一行就是 row,剩下的偏移量就是 col。

最后一步才是几何映射:把 (row, col) 转成像素坐标。

  1. row 决定纵向层级(乘以 ROW_PITCH_Y)
  2. col 决定横向位置(乘以 ROW_PITCH_X)
  3. 再减去该行半宽,保证每行都以中心对齐
两套坐标系

这里实际用了两套坐标语义:

  1. 算法求解阶段:采用"容器中心为原点"的局部坐标系,气泡坐标表示的是气泡中心,允许出现负值。
  2. 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):

  1. col 是"从左到右"的列坐标。
  2. row_half_span 是这一行的"半宽列数"。
  3. 相减后,坐标就从"左对齐坐标系"变成"中心对齐坐标系"。
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
中心对齐坐标系:每行以自身中心为原点左右对称分布
中心对齐布局:每行独立居中,通过减去半宽实现

举个直观例子:

  1. 若该行 bubble_count = 3,则 row_half_span = 1;col=0/1/2 会变成 −1/0/1。
  2. 若该行 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 坐标。这样奇偶行都不会出现"整行偏左或偏右"。

这里有两个工程上的收益:

  1. 任何索引都可映射,天然支持按需扩容。
  2. 排布规则由函数定义而不是静态表,后续改 3-4 为 4-5 或其他拓扑的改动成本很低。

垂直动态居中#

row_center 不是常量。系统会根据"当前有图标资源的最小行与最大行"计算中点,确保图标集合整体居中。

这一步避免了图标只填部分索引时出现"重心漂移"。


视觉模型:距离场驱动缩放#

我将视觉模型划分为两个距离场:

距离场区域:中心红色区域与中间过渡带
Center Region(满比例)与 Fringe Region(缩放过渡)

中心红色区域是 Center Region,中间过渡带是 Fringe Region。

气泡的缩放由它们到边界的距离驱动:

  1. 中心区域(distance <= 0):scale = max_scale
  2. 过渡带(0 < distance <= fringe_width):scale 从 max_scale 线性过渡到 min_scale
  3. 边缘区域(distance > fringe_width):scale = min_scale

边界形状#

边界不是矩形,而是带圆角的近似圆角矩形:

  1. X_RADIUS 与 Y_RADIUS 定义中心有效区。
  2. CORNER_RADIUS 处理角部曲率。
  3. fringe_width 控制从最大缩放过渡到最小缩放的带宽。

视觉求解的实际顺序#

一帧中每个图标的求解顺序为:

  1. 网格坐标 → 基础 x/y
  2. 叠加全局 offset
  3. quick culling(先丢掉不可见气泡)
  4. 计算 distance_to_edge
  5. distance → scale
  6. 边界压缩与平移补偿
  7. 二次可见性判定(结合缩放后的半径)

顺序不能随意交换,尤其是"先 quick culling 再做复杂距离计算"可以节省大量计算开销。


边缘压缩与平移补偿#

气泡只做缩放会产生两个副作用:

  1. 边缘气泡虽然变小了,但中心气泡也没变小,导致边缘气泡看起来更稀疏。
  2. 边缘气泡观感会比边中区域更突兀。

所以在缩放前后增加两步修正:

  1. Boundary compaction:按到边界距离压缩坐标增量。
  2. Compact translation:沿法向或角平分方向做补偿位移。

这两步让边缘既收缩又不塌陷,保持"往中心聚拢"的视觉势能。

这里的核心思想不是"两个对象之间的距离"直接变小,而是"对象到边界的距离"变小。

更具体地说,先把图标看成落在一个理想坐标系里的点,然后对这个点做一次向内的非线性映射:

  1. 点越靠近中心,基本不动。
  2. 点越接近边界,越被压向内侧。
  3. 点进入边缘过渡带后,压缩强度逐步变大。

所以最后看到的结果是:

  1. 边缘图标彼此间隙看起来更小。
  2. 边缘图标和中心图标之间的视觉距离也会缩短。
  3. 但它不是简单地把所有图标统一往中心平移,而是"按位置不同,压缩程度不同"。

换言之,它是一个空间非线性映射:输入坐标越接近边界外侧,压缩系数越大。

空间非线性压缩映射:越靠近边界压缩越强
压缩是非线性的——中心点几乎不动,边界点被大幅折叠

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

压缩流水线:边界距离 → 压缩强度 → 单轴折叠 → 方向补偿 → 限幅提交
五步压缩流水线

在实现上,这个压缩不是全局统一缩放,而是分两层做的:

  1. apply_boundary_compaction():先把超出边界附近的坐标"折叠"一点,避免边缘点过于外扩。
  2. apply_compact_translation():再根据点所在位置和边界距离,向中心方向补一段平移,让视觉间距更紧凑。

具体的压缩流程可以拆成 5 步:

  1. 先求边界距离:对每个图标先计算 distance_from_edge,这个值越大,说明图标越靠近外圈。
  2. 再算压缩强度:把 distance_from_edge 映射成压缩系数,中心区域几乎不动,进入 fringe 后开始线性增强,超过 fringe 后继续按更强的力度向内收。
  3. 先做单轴折叠:分别对 x 与 y 调用 apply_boundary_compaction(),把超过 X_RADIUS / Y_RADIUS 的部分折叠到边界内侧,避免图标沿水平或垂直方向"撑出去"。
  4. 再做方向补偿:调用 apply_compact_translation(),根据当前点所在象限、是否位于角落、以及离边界有多远,沿法向或角平分方向补一段位移。
  5. 最后限幅并提交:把补偿量限制在 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() 解决的是"折叠以后还显得松散,怎么把视觉间距补紧"的问题。两者配合起来,边缘图标既不会显得散,也不会一股脑挤成一团。


输入算法:点击与拖拽如何共存#

状态机#

输入分为三类事件:

  1. pressed:记录起点和候选图标
  2. pressing:累计位移与速度
  3. released:判定点击或进入惯性

对应到 LVGL,就是三个事件回调:

  1. LV_EVENT_PRESSED
  2. LV_EVENT_PRESSING
  3. LV_EVENT_RELEASED

在这三个回调里只做状态更新,不直接做复杂物理积分;物理积分放在定时器统一执行。

点击判定#

点击不是只看 released 时命中,而是同时满足:

  1. 位移不超过阈值 TAP_MOVE_TOLERANCE
  2. 抬起命中图标与按下候选一致

这样可避免"拖动途中扫过图标"触发误点击。

再加一层工程保护:

  1. release 时先缓存 pending_click 数据。
  2. 在 LV_EVENT_CLICKED 里只转发一次自定义 payload。

这样可以避免输入链路中可能出现的重复点击分发。

命中优先级#

命中检测时,若多个图标都覆盖当前触点,选择 scale 更大的图标。

理由很直接:在视觉上更大的图标更靠前,用户预期也应优先命中它。

命中优先级——多图标重叠时优先命中 scale 更大(更靠近中心)的图标
图标重叠时,scale 更大的那个获得命中

物理算法:阻尼、弹簧、吸附的协同#

定时器以固定步长推进离散系统,每帧更新 offset 和 velocity。

实现上的关键是把它看作"显式迭代求解器":

  1. 根据上一帧状态预测新状态。
  2. 对新状态施加约束(限幅、吸附、超界压缩)。
  3. 速度过小则截断为 0,避免无限小抖动。

当状态变化量低于阈值时,系统进入静止状态,不再持续重绘。

X 方向#

X 方向更强调"回中":

  1. 速度先阻尼衰减。
  2. 再叠加指向中心的弹簧项。
  3. 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 为回中刚度。

X 轴阻尼弹簧系统动画,展示速度衰减与弹簧回中过程
X 轴:阻尼弹簧自动回中

Y 方向#

Y 方向更强调"内容滚动感":

  1. 拖动时允许 overscroll,并进行橡皮筋压缩。
  2. 释放后向最近 ratchet step 吸附。
  3. 超界区域使用更强的回弹项。

这里组合了两种力:

  1. 弹簧力,负责纠正偏离。
  2. 阻尼力,负责消除振荡。

并通过 dead zone 决定更偏向"软吸附"还是"强回弹"。

Y 方向之所以更复杂,是因为它同时承担:

  1. 滚动感
  2. 越界橡皮筋感
  3. 离散步进吸附感

三个目标互相冲突,所以采用分段策略而不是单一方程。


按压动画:局部形变而非全局变换#

按压反馈包含三部分:

  1. 被按图标缩小并暗化。
  2. 邻近图标向按压点轻微牵引。
  3. 松手后按不同速度做进出动画。

关键点在于:这是对局部节点施加扰动,不会破坏整体布局求解。 因此反馈明显,但不会让用户感觉"整个网格在晃"。

在实现上,这一段并不复杂,本质是"按压进度"驱动的插值更新。

按下后为什么会"变色 + 变小"#

按下命中图标后,组件会记录 pressed_icon_index,并把 press_anim_target 设为 1。定时器每一帧把 press_anim_progress 逐步逼近目标值,因此你会看到一个平滑的按下动画,而不是瞬间切换。

当某个图标是当前按压对象时,会做两件事:

  1. 缩小:用插值把当前 scale 从 1.0 过渡到 PRESS_SCALE,再写回对象尺寸。
  2. 变暗:按同一份进度计算暗化强度,分别作用到气泡背景色和图标图片层的 image recolor 不透明度。

所以视觉上就是"按下去会微微塌下去",同时颜色变深一点,手感会更像实体按钮。

周围对象是怎么"靠过去"的#

被按图标先算出中心点,其他图标再根据自己到这个中心点的距离决定是否参与牵引:

  1. 距离超过影响半径,不移动。
  2. 距离越近,牵引系数越大。
  3. 位移方向是"当前图标指向按压中心"的单位向量。
  4. 最终位移还会乘上按压进度,保证按下和松手阶段都平滑。

这就是为什么你看到的是局部区域轻微收拢,而不是整屏一起移动。

松手后如何恢复#

松手或判定为拖拽后,会把 press_anim_target 设回 0。定时器继续插值:按压图标的缩放和暗化逐步消失,邻近图标的牵引位移也同步回到 0。整个回弹过程不需要单独写一套反向动画,只要目标值回 0 即可自然恢复。

对应到 LVGL,最终落地仍然是几类稳定的属性更新:

  1. lv_obj_set_size() / lv_obj_set_pos()
  2. lv_obj_set_style_bg_color()
  3. image recolor + 不透明度(lv_obj_set_style_image_recolor_opa())

即使动画看起来复杂,底层依然是同一套插值驱动的几何和样式刷新。


性能思想:只在需要时刷新#

算法再好,如果每帧无差别重绘,也会浪费性能。

因此实现采用脏标记策略:

  1. 状态变化时 mark_refresh。
  2. refresh_if_needed 才执行对象更新。

同时在图标可见性计算中先做快速裁剪,再做昂贵计算。这让组件在静止状态接近零更新成本,在交互状态保持稳定帧率。

建议把性能策略分成两层:

  1. 状态层:changed 与 mark_refresh。
  2. 图标层:先 quick culling,再做精确判定。

这样可以把"每帧全量求解"压缩为"必要图标求解 + 必要帧提呈"。


设计与实现的权衡解释#

fx 定点数的使用#

实际算法中为了避免浮点数运算,使用了定点数(fx)进行计算,最终提呈给 LVGL 的坐标和尺寸需要转换回整数。上述描述中为了表达清楚,直接用了"坐标"、"半宽"等术语,但实际代码里是通过定点数计算后再转换成整数像素值的。

为什么自己维护一个定时器?#

不使用 LVGL 内置动画系统,而是自维护一个定时器,原因如下:

  1. 强耦合联动太多。 按压缩放、邻居牵引、边缘压缩、惯性回弹、吸附都在互相影响。如果用 LVGL 自带动画,通常要给很多对象分别挂动画,彼此同步和中断会很复杂。

  2. 交互要可打断、可即时接管。 手指一按下/拖动/松手,状态要立刻切换。统一定时器里可以直接改 offset/velocity/press_anim_target,下一帧立刻生效,不用管理一堆动画的启动、停止、重定向。

  3. 物理和动画是同一个"时间轴"。 惯性、弹簧、吸附、按压进度都在同一 tick 里推进。这样每帧只有一个真值来源,行为更可控,也更容易调参。

  4. 定点数与可预测性。 本组件大量用 fixed-point 计算。自己推进能保证数值路径一致,避免不同动画通道带来的节奏偏差。

  5. 性能和维护成本。 几十上百个气泡同时参与时,用统一刷新(含裁剪)通常更稳。否则每个对象各自动画,调度和状态管理成本会明显上升。


参考文章#

Deconstructing the Iconic Apple Watch Bubble UI

关于作者

Sab1e
Sab1e

社区贡献者

嵌入式开发者与 LVGL 社区贡献者,专注于嵌入式 UI 系统。ElenixOS 的创建者——一款基于 LVGL 和 JerryScript 的智能手表操作系统。

认识博客背后的作者们

了解那些分享 LVGL 知识的优秀作者们

查看作者

订阅我们的通讯 不错过任何关于 LVGL 的新闻。我们每月最多发送 2 封邮件。

LVGL

LVGL 是最受欢迎的免费开源嵌入式图形库,支持任何 MCU、MPU 和显示类型,助您构建精美的用户界面。

我们还提供 UI 设计、实现和咨询等服务。

© 2026 LVGL。保留所有权利。
YouTubeGitHubLinkedIn