画是死的,动起来才有生命——给每一帧注入插值
核心方法论:赋予画面生命,研究插值曲线如同研究人体比例
「一幅画的价值在于'呼吸感'。LVGL 的动画不是炫技,而是把'状态变化'翻译成人眼能读懂的过渡——就像我解剖人体是为了画准一个手势,你研究缓动曲线,是为了让一个按钮的弹跳、一个面板的淡入都有生命。别让 UI 蹦跳,让它生长。」
动画的通用定义只有一句:在给定时间内,把一个数值从起点插值到终点。LVGL 把"这一次插值"完整描述成一个 lv_anim_t 结构体:动画谁(var)、每帧怎么执行(exec_cb)、从哪到哪(values)、花多久(duration)、沿什么曲线(path_cb)、要不要循环(repeat_count)。把这些字段填好后交给 lv_anim_start(&a),库就接管了后续每一帧。类型、全部 setter 与内置路径函数都声明在 include/lvgl/core/lv_anim.h,实现与全局刷新定时器在 src/misc/lv_anim.c——动画随屏幕刷新逐帧推进,因此它的顺滑度天然跟刷新率走。
使用套路是固定的五步:lv_anim_t a; lv_anim_init(&a); 先初始化(把结构体清零并填默认值)→ lv_anim_set_var(&a, obj); 指定被动画的对象 → lv_anim_set_values(&a, start, end); 与 lv_anim_set_duration(&a, ms); 定数值范围与时长 → lv_anim_set_exec_cb(&a, anim_x_cb); 绑定执行函数 → lv_anim_start(&a); 启动。exec_cb 的签名是 void (*)(void * var, int32_t value),LVGL 官方注释明确写着它"与 lv_xxx_set_yyy(obj, value) 这类 setter 兼容"——所以 lv_obj_set_x、lv_obj_set_width 这类函数都能直接拿来当执行回调。
/* 五步套路:让标签在 500ms 内从当前位置滑到 x=100(参考 lv_example_anim_1.c) */
static void anim_x_cb(void * var, int32_t v)
{
lv_obj_set_x((lv_obj_t *) var, v); /* LVGL 每帧调用,v 是当前插值 */
}
lv_anim_t a;
lv_anim_init(&a); /* ① 初始化 */
lv_anim_set_var(&a, label); /* ② 动画谁 */
lv_anim_set_values(&a, lv_obj_get_x(label), 100); /* ③ 从哪到哪 */
lv_anim_set_duration(&a, 500); /* ④ 多久 */
lv_anim_set_exec_cb(&a, anim_x_cb); /* ⑤ 每帧执行 */
lv_anim_set_path_cb(&a, lv_anim_path_overshoot);
lv_anim_start(&a); /* 启动:栈上 a 之后可丢弃 */
/* exec_cb 的三种写法 */
/* 1) 直接复用 LVGL setter(签名兼容) */
lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t) lv_obj_set_x); /* 水平移动 */
/* 2) 一个值驱动两个属性时,自己包一层 */
static void anim_size_cb(void * var, int32_t v)
{
lv_obj_set_size((lv_obj_t *) var, v, v); /* 宽高同步变化 */
}
| 核心方法 | 作用 |
|---|---|
lv_anim_init() | 初始化结构体并填默认值 |
lv_anim_set_var() | 指定被动画的对象 / 变量 |
lv_anim_set_values() | 起始值与结束值 |
lv_anim_set_duration() | 动画时长(毫秒) |
lv_anim_set_exec_cb() | 每帧执行函数 |
lv_anim_set_path_cb() | 缓动曲线 |
lv_anim_start() | 启动动画,返回库内动画实例 |
lv_anim_start() 返回的是库内部管理的动画实例,栈上那个 a 启动后就不再需要。同一块 lv_anim_t 可以反复复用——但每次复用前记得重新 lv_anim_init(),否则上一轮的字段会残留成"没说的秘密"。
什么都不指定时,动画是匀速的——即内置路径 lv_anim_path_linear。但真实世界很少匀速:起步的汽车、落地的皮球,都有自己的"加速度"。LVGL 内置 8 条路径函数,通过 lv_anim_set_path_cb(&a, path_cb) 选择:lv_anim_path_linear(匀速)、lv_anim_path_ease_in(慢起快落)、lv_anim_path_ease_out(快起慢停)、lv_anim_path_ease_in_out(两头慢中间快)、lv_anim_path_overshoot(终点过冲后回弹)、lv_anim_path_bounce(三次弹跳)、lv_anim_path_step(结束瞬间跳到终值)、lv_anim_path_custom_bezier3(自定义贝塞尔)。
需要更精细的"性格"时,请贝塞尔出场。lv_anim_path_custom_bezier3 配合 lv_anim_set_bezier3_param(&a, x1, y1, x2, y2) 自定义四个控制点(值域 0..1024,examples/anim/lv_example_anim_3.c 甚至做了一个带实时曲线预览的编辑器);更省事的是直接用现成宏 LV_ANIM_SET_EASE_OUT_BACK(&a)、LV_ANIM_SET_EASE_IN_OUT_BACK(&a)——这些宏把 easings.net 的贝塞尔参数写进 a->parameter.bezier3,再配 custom_bezier3 路径使用。examples/anim/lv_example_anim_1.c 正是用 lv_anim_path_overshoot 让标签"冲过头再弹回来",一下子就显得有弹性。
/* 8 条内置路径:换一条曲线,节奏就换一种性格 */
lv_anim_set_path_cb(&a, lv_anim_path_linear); /* 匀速直线 */
lv_anim_set_path_cb(&a, lv_anim_path_ease_in); /* 慢起快落 */
lv_anim_set_path_cb(&a, lv_anim_path_ease_out); /* 快起慢停 */
lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); /* 两头慢、中间快 */
lv_anim_set_path_cb(&a, lv_anim_path_overshoot); /* 终点过冲再回弹 */
lv_anim_set_path_cb(&a, lv_anim_path_bounce); /* 弹跳落地 */
lv_anim_set_path_cb(&a, lv_anim_path_step); /* 结束瞬间跳变 */
lv_anim_set_path_cb(&a, lv_anim_path_custom_bezier3); /* 自定义贝塞尔 */
/* 自定义贝塞尔:四个控制点(参考 lv_example_anim_3.c,值域 0..1024) */
lv_anim_set_path_cb(&a, lv_anim_path_custom_bezier3);
lv_anim_set_bezier3_param(&a, 200, 0, 800, 1024);
/* 或用现成宏:先把路径设为 custom_bezier3,再套宏写入参数 */
lv_anim_set_path_cb(&a, lv_anim_path_custom_bezier3);
LV_ANIM_SET_EASE_OUT_BACK(&a); /* easeOutBack:冲过头再回来 */
LV_ANIM_SET_EASE_IN_OUT_BACK(&a); /* easeInOutBack:进出都带回弹 */
| 路径函数 | 效果 |
|---|---|
lv_anim_path_linear | 匀速直线 |
lv_anim_path_ease_in | 慢起快落 |
lv_anim_path_ease_out | 快起慢停 |
lv_anim_path_ease_in_out | 两头慢、中间快(S 形) |
lv_anim_path_overshoot | 终点过冲后回弹 |
lv_anim_path_bounce | 三次弹跳 |
lv_anim_path_step | 结束瞬间跳到终值 |
lv_anim_path_custom_bezier3 | 自定义贝塞尔曲线 |
选曲线要看"叙事":ease_in 适合进场(物体从静止开始加速),ease_out 适合离场与收尾(快进慢停最自然),overshoot / bounce 用于强调"存在感"。一律用 linear 会让界面显得生硬——但一律用 bounce 同样是一场灾难。
一段动画的生命周期可以精细调控:lv_anim_set_delay(&a, ms) 延迟启动;lv_anim_set_reverse_duration(&a, ms) 正向结束后自动反向播放(0 关闭);lv_anim_set_reverse_delay(&a, ms) 正反向之间的停顿;lv_anim_set_repeat_count(&a, n) 设置重复次数,传 LV_ANIM_REPEAT_INFINITE(值 0xFFFFFFFF)即无限循环;lv_anim_set_repeat_delay(&a, ms) 控制两轮之间的间隔。这里有个版本细节:v9 里旧名 lv_anim_set_reverse_time 已在头文件里标记"即将移除",一律改用 lv_anim_set_reverse_duration。
examples/anim/lv_example_anim_2.c 是"控制组合"的样板:一个红色圆形对象,"放大到 50 + 右移到 240"两段动画并行播放,ease_in_out 曲线、300ms 反向、100ms 反向延迟、500ms 轮间停顿、无限循环——而且同一个 lv_anim_t 被复用两次:先配 anim_size_cb 启动一次,再改 exec_cb 为 anim_x_cb、改 values 启动第二次。运行中还能中途干预:lv_anim_pause() 暂停、lv_anim_resume() 恢复、lv_anim_pause_for(&a, ms) 暂停指定时长后自动继续(lv_example_anim_4.c 在动画启动 200ms 后让它暂停 1000ms 再继续);lv_anim_delete(var, exec_cb) 按目标对象清理动画,lv_anim_count_running() 查询当前运行数量。
/* 参考 examples/anim/lv_example_anim_2.c:放大 + 右移并行、无限循环 */
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, obj);
lv_anim_set_values(&a, 10, 50);
lv_anim_set_duration(&a, 1000);
lv_anim_set_reverse_delay(&a, 100); /* 正向结束后停 100ms */
lv_anim_set_reverse_duration(&a, 300); /* 反向播放 300ms */
lv_anim_set_repeat_delay(&a, 500); /* 一轮结束后停 500ms */
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); /* 无限循环 */
lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out);
lv_anim_set_exec_cb(&a, anim_size_cb); /* 第一段:放大 */
lv_anim_start(&a);
lv_anim_set_exec_cb(&a, anim_x_cb); /* 复用同一配置,改 exec_cb 再启动 */
lv_anim_set_values(&a, 10, 240);
lv_anim_start(&a);
/* 运行中干预:先记住库内的动画实例(参考 lv_example_anim_4.c) */
lv_anim_t * anim = lv_anim_start(&a);
lv_anim_pause_for(anim, 1000); /* 暂停 1000ms 后自动恢复 */
lv_anim_pause(anim); /* 暂停 */
lv_anim_resume(anim); /* 恢复 */
/* 清理与查询 */
lv_anim_delete(obj, anim_x_cb); /* 删除 obj 上以 anim_x_cb 执行的所有动画 */
lv_anim_delete(obj, NULL); /* 删除 obj 上的全部动画 */
uint16_t n = lv_anim_count_running(); /* 当前运行中的动画数量 */
| 播放控制 API | 作用 |
|---|---|
lv_anim_set_delay() | 启动前延迟 |
lv_anim_set_reverse_duration() | 反向播放时长(0 关闭) |
lv_anim_set_repeat_count() | 重复次数,LV_ANIM_REPEAT_INFINITE 无限 |
lv_anim_pause() / lv_anim_resume() | 暂停 / 恢复 |
lv_anim_pause_for() | 暂停指定毫秒后自动恢复 |
lv_anim_delete() | 按对象 / 执行函数删除动画 |
lv_anim_count_running() | 运行中动画数量 |
暂停与恢复操作的对象是 lv_anim_start() 返回的库内实例,不是栈上那份"配方"——直接 lv_anim_pause(&a) 是无效的。这是动画 API 里最容易被忽略的一个"真相"。
单个 lv_anim_t 只描述一段插值,而复杂的入场动画往往是"多个对象、错峰开始"。lv_anim_timeline_t 把多段动画编成一条时间轴:lv_anim_timeline_create() 创建;lv_anim_timeline_add(at, start_time, &a) 把一段动画按"相对时间轴的毫秒偏移"挂进去;lv_anim_timeline_start(at) 让整条时间轴按编排播放。实现在 src/misc/lv_anim_timeline.c,头文件是 include/lvgl/core/lv_anim_timeline.h。
examples/anim/lv_example_anim_timeline_1.c 是完整范例:3 个对象的宽高动画分别从 0ms / 200ms / 400ms 错峰开始,宽度用 lv_anim_path_overshoot、高度用 lv_anim_path_ease_out,外加一条 700ms 的进度条动画。时间轴还内置"播放控制":lv_anim_timeline_pause(at) 暂停、lv_anim_timeline_set_reverse(at, true) 反向播放、lv_anim_timeline_set_progress(at, p) 直接跳到进度 p(取值 0..LV_ANIM_TIMELINE_PROGRESS_MAX=0xFFFF)——把 set_progress 接在滑块上,就能做出"拖动预览"的效果。
/* 参考 examples/anim/lv_example_anim_timeline_1.c:三段动画错峰入场 */
static void set_width(lv_anim_t * var, int32_t v)
{
lv_obj_set_width((lv_obj_t *) var->var, v);
}
lv_anim_timeline_t * at = lv_anim_timeline_create();
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, obj1);
lv_anim_set_values(&a, 0, 90);
lv_anim_set_custom_exec_cb(&a, set_width);
lv_anim_set_path_cb(&a, lv_anim_path_overshoot);
lv_anim_set_duration(&a, 300);
lv_anim_timeline_add(at, 0, &a); /* 第 0ms 开始 */
lv_anim_t b;
lv_anim_init(&b);
lv_anim_set_var(&b, obj2); /* 同样的配置,换对象 */
lv_anim_set_values(&b, 0, 90);
lv_anim_set_custom_exec_cb(&b, set_width);
lv_anim_set_path_cb(&b, lv_anim_path_overshoot);
lv_anim_set_duration(&b, 300);
lv_anim_timeline_add(at, 200, &b); /* 第 200ms 开始 */
lv_anim_timeline_start(at); /* 整条时间轴播放 */
/* 时间轴的播放控制:暂停 / 反向 / 拖进度 */
lv_anim_timeline_pause(at);
lv_anim_timeline_set_reverse(at, true); /* 反向播放 */
/* 配合滑块做"拖动预览":进度 0..LV_ANIM_TIMELINE_PROGRESS_MAX(0xFFFF) */
int32_t progress = lv_slider_get_value(slider);
lv_anim_timeline_set_progress(at, (uint16_t) progress);
| 时间轴 API | 作用 |
|---|---|
lv_anim_timeline_create() | 创建时间轴 |
lv_anim_timeline_add(at, start_time, &a) | 把一段动画挂进时间轴 |
lv_anim_timeline_start() | 整体播放 |
lv_anim_timeline_pause() | 暂停 |
lv_anim_timeline_set_reverse() | 反向播放 |
lv_anim_timeline_set_progress() | 跳转播放进度 |
时间轴的 start_time 只决定"何时开始",每段动画自己的曲线与时长仍然独立配置。同一支时间轴里完全可以混合 overshoot 与 ease_out——"起落错落"的层次感,正来自每段各自的呼吸。
除了主动播放,LVGL 还能让"样式值的变化"自动动画化,这就是过渡(transition)。三步走:先用 lv_style_prop_t 数组声明要过渡的属性(如 { LV_STYLE_BG_COLOR, LV_STYLE_BG_OPA, 0 },末位必须是 0 作哨兵);再用 lv_style_transition_dsc_init(&tr, props, path_cb, time, delay, user_data) 初始化描述符;最后 lv_obj_set_style_transition(obj, &tr, selector) 把它挂到对象上。此后该对象相关样式一变,变化就被自动补帧成平滑渐变——按钮按下时背景从灰渐变到高亮,而不是"啪"地跳过去。描述符与初始化函数定义在 include/lvgl/core/lv_style.h,属性 LV_STYLE_TRANSITION 及配套 setter 由 lv_style_gen.h 生成。
过渡与动画的分工要分清:lv_anim 是"你主动播放一段动画";过渡是"样式值变化时自动动画化",把"结果"变成"过程"。曲线的选择参数同样来自 lv_anim_path_* 系列。一个值得注意的细节是:本版本里坐标 X / Y 也是样式属性——lv_obj_set_x() 底层写的就是 LV_STYLE_X,所以位置变化同样能被过渡动画化。想关掉某次操作自带的补帧,用 LV_ANIM_OFF(类型 lv_anim_enable_t,与 LV_ANIM_ON 相对),例如 lv_slider_set_value(slider, 50, LV_ANIM_OFF) 就是"直接跳到 50,不做过渡"。
/* 让按钮按下时背景色平滑渐变,而不是瞬间跳变 */
static const lv_style_prop_t trans_props[] = { LV_STYLE_BG_COLOR, LV_STYLE_BG_OPA, 0 };
static lv_style_transition_dsc_t trans;
lv_style_transition_dsc_init(&trans, trans_props, lv_anim_path_ease_out, 200, 0, NULL);
lv_obj_set_style_transition(btn, &trans, 0); /* 挂到对象上(selector 0) */
/* 之后改变背景色:变化会被自动补帧 */
lv_obj_set_style_bg_color(btn, lv_color_hex(0x00ff00), LV_STATE_PRESSED);
/* LV_ANIM_OFF / LV_ANIM_ON:lv_anim_enable_t 类型,控制"是否补帧" */
lv_slider_set_value(slider, 50, LV_ANIM_OFF); /* 直接跳到 50 */
lv_slider_set_value(slider, 50, LV_ANIM_ON); /* 带过渡效果 */
/* 关键认知:过渡只作用于"样式属性";本版本连坐标都是样式属性 */
lv_obj_set_x(obj, 100); /* 底层写 LV_STYLE_X,位置也能被过渡动画化 */
| 维度 | lv_anim | 样式过渡 |
|---|---|---|
| 触发方式 | 主动 lv_anim_start() | 样式值变化时自动 |
| 作用对象 | 任意 setter / 变量 | 样式属性 |
| 曲线 | lv_anim_set_path_cb() | lv_style_transition_dsc_init() 的 path 参数 |
| 典型用途 | 位置、入场出场、循环 | 状态切换(按下 / 聚焦)的平滑 |
过渡描述符要挂在"属性变化发生"的那个对象上,想让所有状态都生效就用 selector 0。属性数组必须以 0 结尾——漏掉哨兵,库会读到数组边界之外,行为未定义。这跟上一章 Grid 轨道数组的 LV_GRID_TEMPLATE_LAST 是同一个设计思路。
创建一个标签,初始放在屏幕外(x = -200),启动后用它滑到 x = 50。要求:时长 600ms,曲线 lv_anim_path_ease_out。写出完整代码。
回忆五步套路:lv_anim_init → lv_anim_set_var → lv_anim_set_values(-200, 50) 与 lv_anim_set_duration(600) → lv_anim_set_exec_cb(anim_x_cb) → lv_anim_set_path_cb(lv_anim_path_ease_out) → lv_anim_start。
static void anim_x_cb(void * var, int32_t v) { lv_obj_set_x((lv_obj_t *)var, v); };然后 lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, label); lv_anim_set_values(&a, -200, 50); lv_anim_set_duration(&a, 600); lv_anim_set_exec_cb(&a, anim_x_cb); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a);。ease_out 让滑动"快进慢停",收尾最自然。
创建一个按钮,让它从屏幕上方(y = -80)落到 y = 150。要求:曲线 lv_anim_path_bounce,时长 800ms,落定后反向弹回 lv_anim_set_reverse_duration 400ms,并重复 3 次。写出核心代码。
lv_anim_set_values(&a, -80, 150);exec_cb 里调 lv_obj_set_y;加 lv_anim_set_path_cb(&a, lv_anim_path_bounce)、lv_anim_set_reverse_duration(&a, 400)、lv_anim_set_repeat_count(&a, 3)。
lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, btn); lv_anim_set_values(&a, -80, 150); lv_anim_set_duration(&a, 800); lv_anim_set_exec_cb(&a, anim_y_cb); lv_anim_set_path_cb(&a, lv_anim_path_bounce); lv_anim_set_reverse_duration(&a, 400); lv_anim_set_repeat_count(&a, 3); lv_anim_start(&a);。注意反向播放用的还是正向后半程的曲线,落定-弹回的整体节奏由 bounce + reverse 共同决定。
用 lv_anim_timeline 做三张卡片的错峰入场:三个 lv_obj 依次从宽度 0 长到 90px,起始偏移分别为 0ms、200ms、400ms,每段时长 300ms、曲线 lv_anim_path_overshoot。写出时间轴编排的核心代码。
lv_anim_timeline_create() → 为每个对象配置一段动画(lv_anim_set_values(0, 90)、lv_anim_set_custom_exec_cb(set_width)、300ms、overshoot)→ lv_anim_timeline_add(at, offset, &a) → lv_anim_timeline_start(at)。
复用 9.4 的 set_width(lv_anim_t * var, int32_t v)。为 obj1/obj2/obj3 各配置一段几乎相同的动画,分别 lv_anim_timeline_add(at, 0, &a)、lv_anim_timeline_add(at, 200, &b)、lv_anim_timeline_add(at, 400, &c),最后 lv_anim_timeline_start(at)。偏移只决定"何时开始",三段各自的曲线与时长独立生效——这就是时间轴相对"一把梭合到一起"的优势。
做一个"开关控制的面板":一个开关(LV_STATE_CHECKED)切换一个面板的左右位置;同时面板本身在按下状态有背景色过渡。要求:(1) 开关的 LV_EVENT_VALUE_CHANGED 回调里启动动画,checked 时面板用 lv_anim_path_overshoot 滑到 x=100、否则用 lv_anim_path_ease_in 滑回屏幕外;(2) 面板挂一个背景色过渡,按下时平滑变色。写出关键代码。
参考 lv_example_anim_1.c:回调里用 lv_obj_has_state(sw, LV_STATE_CHECKED) 分支,再 lv_anim_start(&a);过渡用 9.5 的三步:属性数组 → lv_style_transition_dsc_init → lv_obj_set_style_transition。
回调:if(lv_obj_has_state(sw, LV_STATE_CHECKED)) { lv_anim_init(&a); lv_anim_set_var(&a, panel); lv_anim_set_values(&a, lv_obj_get_x(panel), 100); lv_anim_set_duration(&a, 500); lv_anim_set_exec_cb(&a, anim_x_cb); lv_anim_set_path_cb(&a, lv_anim_path_overshoot); lv_anim_start(&a); } else { /* ease_in 滑回屏幕外 */ }。过渡:static const lv_style_prop_t props[] = { LV_STYLE_BG_COLOR, 0 }; static lv_style_transition_dsc_t tr; lv_style_transition_dsc_init(&tr, props, lv_anim_path_ease_out, 200, 0, NULL); lv_obj_set_style_transition(panel, &tr, 0);,再在 LV_STATE_PRESSED 下改背景色。两个机制各司其职:位置用 lv_anim 主动播放,状态色用过渡自动平滑。