ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

LVGL环形无限循环滚动实现:从原理到代码实战

LVGL环形无限循环滚动实现:从原理到代码实战 在 LVGL 界面开发中环形无限循环滚动是一个出现频率很高、但代码实现思路并不统一的需求。很多同学在做嵌入式 GUI、智能硬件菜单、设置列表或图标选择器时都会遇到“列表只能滚到底不能从末尾回到开头”的尴尬。如果直接用简单粗暴的lv_obj_scroll_to_y(obj, 0, LV_ANIM_ON)回跳又会出现生硬的跳变体验非常差。这篇文章会围绕 LVGL 中如何实现环形无限循环滚动效果展开从基础滚动机制讲起逐步拆解两种常用实现思路并给出可以实际运行的完整代码示例。如果你正准备在 STM32、ESP32 或其他嵌入式平台上做一个可以无限循环滚动的列表又或者只是想在 VSCode 模拟器里先验证效果这篇内容都适合继续读下去。1. 背景与核心概念1.1 什么是 LVGL 环形无限循环滚动先解释一下标题里的几个词。LVGL 是一个开源嵌入式图形库全称 LittlevGL后来改名为 LVGL。它可以在资源非常有限的 MCU 上运行也支持模拟器运行。它提供了控件、布局、动画、输入设备管理、主题样式等完整能力是目前嵌入式 GUI 开发中很常用的方案之一。“滚动”比较好理解就是通过触摸、鼠标滚轮或物理按键让某个容器或列表内容在可视区域内上下左右移动。“环形无限循环滚动”则是指当内容滚动到末尾时不是停在最后一项而是继续滚动又能回到开头视觉上就像一条首尾相接的环形带子可以一直朝同一个方向滚下去。比如一个音乐播放器的歌曲列表、一个时间选择器的分钟列表、一个 HMI 上的模式切换菜单都可能用到这种效果。1.2 使用场景环形无限循环滚动在常见 UI 场景里并不少见汽车仪表盘或中控台上的模式切换菜单左右滑动可以循环选择。视频播放器或音乐 App 里的倍率选择、曲目切换。智能家居面板的时间、温度、亮度调节。各类工业 HMI 中的参数浏览列表。带滚轮选择器的嵌入式设备比如老式 MP3、嵌入式收音机界面。在这些场景里用户不希望滑到列表底部就停住而是希望可以一直滑动所有条目形成闭环。1.3 与普通滚动的区别普通滚动天然存在边界内容滚动到顶部或底部就会停止这是 LVGL 中滚动对象的默认行为。环形滚动则需要额外处理边界逻辑核心是要解决两个问题当滚动位置到达末尾时如何“无缝”切回开头。当滚动位置回跳到开头时如何避免用户肉眼看到明显的跳变。所以环形无限循环滚动并不是 LVGL 内置的一个独立控件它是在 LVGL 滚动容器基础上通过事件监听、位置计算和动画控制实现的“组合效果”。理解这一点就很容易自己动手实现而不是依赖某个特殊控件。2. 环境准备与版本说明2.1 开发环境选择LVGL 的开发和运行可以分成两种形态。第一种是模拟器方式推荐使用 VSCode SDL 模拟器。LVGL 官方和社区提供了很多模拟器工程比如lv_sim_vscode_sdl这类项目可以在 Windows 或 Linux 上直接编译运行不需要真实硬件。这种方式适合快速验证 UI 布局、滚动效果、事件逻辑调试效率比较高。第二种是嵌入式平台方式比如 STM32 屏幕驱动 LVGL 移植工程。这种方式适合最终产品落地但每次烧录调试都比较耗时建议先把效果在模拟器上验证通过再移植到目标板。本文提供的核心逻辑在两种环境下都可以运行。示例代码以 LVGL 8.3 版本为主LVGL 9.x 的 API 存在少量差异文中会单独说明。2.2 版本差异与注意事项LVGL 的 API 在版本演进中发生过一些变化典型的差异集中在样式设置、动画执行回调和滚动 API 命名上。LVGL 8.x 中常用lv_obj_set_scroll_dir、lv_obj_scroll_to_y、lv_obj_get_scroll_y等函数。LVGL 9.x 中大部分滚动 API 仍然保留但部分样式接口如lv_obj_set_style_pad_all可能在头文件组织上有所调整。点击事件、滚动事件、动画创建的 API 在 8.x 和 9.x 之间也有细微差别。如果你在使用 9.x遇到编译不通过的情况优先去 LVGL 官方文档或官方仓库查看对应版本的头文件不要直接照搬旧代码。嵌入式开发中最怕的就是版本不统一导致“代码看起来对编译就是不过”。2.3 示例项目结构为了便于理解我先给出本文示例的逻辑结构实际创建时不必完全一致。ring_scroll_demo/ ├── lvgl/ # LVGL 库源码 ├── src/ │ ├── main.c # 程序入口 │ ├── ui_ring_scroll.c # 环形滚动相关代码 │ └── ui_ring_scroll.h └── Makefile 或 CMakeLists.txt如果你的环境是 VSCode SDL 模拟器通常只需要在已有的main.c或ui文件中增加一个函数再在lv_demo的调用位置调用即可。3. 滚动机制与原理拆解3.1 谁在滚动容器与可滚动对象LVGL 的所有控件本质上都是一个对象对象之间可以嵌套。滚动行为并不是列表控件的专属能力而是任何对象的通用属性。一个普通容器lv_obj在设置了滚动方向之后也变成一个可滚动容器。// 创建一个容器 lv_obj_t *cont lv_obj_create(parent); // 设置容器大小 lv_obj_set_size(cont, 200, 240); // 允许垂直滚动 lv_obj_set_scroll_dir(cont, LV_DIR_VER); // 设置滚动条为自动显示 lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_AUTO);当容器内部子对象总高度超过容器本身高度时就可以通过触摸或滚轮滚动查看被遮挡的部分。这是 LVGL 滚动的基础。3.2 滚动条与滚动方向滚动方向可以组合使用例如LV_DIR_VER表示允许垂直滚动LV_DIR_HOR表示允许水平滚动LV_DIR_ALL表示两个方向都允许。在实现环形循环滚动时需要明确是纵向循环还是横向循环通常只会用到其中一个方向。滚动条分为三种模式LV_SCROLLBAR_MODE_OFF关闭滚动条。LV_SCROLLBAR_MODE_AUTO内容超出时显示不超出时隐藏。LV_SCROLLBAR_MODE_ON始终显示。环形滚动场景中滚动条一般选择OFF或AUTO因为滚动条一旦显示出来用户就能看到环形滚动其实是来回跳变的这会破坏视觉上的连续性。3.3 snap 吸附机制LVGL 提供了滚动吸附scroll snap功能。开启后当滚动停止时容器会自动吸附到最近的子对象位置让子对象边缘或中心对齐到容器边缘或中心。这个功能对实现“环形选择器”非常有帮助。// 示例设置水平滚动吸附到子对象靠左对齐 lv_obj_set_scroll_snap_x(cont, LV_SCROLL_SNAP_START); // 示例设置垂直滚动吸附到子对象中心对齐 lv_obj_set_scroll_snap_y(cont, LV_SCROLL_SNAP_CENTER);接入输入设备时LVGL 的触摸或滚轮事件会驱动滚动滚动结束后自动触发 snap 逻辑。吸附机制可以让我们少写很多对齐计算。3.4 环形无限循环滚动的两个常见实现思路在 LVGL 中实现环形滚动目前主流思路有两条。思路一滚动位置重置法在容器中一次性创建所有条目当检测到滚动位置接近容器末尾时通过设置滚动偏移量把视图“瞬移”到开头或者反过来从开头继续向左/上滚动时跳出到末尾。由于大部分场景下用户是在滑动/拖动后松手而不是一直盯着滚动过程所以只要处理得当跳变可以被动画掩盖甚至完全看不出来。这种方法实现简单适合条目数量有限的情况。缺点是如果条目数量非常多需要创建大量子对象内存占用会比较大。思路二数据复用 / 虚拟列表法只创建可视区域内的少量条目当滚动到边界时把条目内容和位置重新进行调整。视觉上仍然是无限滚动但实际使用的对象数量非常少。这种方法更接近 PC 端长列表的“虚拟滚动”思想适合嵌入式平台上条目数量很大的场景。缺点是逻辑更复杂需要维护一个数据源与视图对象之间的映射关系。本文第 4 节重点讲思路一的完整实现第 6 节会补充思路二的核心设计思路。4. 完整实战案例下面以一个纵向环形选择器为例演示完整的实现过程。示例假设每一条目高度固定为 80 像素可视区域高度为 240 像素容器内一共放 6 个条目。4.1 创建项目基础结构在已有的 LVGL 工程中创建一个新文件ui_ring_scroll.c并在头文件中声明一个创建函数// 文件路径src/ui_ring_scroll.h #ifndef UI_RING_SCROLL_H #define UI_RING_SCROLL_H #include lvgl.h void ui_ring_scroll_create(lv_obj_t *parent); #endif在main.c或 UI 初始化函数里调用#include ui_ring_scroll.h void ui_init(void) { ui_ring_scroll_create(lv_scr_act()); }4.2 定义宏与全局变量// 文件路径src/ui_ring_scroll.c #include ui_ring_scroll.h #define RING_ITEM_NUM 6 /* 条目数量 */ #define RING_ITEM_H 80 /* 每个条目高度单位像素 */ #define RING_VIEW_W 200 /* 滚动容器宽度 */ #define RING_VIEW_H 240 /* 滚动容器高度 */ static lv_obj_t *ring_cont; /* 滚动容器 */这里把容器宽度定义为 200 像素高度 240 像素实际使用时根据自己的屏幕尺寸调整。4.3 创建条目为了让示例简单直观每个条目使用一个标签来显示文本。实际项目中可以把标签换成包含图标、文字的复合对象。static void create_ring_items(lv_obj_t *cont) { const char *texts[RING_ITEM_NUM] { Item 0, Item 1, Item 2, Item 3, Item 4, Item 5 }; for (int i 0; i RING_ITEM_NUM; i) { lv_obj_t *item lv_obj_create(cont); lv_obj_set_size(item, RING_VIEW_W, RING_ITEM_H); lv_obj_align(item, LV_ALIGN_TOP_LEFT, 0, i * RING_ITEM_H); /* 去掉条目本身的圆角和边框保持简洁 */ lv_obj_set_style_radius(item, 0, 0); lv_obj_set_style_border_width(item, 0, 0); /* 在条目上放一个标签 */ lv_obj_t *label lv_label_create(item); lv_label_set_text(label, texts[i]); lv_obj_center(label); } }这里关键是lv_obj_align(item, LV_ALIGN_TOP_LEFT, 0, i * RING_ITEM_H)它让每个条目依次向下排列间隔等于条目的高度。4.4 创建滚动容器static void create_ring_cont(lv_obj_t *parent) { ring_cont lv_obj_create(parent); lv_obj_set_size(ring_cont, RING_VIEW_W, RING_VIEW_H); lv_obj_set_style_radius(ring_cont, 0, 0); /* 关闭容器自身的内边距避免影响坐标计算 */ lv_obj_set_style_pad_all(ring_cont, 0, 0); /* 允许垂直滚动 */ lv_obj_set_scroll_dir(ring_cont, LV_DIR_VER); /* 环形滚动场景建议关闭滚动条 */ lv_obj_set_scrollbar_mode(ring_cont, LV_SCROLLBAR_MODE_OFF); /* 开启纵向吸附到中心滚动松手后自动对齐 */ lv_obj_set_scroll_snap_y(ring_cont, LV_SCROLL_SNAP_CENTER); create_ring_items(ring_cont); }需要注意容器创建时会带默认样式例如边框、内边距、圆角。如果不处理这些样式会影响条目坐标计算。所以上面用lv_obj_set_style_pad_all(ring_cont, 0, 0)把内边距清零。4.5 核心代码监听滚动事件LVGL 提供了多个滚动相关事件。在 8.x 中常用的事件有LV_EVENT_SCROLL滚动过程中持续触发。LV_EVENT_SCROLL_BEGIN滚动开始。LV_EVENT_SCROLL_END滚动结束。环形滚动的核心逻辑放在LV_EVENT_SCROLL_END里比较合适因为这时候用户已经松手触摸或滚轮停止再对位置做修正不会产生明显的抖动。static void ring_scroll_end_cb(lv_event_t *e) { lv_obj_t *cont lv_event_get_target(e); lv_coord_t scroll_y lv_obj_get_scroll_y(cont); int item_count lv_obj_get_child_cnt(cont); lv_coord_t total_h item_count * RING_ITEM_H; lv_coord_t max_scroll_y total_h - RING_VIEW_H; /* 如果滚动位置超过了总高度的一半则回跳到开头 */ if (scroll_y max_scroll_y / 2) { lv_obj_scroll_to_y(cont, 0, LV_ANIM_ON); } }这段代码的意思是当滚动结束时如果当前已经滚过了列表总滚动范围的一半就平滑回跳回开头。这样用户往下滑到后半段时会看到列表自动跳回首项视觉上形成循环。不过这里有个问题如果用户刚好滚到了中间位置比如正好在第 3 个条目回跳到开头会显得很突然。更合理的做法是先吸附到最近的条目中心再判断是否需要回跳。4.6 实现平滑回跳动画上面的lv_obj_scroll_to_y(cont, 0, LV_ANIM_ON)已经自带动画效果但动画速度和缓动曲线是默认值。为了让回跳更平滑我们可以手动创建动画并且根据当前滚动位置动态调整动画时长。static void smooth_scroll_to_y(lv_obj_t *obj, lv_coord_t target_y) { lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, obj); lv_anim_set_exec_cb(a, (lv_anim_exec_xcb_t)lv_obj_set_scroll_y); lv_anim_set_values(a, lv_obj_get_scroll_y(obj), target_y); lv_anim_set_time(a, 300); lv_anim_set_path_cb(a, lv_anim_path_ease_out); lv_anim_start(a); }这个函数把滚动位置从当前值平滑过渡到目标值。lv_anim_path_ease_out表示先快后慢适合回跳这种需要自然减速的场景。4.7 吸附与回跳结合把 snap 吸附和环形回跳结合起来可以实现比较自然的效果。static void ring_scroll_end_cb(lv_event_t *e) { lv_obj_t *cont lv_event_get_target(e); lv_coord_t scroll_y lv_obj_get_scroll_y(cont); int item_count lv_obj_get_child_cnt(cont); lv_coord_t total_h item_count * RING_ITEM_H; lv_coord_t max_scroll_y total_h - RING_VIEW_H; /* 回到开头当前滚动位置超过总滚动范围一半 */ if (scroll_y max_scroll_y / 2) { smooth_scroll_to_y(cont, 0); return; } /* 回到开头之后吸附到对应条目中心 */ lv_coord_t target_y (scroll_y RING_ITEM_H / 2) / RING_ITEM_H * RING_ITEM_H; smooth_scroll_to_y(cont, target_y); }当滚动到后半段时直接回跳开头。当滚动在前半段时计算最近的条目偏移量做一次平滑吸附。需要注意的是由于 LVGL 的 snap 本身会自动吸附如果你已经开启LV_SCROLL_SNAP_CENTER可以省去手动吸附计算但如果自定义代码在SCROLL_END里又修改了滚动位置可能会和 snap 逻辑产生冲突建议关闭 snap 然后统一用手动吸附逻辑或者保留 snap 但不要重复修改位置。实际开发中不同的 LVGL 版本对 snap 和动画的交互表现不完全一致最好以真机表现为准。4.8 完整组装函数void ui_ring_scroll_create(lv_obj_t *parent) { create_ring_cont(parent); /* 给滚动容器添加滚动结束事件回调 */ lv_obj_add_event_cb(ring_cont, ring_scroll_end_cb, LV_EVENT_SCROLL_END, NULL); }到这里一个最基本的环形无限循环滚动列表就完成了。4.9 运行与验证在模拟器中运行后向下滑动列表可以看到 6 个条目依次出现。当列表滚过一半后松手列表会自动平滑回到顶部。回到顶部后继续下滑可以再次从 Item 0 开始浏览。再用滚轮或向上滑测试反向效果当前如果处于顶部向上滑没有内容LVGL 默认会停在顶部。如果需要反向循环可以在SCROLL_END中增加一个判断如果滚动位置为 0并且用户试图继续向上滚动则直接平滑跳到尾部。但 LVGL 的SCROLL_END只能获取当前结束位置获取用户滚动的方向需要结合滚动开始前的位置通过比较前后两次scroll_y来实现。这就是下一节要介绍的方向判断技巧。5. 常见问题与排查思路问题现象常见原因解决思路滚动结束回跳非常生硬直接使用LV_ANIM_OFF强制设置位置使用lv_anim平滑过渡或使用LV_ANIM_ON快速滑动时容器位置抖动在LV_EVENT_SCROLL中强制修改滚动位置不要在滚动过程中修改位置统一放到LV_EVENT_SCROLL_END处理触摸滚动完全无反应容器尺寸小于内容但未开启滚动方向或输入设备未注册检查lv_obj_set_scroll_dir(cont, LV_DIR_VER)确认触摸驱动已初始化滚动条一直显示体验不佳未设置滚动条模式调用lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_OFF)吸附位置不对容器存在内边距导致坐标计算偏差将容器pad清零或在计算目标位置时加入 pad 偏移条目数量很多时内存暴涨一次性创建了大量子对象改用数据复用/虚拟列表方案控制子对象数量LVGL 9.x 编译报错8.x 与 9.x API 有改动查看官方迁移文档根据 9.x 头文件修正函数回跳动画触发时画面闪烁屏幕刷新缓冲区配置不合理或脏矩形区域过大检查LV_COLOR_DEPTH与缓冲区配置必要时启用双缓冲向上滑时无法从顶部跳到底部没有判断滚动方向和顶部边界记录滚动开始前的位置在结束回调中判断滚动方向后决定跳转目标其中“滚动方向”的获取可以参考下面的思路static lv_coord_t last_scroll_y 0; static void ring_scroll_begin_cb(lv_event_t *e) { lv_obj_t *cont lv_event_get_target(e); last_scroll_y lv_obj_get_scroll_y(cont); } static void ring_scroll_end_cb(lv_event_t *e) { lv_obj_t *cont lv_event_get_target(e); lv_coord_t scroll_y lv_obj_get_scroll_y(cont); /* 通过比较滚动开始前与结束时的位置判断方向 */ if (scroll_y last_scroll_y) { /* 向下滚动 */ if (scroll_y max_scroll_y / 2) { smooth_scroll_to_y(cont, 0); } } else if (scroll_y last_scroll_y) { /* 向上滚动 */ if (scroll_y 0) { smooth_scroll_to_y(cont, max_scroll_y); } } } void ui_ring_scroll_create(lv_obj_t *parent) { create_ring_cont(parent); lv_obj_add_event_cb(ring_cont, ring_scroll_begin_cb, LV_EVENT_SCROLL_BEGIN, NULL); lv_obj_add_event_cb(ring_cont, ring_scroll_end_cb, LV_EVENT_SCROLL_END, NULL); }这段逻辑中如果当前已经在顶部并且用户向上滚即手指向下滑内容向下移动scroll_y从 0 变成负数但 LVGL 通常不允许负值需要额外判断边界。真实场景中LVGL 滚动一般不会产生负的scroll_y所以这个判断通常只对“从顶部往下正常滚动”生效。实现反向循环时可以考虑使用一个“假顶部偏移量”在容器内容前面额外增加一段空白或复制条目让顶部也有足够内容可以回卷。6. 最佳实践与工程建议6.1 明确选择适合你的方案如果业务中条目数量不大比如只有几个到十几个优先使用“位置重置法”代码简单逻辑清楚。如果条目数量很大比如上百个甚至更多一定要用“数据复用 / 虚拟列表法”否则 MCU 内存会被大量对象占满。一个可用作参考的阈值是可视区最多同时显示超过 10 个条目时建议考虑虚拟列表如果目标平台内存低于 256KB更要提前规划控件数量。6.2 数据处理与对象复用“数据复用 / 虚拟列表法”的核心思想是把“数据数组”和“可视化对象数组”分离数据数组存储完整列表的所有数据。可视化对象数组只创建可视区域内能看到的 N 个对象。滚动过程中根据当前滚动位置动态更新这 N 个对象的内容和坐标。下
返回列表