这次我们来看一个 LVGL 开发里经常被问到的问题:环形无限循环滚动效果。无论是设置界面里的横向菜单,还是智能家居屏幕上的轮播卡片,很多时候我们并不希望列表滚到最后一个就停住,而是希望它能在视觉上无缝地循环回开头。LVGL 自带的滚动逻辑是有边界限制的,swipe 到头部或尾部会触发边界回弹,直接拿来做无限循环会非常别扭。要做出真正的环形效果,通常需要自己处理动画回调、坐标复位和索引计算这几件事。
这篇文章不会绕太多概念,重点讲怎么在 LVGL v8.x/v9.x 上实现横向环形无限循环,并且给出可直接运行的示例代码。核心思路很简单:用一个容器保存一组相同宽度的子项,在末尾多放一个首项的副本,再用取模运算把不断增长的滚动坐标映射到固定周期内,视觉上就不会出现跳变。顺着这个思路,还可以扩展出纵向滚动、自动轮播、触摸拖动和批量列表生成。
如果你正在做 STM32、ESP32 或者 PC 模拟器上的 LVGL 项目,这篇文章可以直接收藏。读完你能知道:环形循环的核心逻辑是什么、怎么写循环动画、如何处理触摸交互、怎么计算当前索引、在 MCU 上运行时怎么观察内存和帧率,以及常见的坑怎么排查。
1. LVGL 环形无限循环滚动核心能力速览
| 能力项 | 说明 |
|---|---|
| 效果类型 | 横向/纵向无限循环滚动、自动轮播、触摸拖动 |
| 实现方案 | 容器 + 首项副本 + 取模坐标映射 + lv_anim/lv_timer |
| 适用版本 | LVGL v8.x 与 v9.x,注意部分 API 差异 |
| 输入方式 | 触摸、鼠标、滚轮、编码器,取决于屏和驱动 |
| 内存开销 | 低,主要占用来自 item 数量和屏幕重绘区域 |
| 硬件门槛 | 低,STM32、ESP32 等 MCU 可运行 |
| 可选能力 | 当前索引回调、批量动态创建列表项 |
| 启动方式 | 嵌入式项目编译烧录,或 PC 模拟器运行 |
| 接口能力 | 无 Web API,可把滚动模块封装成可复用的 C 接口 |
| 适合场景 | 车载菜单、仪表盘、智能家居面板、彩屏轮播控件 |
先说结论:这个效果不需要额外第三方库,LVGL 自身的动画系统、对象坐标系和事件机制已经足够。代码量也不大,核心逻辑集中在取模运算和一个动画回调里,很好维护。真正影响工作量的是你如何处理触摸手势和索引回调,这两点决定了它能不能从“演示效果”变成“可用控件”。
2. LVGL 环形无限滚动效果的设计思路
首先解释为什么原生滚动不够用。LVGL 里lv_obj有scroll特性,但它的滚动范围受父容器大小限制,滚动到边界后还会触发回弹效果。你要实现无限循环,本质上是在和一个“有界”的滚动模型做对抗。与其修改原生滚动逻辑,不如用更基础的坐标系操作来绕过它:把多个子项放进一个容器,容器在父视图里移动,只要移动范围在一个周期内循环,视觉上就是无限滚动。
实现时有两个核心问题要解决:
第一是视觉上的无缝衔接。如果容器只在 0 到-(N * ITEM_W)之间往复,用户一定会看到“滚到底再弹回来”的过程,这不是环形效果。正确做法是在容器末尾多放一个“首项副本”,让最后一个可视位置和第一个可视位置内容一致,这样当容器坐标跳回起点时,画面看起来没有变化。副本数量不需要额外再加,常见做法是N + 1个对象,最后一个与第一个内容相同。
第二是坐标不能无限增大。如果你用一个不断累加递减的变量来记录滚动位置,几十秒后int32_t会溢出,而且一旦需要回绕,还要额外写很多边界判断。更稳定的做法是用取模运算:让一个动画值v从 0 持续往下走,实际显示的坐标永远是v % TOTAL_W,其中TOTAL_W = ITEM_W * ITEM_NUM。这样容器坐标始终被限制在0 ~ -TOTAL_W之间,既不会溢出,也不需要手动“重置”。
从实现方式上看,常见的有三种:
lv_anim线性动画加取模坐标映射,适合自动轮播和无缝循环。- 触摸事件驱动坐标更新,按下拖动时直接改容器坐标,适合手势交互。
lv_timer定时推进,逻辑最简单,适合在资源有限的 MCU 上做演示。
本文主推第一种,并在此基础上扩展触摸手势。因为lv_anim自带帧回调、动画路径和重复次数,能减少很多定时器代码。
3. LVGL 开发环境准备与前置条件
在写具体代码之前,先把环境确认好。环形滚动本身不挑平台,LVGL 能跑起来的地方都可以做,但推荐按下面两种方式准备:
3.1 PC 模拟器环境
先用 PC 模拟器跑通效果,调试效率最高。官方提供了基于 SDL 和 Eclipse 的模拟器工程,也可以在 VS Code 里配置 CMake 工程,把 LVGL 源码包进来,再接入一个模拟器驱动。模拟器不需要真实屏幕和触摸驱动,鼠标移动可以模拟触摸滑动,非常适合验证滚动逻辑、动画回调、索引计算这些核心功能。
建议固定使用一个版本,比如 v8.3 或 v9.x。不要混合使用不同版本的头文件,LVGL v8 到 v9 之间 API 有一些变化,比如部分lv_obj函数名、事件获取方式、动画初始化方式都存在差异。本文代码以 v8.x 的写法为主,v9 用户编译时结合官方迁移说明调整即可。
3.2 MCU 平台环境
如果直接做 STM32 或 ESP32 移植,需要准备以下部分:
- LVGL 源码,建议以子模块或固定版本目录方式管理。
- 显示屏驱动,确认分辨率、颜色深度和刷新接口。
- 触控驱动,返回坐标点给
lv_indev。 - 一个定时器或者时基,LVGL 的
lv_tick_inc()需要定期调用。 - 内存堆配置,确认
LV_MEM_CUSTOM或者lv_mem的堆大小足够。
环境准备的重点不是版本越新越好,而是“锁定版本、保持稳定”。环形滚动这种功能对帧率有一定要求,如果刷新链路或者触控链路不稳定,后面排查问题时会分不清是代码问题还是驱动问题。
3.3 通用检查清单
| 检查项 | 说明 |
|---|---|
| LVGL 版本 | 固定 v8.x 或 v9.x,不要混用 |
| 屏幕分辨率 | 决定视口大小和裁剪区域 |
| 颜色深度 | 常见 RGB565、RGB888,影响内存和刷新速度 |
| 内存配置 | 确认堆大小足够创建页面对象 |
| 字体 | 包含需要的数字、英文、中文,避免显示方块 |
| 输入设备 | 触摸、鼠标、滚轮任选其一 |
| 刷新缓冲 | 单缓冲省内存,双缓冲减少撕裂 |
4. 横向环形无限循环滚动核心代码实现
下面给出一段完整的 LVGL 横向环形无限循环示例代码。先定义一个固定宽度和高度的卡片区域,然后创建N + 1个子项,其中最后一项复制第一项的内容,用于视觉无缝衔接。动画值v从 0 一直递减到-(TOTAL_W * 2),执行回调时用v % TOTAL_W映射到合法坐标范围,动画设置无限重复,这样容器就会一直循环滚动。
#include "lvgl.h" #define ITEM_W 100 #define ITEM_H 80 #define ITEM_NUM 6 #define TOTAL_W (ITEM_W * ITEM_NUM) /* 600 */ static void circular_scroll_cb(void *var, int32_t v) { lv_obj_t *wrap = (lv_obj_t *)var; /* v 持续递减,取模后映射到 0 ~ -TOTAL_W */ lv_obj_set_x(wrap, v % TOTAL_W); } static void start_loop_anim(lv_obj_t *wrap) { lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, wrap); lv_anim_set_exec_cb(&a, circular_scroll_cb); lv_anim_set_time(&a, 12000); lv_anim_set_values(&a, 0, -(TOTAL_W * 2)); lv_anim_set_path_cb(&a, lv_anim_path_linear); lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); lv_anim_start(&a); } void lv_demo_circular_scroll(void) { lv_obj_t *parent = lv_obj_create(lv_scr_act()); lv_obj_set_size(parent, 320, ITEM_H); lv_obj_align(parent, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_border_width(parent, 1, 0); lv_obj_set_style_clip_corner(parent, true, 0); lv_obj_t *wrap = lv_obj_create(parent); lv_obj_remove_style_all(wrap); lv_obj_set_size(wrap, ITEM_W * (ITEM_NUM + 1), ITEM_H); lv_obj_set_pos(wrap, 0, 0); for (int i = 0; i < ITEM_NUM + 1; i++) { int idx = i % ITEM_NUM; lv_obj_t *card = lv_obj_create(wrap); lv_obj_set_size(card, ITEM_W - 4, ITEM_H - 4); lv_obj_set_pos(card, i * ITEM_W + 2, 2); lv_obj_set_style_radius(card, 8, 0); lv_obj_set_style_bg_color(card, lv_palette_main(idx), 0); lv_obj_t *label = lv_label_create(card); lv_label_set_text_fmt(label, "Item %d", idx + 1); lv_obj_center(label); } start_loop_anim(wrap); }代码里需要注意几个点:
wrap的宽度是ITEM_W * (ITEM_NUM + 1),比实际循环周期多了一个 item,这是为了放首项副本。TOTAL_W是真正的循环周期ITEM_W * ITEM_NUM,取模时用它。lv_obj_set_style_clip_corner(parent, true, 0)很重要,它让父容器裁剪掉超出边界的子项,否则移出视口的内容会直接显示出来,破坏视觉效果。- 把
lv_anim_set_time和lv_anim_set_values配合起来看,可以实现匀速移动。上面设置 12 秒内移动-1200px,会循环两轮,每一轮 6 秒,每个 item 停留约 1 秒,速度比较适中。
这段代码在 PC 模拟器上可以直接调用lv_demo_circular_scroll(),编译运行后就能看到卡片循环向左滚动。验证方法很简单:看最后一个卡片划出左边后,第一张卡片是不是从右侧无缝进入,中间没有空白也没有跳变。
5. 触摸滑动交互扩展
自动轮播只是第一步,实际项目中用户更希望“能拖动”。触摸拖动和自动轮播的最大区别是:自动轮播由动画值驱动,触摸拖动则由触摸坐标差驱动容器位置。两者可以共存,但需要在事件回调里切换状态。
实现思路如下:
- 按下时删除正在运行的循环动画,记录按下时的容器坐标和触摸点。
- 拖动过程中,计算当前触摸点和上次触摸点的差值,把差值叠加到容器坐标上,并用取模回绕保持范围。
- 释放时重新启动自动循环动画,或者根据速度做惯性归位。
static lv_obj_t *g_wrap; static lv_point_t g_last_point; static lv_coord_t g_last_x; static void wrap_event_cb(lv_event_t *e) { lv_event_code_t code = lv_event_get_code(e); lv_obj_t *wrap = lv_event_get_target(e); lv_indev_t *indev = lv_indev_get_act(); lv_point_t p; if (code == LV_EVENT_PRESSED) { lv_indev_get_point(indev, &p); g_last_point = p; g_last_x = lv_obj_get_x(wrap); lv_anim_del(wrap, circular_scroll_cb); } else if (code == LV_EVENT_PRESSING) { lv_indev_get_point(indev, &p); int32_t dx = p.x - g_last_point.x; g_last_point = p; int32_t pos = g_last_x + dx; if (pos >= 0) pos -= TOTAL_W; if (pos <= -TOTAL_W) pos += TOTAL_W; g_last_x = pos; lv_obj_set_x(wrap, pos); } else if (code == LV_EVENT_RELEASED) { start_loop_anim(wrap); } }然后把事件回调挂到容器上:
lv_obj_add_event_cb(wrap, wrap_event_cb, LV_EVENT_ALL, NULL);这里有一个细节:LVGL 的事件会向父级冒泡,所以触摸卡片时wrap也能收到按下、拖动、释放事件。如果你发现事件收不到,优先检查是否有其他对象拦截了指针事件,或者把事件挂到更上层的父容器上再试。
如果拖动后不想立刻恢复自动滚动,可以在LV_EVENT_RELEASED里改成“回弹到最近的一个整数 item 位置”,类似轮播卡片停在整张卡片的中心。做法是释放时取index = (-x + ITEM_W / 2) / ITEM_W,然后启动一个短动画,把x平移到-index * ITEM_W,这个短动画可以把lv_anim_set_time设置为 200ms,路径用lv_anim_path_ease_out,手感和原生的惯性吸附比较接近。
6. 当前索引计算与事件回调接口
无线循环滚动之后,业务层真正关心的是“当前显示的是哪一项”。这里有一个很简洁的计算方式:
int get_current_index(int32_t x) { int idx = (-x) / ITEM_W; return idx % ITEM_NUM; }因为x一直被限制在0 ~ -TOTAL_W之间,-x的范围是0 ~ TOTAL_W,除以ITEM_W得到 0 到ITEM_NUM之间的数值,再取模就映射回 0 到ITEM_NUM - 1。即使动画值因为取模出现跳变,这个索引计算结果也是连续的,不会出现突然跳到错误项的情况。
在实际项目中,不建议直接用全局变量传递索引,而是封装成一个环形滚动组件的回调。例如定义:
typedef void (*circular_scroll_index_cb_t)(int index); static circular_scroll_index_cb_t s_index_cb;在滚动回调或者释放回调里调用s_index_cb(current_index),这样业务层只需要注册一个回调,就能实时拿到当前项。对于车载菜单、仪表盘、家居面板这类场景,这比在多个文件里查找坐标计算逻辑要干净得多。
批量创建列表项也可以在这个阶段一起考虑。如果是固定数量且数量不大,直接创建N + 1个对象即可;如果是几百上千条数据,务必改成对象池方式,只显示视口内可见的少量对象,更新数据时只改文本和图标,不要再创建新对象,否则内存和初始化耗时都会失控。
7. 纵向环形滚动与二维网格扩展
横向滚动改造成纵向滚动非常容易。把lv_obj_set_x(wrap, ...)改成lv_obj_set_y(wrap, ...),把ITEM_W换成ITEM_H,TOTAL_W换成TOTAL_H,触摸事件里取dy而不是dx,其余逻辑完全不变。纵向环形滚动在设置列表、频道选择、数值选择等场景下很常见。
如果把问题扩展到二维网格的环形滚动,比如一个 4 列 2 行的卡片矩阵,希望在横向和纵向都能无限循环,难度会明显上升。你需要同时维护 x 和 y 两个方向的周期,还要在容器里复制多份首行和首列的子项,坐标取模也要变成二维取模。这个方案不是不能做,但如果在低主频 MCU 上跑,重绘面积和对象数量会成倍增长,不建议在性能有限的平台上硬上。更稳妥的做法是缩小视口裁剪区域,或者干脆用单方向环形滚动,另一方向使用普通分页。
另外,如果只需要文字列表的环形选择,LVGL 自带的lv_roller控件本身就能实现循环,不需要自己写滚动