LVGL环形无限循环滚动:数据不动,位置循环 如果你在小屏幕上做过嵌入式 GUI大概率遇到过这样的需求屏幕只有 800 像素宽但你要展示的内容有 20 个分类或者你需要在主界面上做一个轮播区域让项目从右往左循环滚动。最直接的做法是用 LVGL 自带的可滚动对象但滚动到最后一项后用户要么手动拖回去要么看着内容停在边界体验非常生硬。如果做成自动滚动到边界又会停下或者突然弹回去谈不上“无限”。LVGL 本身没有现成的“环形无限循环滚动”控件你需要把动画、容器和位置偏移结合起来自己实现。很多人第一反应是把内容复制一份甚至复制好几份摆在同一行滚动到末尾后再切回开头。这个思路确实能出效果但内存会成倍增长内容更新时还要同步多份数据维护成本很高。尤其在 STM32、ESP32 这类资源受限的 MCU 上这种方案很容易把 Flash 和 RAM 吃紧。这篇文章的核心判断是LVGL 实现环形无限循环滚动的关键不是把数据复制多份而是让位置在逻辑上循环。数据始终只有一份位置通过“补一个整周期”回到原点用户看到的是连续平滑移动的视觉效果背后只是一组对象在平移。读完这篇文章你能明白 LVGL 动画与位置偏移的运行机制能自己封装一个可复用的环形滚动模块并且知道在验证、排查、性能优化时应该注意哪些坑。1. 环形无限循环滚动到底解决了什么问题很多嵌入式界面里都有“一排内容横向滚动”的需求典型场景包括主界面上的状态栏轮播比如温度、湿度、时间、WiFi 信号。产品展示或广告横幅每隔几秒切换一条。新闻标题或消息列表在固定区域里循环显示。设置页的图标导航栏支持左右滑动切换分类。这些场景有一个共同点可视区域很窄但内容数量可能远超屏幕宽度。如果只是用一个普通的lv_obj加LV_OBJ_FLAG_SCROLLABLE用户确实可以拖动但存在三个明显问题第一列表滚动到末尾后会停住无法自然衔接回开头。用户需要手动滑回起点或者点击翻页按钮。第二自动滚动到边界时如果直接设置坐标跳回去会产生明显的视觉跳变。用户会看到内容“瞬移”体验很差。第三如果为了做循环而复制多份 UI 对象内存占用会成倍增加。在资源受限的 MCU 上这通常是不可接受的。环形滚动的本质是让 UI 对象在平移过程中把“边界”隐藏起来。当内容向左移动超过半个周期时观察者看到的只是同样的内容又出现在右侧而不会意识到位置其实发生了一次“瞬间复位”。从这个角度看环形滚动真正解决的并不是“让内容滚动起来”而是“如何在一个有限的坐标系里表达无限滚动的视觉连续性”。理解了这一点就能理解后面所有代码设计的动机。这篇文章适合正在做 MCU GUI 的开发者比如用 LVGL 开发智能家居面板、仪器仪表、车载小屏或消费电子 HMI 的工程师。也适合刚接触 LVGL、希望在模拟器里快速验证交互效果的初学者。2. LVGL 中两个关键机制translate 与 lv_anim在动手写代码之前先把 LVGL 里两个重要的机制讲清楚。理解了这两个机制环形滚动的实现思路就变得非常自然。2.1 translate_x 和 obj 坐标的区别LVGL 里每个lv_obj_t都有 x/y 坐标用来决定它在父对象中的位置。日常布局用的lv_obj_set_pos()会修改这个坐标。除了基础坐标LVGL 的样式系统还提供了translate_x和translate_y这两个偏移量属性。简单理解x/y决定对象的基础位置translate_x/translate_y是在基础位置上叠加的视觉偏移。它不会改变对象在布局树中的实际位置不会影响其它对象的布局计算只是让对象在屏幕上额外移动一段距离。为什么环形滚动要选择 translate 而不是直接修改 x/y因为 LVGL 的动画回调可以非常频繁地修改 translate 值而不会触发父对象的重新布局。当你在一个宽 320 像素的屏幕上让一个宽度 800 像素的容器向左移动时如果直接修改坐标每帧都要重新计算子对象的位置关系而使用 translate只改变容器的视觉偏移计算量大为减少。在代码里设置与读取 translate 的接口也很直观lv_obj_set_style_translate_x(container, -200, LV_STATE_DEFAULT); int32_t current lv_obj_get_style_translate_x(container, LV_STATE_DEFAULT);第三个参数是 selector一般传LV_STATE_DEFAULT表示默认状态。2.2 lv_anim 动画机制LVGL 的动画系统用lv_anim_t结构体来描述一个动画过程。你可以指定动画作用的对象、变化目标、执行时间、路径函数和结束回调。典型写法如下lv_anim_t anim; lv_anim_init(anim); lv_anim_set_var(anim, obj); // 动画作用的对象 lv_anim_set_exec_cb(anim, exec_cb); // 每帧执行的回调 lv_anim_set_values(anim, start, end); // 起始值和结束值 lv_anim_set_time(anim, 600); // 持续时间 ms lv_anim_set_path_cb(anim, lv_anim_path_linear); lv_anim_set_ready_cb(anim, ready_cb); // 动画结束后的回调 lv_anim_start(anim);动画开始后exec_cb会持续收到从start到end之间的值。这个值不一定是坐标你可以把它映射到任何业务含义上比如透明度、角度或者自定义的参数。这里我们把动画值直接赋给容器的translate_x就形成了一段平滑移动。动画结束时会触发ready_cb。环形滚动的“瞬间复位”正是在这个回调里完成的。因为复位不经过动画而是直接写一个值所以视觉上几乎察觉不到。另外要理解 LVGL 动画和 UI 线程的关系。LVGL 是单线程事件驱动模型动画由lv_timer_handler()周期性驱动。在裸机环境里主循环要不断调用这个函数在 RTOS 环境里一般会创建一个 LVGL 任务来周期调用。只要动画通过lv_anim_start()启动就不需要你自己维护一个定时器LVGL 内部会自动推进动画流程。3. 环境准备与演示项目组织本文的示例代码基于 LVGL 8.x 接口编写。LVGL 9.x 中部分 API 有改名比如lv_anim_set_time在 9.x 中变为lv_anim_set_durationoverflow相关样式也有调整。如果你使用的是 9.x需要对照官方迁移说明做调整。本文重点演示通用思路先不绑定某一个具体版本。实际操作上建议先在 PC 上跑 LVGL 模拟器验证逻辑再移植到 STM32、ESP32 等硬件平台。模拟器调试速度快可以随时改代码重新编译内存也充足非常适合验证动画和位置逻辑。常见的开发组合是用 VSCode CMake LVGL 模拟器。LVGL 官方提供了多个模拟器模板比如 lv_port_pc_vscode 和 lv_port_pc_eclipse。你也可以直接使用 LVGL 官方在线模拟器先把核心代码跑通。演示项目的文件组织可以这样划分project/ ├── lv_conf.h ├── lv_ring_scroll.h ├── lv_ring_scroll.c ├── main_ui.c └── main.clv_ring_scroll.h和lv_ring_scroll.c是环形滚动模块与具体 UI 页面的业务无关。main_ui.c负责创建主界面并调用模块。这样拆分的目的是让滚动逻辑可复用后续换平台、加功能都不需要重写核心算法。如果你用的是 STM32LVGL 的移植方式一般是先在工程里加入 LVGL 源码配置好lv_conf.h然后确保lv_tick_inc()和lv_timer_handler()被正确调用。只要这两步正常后面的滚动代码逻辑与模拟器完全一致。4. 核心设计数据不动位置循环环形滚动模块的设计核心可以总结为一句话数据不动位置循环。下面拆解它的几个关键设计点。4.1 数据只有一份我们只需要创建一组 item它们依次排列在同一个容器内。容器负责整体平移 item 之间保持固定间距。假设屏幕上需要展示若干分类每个分类是一个蓝色圆角块宽度 120高度 48间距 20。那么 6 个 item 的布局如图中的顺序item1 在 x0 处item2 在 x140 处item3 在 x280 处以此类推。容器的逻辑总宽也就是一个滚动周期的长度可以用下面公式计算logical_width item_count * item_width (item_count - 1) * item_spacing这个 logical_width 是环形方案里最重要的参数。后面判断“是否该回到起点”时都是拿当前 translate 与 logical_width 的一半做比较。4.2 动画循环滚动采用“走一步看一步”的方式而不是一次性滚完所有内容。具体流程是当前容器 translate_x 从某个值开始。创建一个动画让 translate_x 向左移动一个 scroll_step比如 40 像素。动画结束后进入 ready_cb。在 ready_cb 里判断当前 translate_x 是否已经越过半个 logical_width。如果越过了就给它补上一个 logical_width让位置瞬间回到周期内的某个等价点。然后启动下一个动画周期。把动画拆成小步长而不是一次滚完全程有几个好处可以控制滚动速度可以在用户交互时随时停止每个动画周期的结束回调是天然的逻辑检查点避免了一次长动画中无法插入回绕判断的问题。4.3 回绕的模运算思想假设逻辑总宽是 1200当前 translate_x 已经移动到 -700。因为判断阈值是-(logical_width / 2)也就是 -600所以 -700 已经越过了阈值。此时只需要执行current logical_width;位置就从 -700 变为了 500。在视觉上同样的内容又出现在周期右侧用户看到的是无缝衔接而不是跳变。如果移动步长较大或者逻辑宽度较小一次补周期可能不够所以要使用 while 循环while (current -(scroll-logical_width / 2)) { current scroll-logical_width; }这个思想本质上就是取模。使用减法而不是求余运算是因为 C 语言里负数求余在某些编译器上会得到负数结果容易引入边界 bug。4.4 为什么不会出现“内容用完”因为 item 始终只有一份当容器向左移动时左侧的 item 会移出可见区域右侧会有空白。但回绕发生后容器位置重新回到周期内原本已经移出左侧的 item 又从右侧进入视野。从用户视角看内容一直在向左流动永远不结束。这个方案不需要在逻辑上创建“第 7 个 item”或“第 8 个 item”内存占用是固定的。5. 完整示例代码实现下面进入实操。我们会写一个可复用的lv_ring_scroll模块包含头文件、实现文件和 UI 调用示例。读者可以把这三个文件直接放进自己的 LVGL 工程里跑通。5.1 头文件 lv_ring_scroll.h#ifndef LV_RING_SCROLL_H #define LV_RING_SCROLL_H #ifdef __cplusplus extern C { #endif #include lvgl.h typedef struct { lv_obj_t *viewport; // 滚动窗口负责裁剪可见区域 lv_obj_t *container; // 真正发生平移的容器 lv_obj_t **items; // item 数组 uint32_t item_count; // item 数量 int32_t logical_width; // 逻辑总宽即一个滚动周期的长度 int32_t item_spacing; // item 之间的间距 int32_t scroll_step; // 每个动画周期移动的像素 uint16_t duration; // 每个动画周期的时长 ms lv_anim_t anim; // 动画实例 bool running; // 是否允许继续滚动 } lv_ring_scroll_t; lv_ring_scroll_t *lv_ring_scroll_create(lv_obj_t *parent, lv_coord_t width, lv_coord_t height); lv_obj_t *lv_ring_scroll_add_item(lv_ring_scroll_t *scroll, const char *text); void lv_ring_scroll_start(lv_ring_scroll_t *scroll); void lv_ring_scroll_stop(lv_ring_scroll_t *scroll); #ifdef __cplusplus } #endif #endif这个头文件把滚动模块的数据结构公开出去方便使用者直接访问 viewport 和 container 做自定义布局。lv_ring_scroll_create指定可视区域的宽高lv_ring_scroll_add_item添加一个文本 itemlv_ring_scroll_start和lv_ring_scroll_stop控制滚动启停。5.2 实现文件 lv_ring_scroll.c#include lv_ring_scroll.h #include stdlib.h #define LV_RING_SCROLL_ITEM_WIDTH 120 #define LV_RING_SCROLL_ITEM_HEIGHT 48 static void ring_scroll_restart(lv_ring_scroll_t *scroll); static void ring_scroll_layout(lv_ring_scroll_t *scroll) { if (scroll-item_count 0) { return; } int32_t x 0; uint32_t i; for (i 0; i scroll-item_count; i) { lv_obj_set_pos(scroll-items[i], x, 0); x LV_RING_SCROLL_ITEM_WIDTH scroll-item_spacing; } scroll-logical_width x - scroll-item_spacing; } static void ring_scroll_anim_exec_cb(void *var, int32_t v) { lv_ring_scroll_t *scroll (lv_ring_scroll_t *)var; lv_obj_set_style_translate_x(scroll-container, v, LV_STATE_DEFAULT); } static void ring_scroll_anim_ready_cb(lv_anim_t *a) { lv_ring_scroll_t *scroll (lv_ring_scroll_t *)a-var; int32_t current lv_obj_get_style_translate_x(scroll-container, LV_STATE_DEFAULT); /* 如果已经越过半个周期就补一个逻辑宽度实现瞬间回绕 */ while (current -(scroll-logical_width / 2)) { current scroll-logical_width; } lv_obj_set_style_translate_x(scroll-container, current, LV_STATE_DEFAULT); if (scroll-running) { ring_scroll_restart(scroll); } } static void ring_scroll_restart(lv_ring_scroll_t *scroll) { int32_t current lv_obj_get_style_translate_x(scroll-container, LV_STATE_DEFAULT); int32_t target current - scroll-scroll_step; lv_anim_init(scroll-anim); lv_anim_set_var(scroll-anim, scroll); lv_anim_set_exec_cb(scroll-anim, ring_scroll_anim_exec_cb); lv_anim_set_ready_cb(scroll-anim, ring_scroll_anim_ready_cb); lv_anim_set_values(scroll-anim, current, target); lv_anim_set_time(scroll-anim, scroll-duration); lv_anim_set_path_cb(scroll-anim, lv_anim_path_linear); lv_anim_start(scroll-anim); } lv_ring_scroll_t *lv_ring_scroll_create(lv_obj_t *parent, lv_coord_t width, lv_coord_t height) { lv_ring_scroll_t *scroll calloc(1, sizeof(lv_ring_scroll_t)); if (scroll NULL) { return NULL; } scroll-item_spacing 20; scroll-scroll_step 40; scroll-duration 600; scroll-running false; scroll-viewport lv_obj_create(parent); lv_obj_set_size(scroll-viewport, width, height); lv_obj_set_style_border_width(scroll-viewport, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(scroll-viewport, 0, LV_STATE_DEFAULT); lv_obj_set_style_radius(scroll-viewport, 0, LV_STATE_DEFAULT); lv_obj_set_style_overflow(scroll-viewport, LV_OVERFLOW_HIDDEN, LV_STATE_DEFAULT); scroll-container lv_obj_create(scroll-viewport); lv_obj_remove_flag(scroll-container, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(scroll-container, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_border_width(scroll-container, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(scroll-container, 0, LV_STATE_DEFAULT); lv_obj_set_style_radius(scroll-container, 0, LV_STATE_DEFAULT); return scroll; } lv_obj_t *lv_ring_scroll_add_item(lv_ring_scroll_t *scroll, const char *text) { lv_obj_t *item lv_obj_create(scroll-container); lv_obj_set_size(item, LV_RING_SCROLL_ITEM_WIDTH, LV_RING_SCROLL_ITEM_HEIGHT); lv_obj_set_style_bg_color(item, lv_palette_main(LV_PALETTE_BLUE), LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(item, LV_OPA_COVER, LV_STATE_DEFAULT); lv_obj_set_style_radius(item, 8, LV_STATE_DEFAULT); lv_obj_set_style_border_width(item, 0, LV_STATE_DEFAULT); lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t *label lv_label_create(item); lv_label_set_text(label, text); lv_obj_set_style_text_color(label, lv_color_white(), LV_STATE_DEFAULT); lv_obj_center(label); scroll-items realloc(scroll-items, sizeof(lv_obj_t *) * (scroll-item_count 1)); scroll-items[scroll-item_count] item; scroll-item_count; ring_scroll_layout(scroll); return item; } void lv_ring_scroll_start(lv_ring_scroll_t *scroll) { if (scroll-running) { return; } scroll-running true; ring_scroll_restart(scroll); } void lv_ring_scroll_stop(lv_ring_scroll_t *scroll) { scroll-running false; if (lv_anim_get(scroll-anim, scroll) ! NULL) { lv_anim_del(scroll-anim, scroll); } }这段代码的核心逻辑集中在ring_scroll_anim_ready_cb和ring_scroll_restart两个函数里。每个动画周期结束时先判断是否越过logical_width / 2如果越过了就补周期回绕然后启动下一个动画周期。需要注意lv_anim_get和lv_anim_del的用法。lv_anim_get(scroll-anim, scroll)的第二个参数是动画作用对象这里我们通过lv_anim_set_var设置的是scroll结构体指针所以查询和删除时也传scroll。5.3 在 UI 中调用示例// main_ui.c #include lv_ring_scroll.h void ui_ring_scroll_demo(lv_obj_t *parent) { lv_ring_scroll_t *scroll lv_ring_scroll_create(parent, 800, 60); if (scroll NULL) { return; } lv_obj_center(scroll-viewport); int i; char buf[32]; for (i 1; i 6; i) { lv_snprintf(buf, sizeof(buf), Item %d, i); lv_ring_scroll_add_item(scroll, buf); } lv_ring_scroll_start(scroll); }在模拟器或开发板上初始化 LVGL 后调用ui_ring_scroll_demo(lv_scr_act())就能看到效果。可视区域宽度设为 800高度设为 60。如果你用的屏幕是 320 宽把第一个参数改成 320 即可。5.4 代码关键点说明lv_obj_set_style_overflow(scroll-viewport, LV_OVERFLOW_HIDDEN, LV_STATE_DEFAULT)的作用是让 viewport 裁剪超出边界的子对象。这样整个滚动区域看起来就像一个“窗口”item 移出窗口后不会在屏幕上露馅。scroll-container的尺寸设置成LV_SIZE_CONTENT让它根据内部 item 自动扩展。同时要移除LV_OBJ_FLAG_SCROLLABLE否则容器本身可能响应触摸滚动干扰自动滚动效果。ring_scroll_layout在每次添加 item 后重新计算位置和逻辑总宽。这里的宽度计算把间距也考虑进去了。逻辑总宽是一个滚动周期内从第一个 item 最左侧到最后一个 item 最右侧的距离但不包括最后一个 item 右侧的间距。这个值用于判断回绕时机。lv_ring_scroll_stop里先设置running false再删除动画。这样可以避免动画回调中再次启动下一个周期。如果你在界面销毁时需要释放滚动模块可以在 stop 之后释放 items 数组和结构体内存。6. 运行结果与效果验证在模拟器里运行后预期的效果是6 个蓝色圆角块从右往左连续滚动每个周期移动 40 像素持续 600 毫秒。当滚动到逻辑周期的一半后你会看到同样的 item 从右侧自然进入视野仿佛内容永远循环。判断实现是否成功可以从四个维度观察。第一滚动是否连续。至少观察 30 秒确认滚动不会停住。如果中途停了多半是ready_cb里没有调用ring_scroll_restart或者running被误改成了

相关新闻

最新新闻

Flume拦截器实战:自定义ETL、数据脱敏与动态路由标签

Flume拦截器实战:自定义ETL、数据脱敏与动态路由标签

Flume拦截器实战:自定义ETL、数据脱敏与动态路由标签1. 引言 Flume作为Hadoop生态系统中的日志采集工具,其拦截器(Interceptor)机制提供了强大的数据处理能力。本文将通过实战案例,介绍如何自定义拦截器实现ETL转换、数据脱敏和动态路由标签功…

2026/8/31 10:49:57
Spring Boot网上订餐系统源码详解与毕业设计实战指南

Spring Boot网上订餐系统源码详解与毕业设计实战指南

简介:这是一套面向Java初学者与高校毕业设计学生的SpringBoot实战项目源码,聚焦餐饮行业线上订餐场景,完整覆盖用户端下单、订单管理与后台多角色协同管理全流程。资源包含724个文件,涵盖82个Java后端逻辑类、153个JavaScript交互…

2026/8/31 10:49:57
MoneyPrinterTurbo 完整教程:一个主题生成高清短视频,5 步做出第一个成片

MoneyPrinterTurbo 完整教程:一个主题生成高清短视频,5 步做出第一个成片

MoneyPrinterTurbo 完整教程:一个主题生成高清短视频,5 步做出第一个成片 【免费下载链接】MoneyPrinterTurbo 利用 AI 大模型和自动化工作流,根据主题或关键词一键生成高清短视频。Generate HD short videos from a topic or keyword with a…

2026/8/31 10:49:57
3ds Max法线烘焙完整指南:原理、步骤与常见错误排查

3ds Max法线烘焙完整指南:原理、步骤与常见错误排查

大家在 3ds Max 里做法线烘焙的时候,应该都遇到过这类情况:低模和高模摆得好好的,参数也设置了,结果烘出来的法线贴图要么是平的,要么黑一块紫一块,还有接缝、反光错乱、进引擎后方向不对等一堆问题。网上资…

2026/8/31 10:49:57
marketingskills 完整指南:如何给 AI 代理装上 50+ 个营销技能,跑通整条增长链路

marketingskills 完整指南:如何给 AI 代理装上 50+ 个营销技能,跑通整条增长链路

marketingskills 完整指南:如何给 AI 代理装上 50 个营销技能,跑通整条增长链路 【免费下载链接】marketingskills Marketing skills for Claude Code and AI agents. CRO, copywriting, SEO, analytics, and growth engineering. 项目地址: https://g…

2026/8/31 10:49:57
GLM-5.3开源:智能体编程与网络防御实战指南

GLM-5.3开源:智能体编程与网络防御实战指南

过去两年里,开源大模型的发展节奏明显加快,每隔一段时间就有新权重、新架构、新应用范式出现。尤其是在智能体(Agent)和安全领域,模型不再只是“问答工具”,而是逐渐变成了能调用工具、能处理任务、能辅助分…

2026/8/31 10:44:57