从零构建嵌入式菜单库(六):导航、动画与消息框

系列定位:这是一套编写教程
本篇把最后几个"高级交互"模块串起来——子菜单调用链追溯、展开动画、超长文本滚动、消息框。这些功能让菜单从"能用"变成"顺手"。


前言:从"单页菜单"到"多级导航"的鸿沟

原型只有一个菜单页,子菜单靠全局变量手搓——每次新增一个页面就要加一个 void subMenu_xxx() 函数和对应的 if/else 分支。随着页面增加到 5 个、10 个、20 个,维护成本指数级上升。

我们需要的是一种自动追溯调用链的机制——类似浏览器的前进/后退,但深度限制在嵌入式可承受范围内。


知识点预备

1.1 固定深度调用栈模拟

#define TRACE_MAX_DEPTH 16

menuItem_cb trace[TRACE_MAX_DEPTH];  // 函数指针数组

// 推入(进入子菜单)
void push(menuItem_cb item) {
    for (int i = 0; i < TRACE_MAX_DEPTH; i++) {
        if (trace[i] == item) {
            // 要去的地方已在调用链中 → 截断循环引用
            trace[i] = NULL;
            // 直接跳转
            break;
        }
        if (trace[i]) continue;  // 找空位
        trace[i] = current;      // 保存当前位置
        if (i + 1 < TRACE_MAX_DEPTH)
            trace[i + 1] = NULL; // 截断后续
        break;
    }
}

// 弹出(返回父菜单)
void pop() {
    int i;
    for (i = 0; i < TRACE_MAX_DEPTH && trace[i]; i++);
    if (i <= 0) return;         // 已经在根页面
    menuItem_cb parent = trace[i - 1];
    trace[i - 1] = NULL;
    // 跳转到 parent
}

这是"用数组模拟栈"的经典模式。固定深度用宏 U8G2_MENU_TRACE_MAX_DEPTH 控制。

1.2 线性追击动画

在第 1 篇原型分析中我们见过:

if (ABS(target - current) > speed)
    current += (target > current) ? speed : -speed;
else
    current = target;

这个算法用到了滚动动画和行高动画——“永远向目标追,不到就继续追”。

1.3 消息框超时

uint32_t timer_left = timeout;

void onTick(uint16_t ms) {
    if (timer_left > ms)
        timer_left -= ms;
    else
        timer_left = 0;  // 超时
}

// 当 timer_left == 0 时关闭弹窗

极其简单,但极其可靠——没有依赖操作系统定时器,只依赖用户提供的 Tick 接口。


2. 子菜单追溯系统

2.1 数据结构

struct u8g2_menu_struct {
    // ...
    menuItem_cb menuItemTrace[U8G2_MENU_TRACE_MAX_DEPTH];  // 16 层
};

menuItemTracemenuItem_cb(函数指针)的定长数组。非 NULL 表示"这个位置记录了一个父页面"。

2.2 推入(Enter)

void u8g2_MenuItemEnter(u8g2_menu_t *u8g2_menu, menuItem_cb menuItem)
{
    for (size_t i = 0; i < U8G2_MENU_TRACE_MAX_DEPTH; i++)
    {
        // 如果目标已在调用链中(循环引用检测)
        if (u8g2_menu->menuItemTrace[i] == menuItem) {
            u8g2_menu->menuItemTrace[i] = NULL;
            u8g2_MenuReplaceItem(u8g2_menu, menuItem);
            break;
        }
        // 找到第一个空位
        if (u8g2_menu->menuItemTrace[i]) continue;

        u8g2_menu->menuItemTrace[i] = u8g2_menu->menuItem;  // 保存当前页
        if (i + 1 < U8G2_MENU_TRACE_MAX_DEPTH)
            u8g2_menu->menuItemTrace[i + 1] = NULL;          // 截断
        u8g2_MenuReplaceItem(u8g2_menu, menuItem);
        break;
    }
}

循环引用检测:如果用户从 A→B→C→A,第三次进入 A 时会发现 A 已在 trace 中,于是截断 C 并直接跳转。防止无限堆叠。

2.3 弹出(Back)

void u8g2_MenuItemBack(u8g2_menu_t *u8g2_menu)
{
    size_t i;
    for (i = 0; i < U8G2_MENU_TRACE_MAX_DEPTH; i++)
        if (u8g2_menu->menuItemTrace[i] == NULL) break;

    if (i <= 0) return;  // 已在根页面,无法再返回

    i -= 1;
    u8g2_MenuReplaceItem(u8g2_menu,
        u8g2_menu->menuItemTrace[i]);  // 恢复到父页面
    u8g2_menu->menuItemTrace[i] = NULL;
}

2.4 menumenu_enter 的区别

case MENU_menu:
    u8g2_MenuReplaceItem(u8g2_menu,
        u8g2_menu->u8g2_menuValue.menu.menuItem);
    break;
    // → 替换当前页,不保存调用链(像"跳转"按钮)

case MENU_menu_enter:
    u8g2_MenuItemEnter(u8g2_menu,
        u8g2_menu->u8g2_menuValue.menu.menuItem);
    break;
    // → 推入调用链,可返回(像"进入"按钮)

menu 是扁平跳转(A→B,不能返回),menu_enter 是层级导航(A→B,可返回 A)。多级菜单应使用 menu_enter


3. 动画效果器系统

3.1 可替换接口

struct u8g2_menu_effect_struct {
    u8g2_int_t (*u8g2_menuEffect_init)(u8g2_menu_t *u8g2_menu);  // 初始化
    u8g2_int_t (*u8g2_menuEffect_run)(u8g2_menu_t *u8g2_menu);   // 逐帧运行

    u8g2_int_t _position;    // 当前滚动位置
    float      _rowHeight;   // 当前行高比例
};

用户可以写自己的效果器并注册:

u8g2_menu_effect_t myEffect = {myInit, myRun};
u8g2_MenuEffectBind(&menu, &myEffect);

3.2 默认效果:展开动画 + 平滑滚动

// 初始化
u8g2_int_t u8g2_Menu_init(u8g2_menu_t *u8g2_menu)
{
    u8g2_menu->menuEffect._rowHeight = ROW_HEIGHT_INCREMENT;  // 从 0.2 开始
    return 0;
}

// 逐帧运行
u8g2_int_t u8g2_Menu_run(u8g2_menu_t *u8g2_menu)
{
    // 1. 行高渐变
    if (u8g2_menu->menuEffect._rowHeight < MAX_ROW_HEIGHT)
        u8g2_menu->menuEffect._rowHeight += ROW_HEIGHT_INCREMENT;
    else
        u8g2_menu->menuEffect._rowHeight = MAX_ROW_HEIGHT;

    // 2. 滚动追击:计算速度调节量
    float spe_adj =
        (u8g2_menu->pickItemY >= u8g2_menu->currentHeight + u8g2_menu->currentY)
            ? (u8g2_menu->pickItemY - u8g2_menu->currentHeight
               - u8g2_menu->currentY) / SPE_ADJUSTMENT + 1
        : (u8g2_menu->pickItemY - u8g2_menu->pickItemHeight
           <= u8g2_menu->currentY)
            ? (u8g2_menu->pickItemY - u8g2_menu->pickItemHeight
               - u8g2_menu->currentY) / SPE_ADJUSTMENT - 1
        : 0;

    u8g2_menu->menuEffect._position += spe_adj;
    u8g2_menu->pickItemY += spe_adj;
    return 0;
}

spe_adj 的设计思路:滚动速度与"选中项离可视区域边界的距离"成正比。选中项被遮挡得越多,滚动越快。

                                  
     可视区                       可视区
  ┌────────┐                  ┌────────┐
  │ item 1 │                  │ item 2 │ ← 部分遮挡 → spe_adj = (遮挡量)/2 + 1
  │ item 2 │ ← 选中           │ item 3 │
  │ item 3 │                  │ item 4 │
  └────────┘                  └────────┘
  选中在可视区内 → spe_adj=0    选中被遮挡 → 加速滚动

3.3 动画帧率控制

// 在 u8g2_DrawMenu 中:
if (!u8g2_menu->timer_effective)
    u8g2_menuEffect_run_call(u8g2_menu);  // 未启用定时器 → 每帧都跑

if (u8g2_menu->timer_effective && u8g2_menu->timer > U8G2_MENU_DELAY) {
    u8g2_menu->timer -= U8G2_MENU_DELAY;
    u8g2_menuEffect_run_call(u8g2_menu);  // 每 100ms 跑一次
}

timeru8g2_MenuTime_ISR 驱动(定时中断中调用)。如果用户不提供定时器,退化为每帧运行。


4. 超长文本自动滚动

菜单项的文字可能超出屏幕宽度——比如一个 Wi-Fi SSID “My_Really_Long_WiFi_Name_That_Doesnt_Fit”。

4.1 "平移头"概念

float positionOffset;             // 目标滚动位置(字符单位)
float _positionOffset;            // 实际滚动位置
float positionOffset_spe;         // 滚动速度
float positionOffset_strHeaderLen; // 平移头长度(字符数)

"平移头"是一个虚拟的指示器,领先于文本滚动。当它到达屏幕最右侧后,文本才开始向左滚动。这样保证每个字符在屏幕上停留的时间大致均匀

时刻 t0: [Hello World]         平移头在右侧边界之外
时刻 t1: [Hello Worl|d]        平移头到达边界,文本开始滚动
时刻 t2: [ello Worl|d]         平移头保持在边界
时刻 t3: [lo World  |]         平移头领先文本

4.2 核心逻辑(在 u8g2_MenuSelectorCall 中)

if (u8g2_menu->currentDrawItem == u8g2_menu->currentItem)
{
    if (u8g2_menu->currentItemWidth > w)  // 宽度超屏
    {
        u8g2_menu->positionOffset -= u8g2_menu->positionOffset_spe;

        // 判断是否完成一次滚动(回到起点)
        if (u8g2_menu->positionOffset * maxCharWidth
            + u8g2_menu->currentItemWidth
            <= u8g2_menu->positionOffset_strHeaderLen)
        {
            u8g2_menu->positionOffset
                = u8g2_menu->positionOffset_strHeaderLen;  // 循环
        }

        // 转换为实际像素偏移
        if (u8g2_menu->positionOffset > 0)
            u8g2_menu->_positionOffset = 0;  // 平移头阶段
        else if (...<= w)
            u8g2_menu->_positionOffset = (float)(w - itemWidth) / maxCharWidth;
        else
            u8g2_menu->_positionOffset = u8g2_menu->positionOffset;
    }
}

5. 消息框系统

5.1 通用接口

void u8g2_MenuDrawMessageBox(u8g2_menu_t *u8g2_menu,
    u8g2_MenuDrawMessageBox_cb drawMessageBox,
    void *message,
    u8g2_uint_t messageBoxWidth, u8g2_uint_t messageBoxHeight,
    uint32_t drawMessageBoxTimer);

消息框内容由回调函数绘制,内容数据通过 void *message 传递——可以是字符串、结构体、图片数据。

5.2 快捷方式

// 文字弹窗
void u8g2_MenuDrawMessageBox_str(u8g2_menu_t *u8g2_menu,
    const char *str, uint32_t drawMessageBoxTimer);

// 图片弹窗
void u8g2_MenuDrawMessageBox_xbm(u8g2_menu_t *u8g2_menu,
    u8g2_uint_t w, u8g2_uint_t h, const uint8_t *bitmap,
    uint32_t drawMessageBoxTimer);

5.3 超时机制

void u8g2_MenuMessageBoxTime_ISR(u8g2_menu_t *u8g2_menu, uint16_t ms)
{
    if (u8g2_menu->drawMessageBoxTimer != U8G2_MENU_INFINITE_TIMEOUT) {
        if (ms < u8g2_menu->drawMessageBoxTimerLeft)
            u8g2_menu->drawMessageBoxTimerLeft -= ms;
        else
            u8g2_menu->drawMessageBoxTimerLeft = 0;
    }
}

drawMessageBoxTimerLeft 减到 0 时,消息框自动关闭。U8G2_MENU_INFINITE_TIMEOUT = UINT32_MAX 表示永不超时。

5.4 消息框绘制调度

// 在 u8g2_DrawMenu 末尾
u8g2_menuMessageBoxCall(u8g2_menu);

消息框在菜单内容绘制之后渲染,覆盖在菜单上方。如果设置了超时,时间由 u8g2_MenuTime_ISR 驱动。


6. 记录功能:文本化菜单内容

#define U8G2_MENU_RECORD      1
#define U8G2_MENU_RECORD_SIZE 256

void u8g2_MenuRecordClear(u8g2_menu_t *u8g2_menu) {
    u8g2_menu->u8g2_menuRecordLen = 0;
    u8g2_menu->u8g2_menuRecord[0] = 0;
}

void u8g2_MenuRecordAdd(u8g2_menu_t *u8g2_menu, const char *text) {
    while (*text && u8g2_menu->u8g2_menuRecordLen < U8G2_MENU_RECORD_SIZE - 1)
        u8g2_menu->u8g2_menuRecord[u8g2_menu->u8g2_menuRecordLen++] = *text++;
    // 超长截断,加 "..."
    u8g2_menu->u8g2_menuRecord[u8g2_menu->u8g2_menuRecordLen] = '\0';
}

u8g2_DrawMenu 中,每次绘制都会自动记录:

u8g2_MenuRecordClear(u8g2_menu);
u8g2_MenuRecordAdd(u8g2_menu, "\r\n---------------");
u8g2_menu->menuItem();  // 用户回调中的绘制被记录
u8g2_MenuRecordAdd(u8g2_menu, "\r\n");

选择器状态用符号区分:(不可调)、(可调)、==(正在编辑)。用户可以调用 u8g2_MenuRecord() 获取字符串——比如通过串口输出到 PC。


7. menuEnter/Leave 弱定义回调

WEAK void u8g2_menuItemEnter_weak(u8g2_menu_t *u8g2_menu,
                                   u8g2_uint_t item) { /* 空 */ }
WEAK void u8g2_menuItemLeave_weak(u8g2_menu_t *u8g2_menu,
                                   u8g2_uint_t item) { /* 空 */ }

u8g2_DrawMenu 中每次检测到选中项变化时触发:

if (u8g2_menu->currentItemLog != u8g2_menu->currentItem) {
    u8g2_menuItemLeave_weak(u8g2_menu, u8g2_menu->currentItemLog);
    u8g2_menuItemEnter_weak(u8g2_menu, u8g2_menu->currentItem);
}

典型用途:选中新菜单项时蜂鸣器短鸣、LED 闪烁,或触发传感器采样。


8. 小结

系统 核心技巧 限制
子菜单追溯 函数指针数组模拟栈 最大 16 层
效果器 可替换回调接口 需要用户理解接口契约
超长文本滚动 "平移头"追击算法 仅水平滚动
消息框 通用回调 + 超时倒计时 依赖 ISR 时间基准
记录 文本缓冲 + 超长截断 256 字节上限
Enter/Leave 弱定义钩子 用户需自行重写

核心教训:这些"高级功能"的代码量都不大(每个模块 100~300 行),但投入产出比极高——因为它们解决的都是"用的时候才发现缺"的问题。16 层追溯在 99% 的场景下够用,但如果做一个文件管理器式的菜单树,就需要评估是否放宽限值。

下一篇——最后一篇——我们将把所有这些模块打包成一个正式的开源项目,处理跨编译器兼容、配置宏系统、开源许可证和工程化。


上一篇:从零构建嵌入式菜单库(五):图表、图片、控件与图层

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐