UI设计工具文档UI设计工具文档
反馈钉钉群
  • AC791
  • AC792
  • AC63
  • GPMCU
  • AD14/15/17/18/AD104
  • AD16
  • AD24
  • AC82
  • AW30
  • AW31
反馈钉钉群
  • AC791
  • AC792
  • AC63
  • GPMCU
  • AD14/15/17/18/AD104
  • AD16
  • AD24
  • AC82
  • AW30
  • AW31
  • 1.工具前言

    • 1.1.工具首页
    • 1.2.前言说明
  • 2.快速使用

    • 2.1.快速使用
    • 2.2.快速上手
    • 2.3.板子配置
    • 2.4.视频教程
    • 2.5.硬件资料
  • 3.控件说明

    • 3.1.控件说明
    • 3.2.基础控件

      • 页面
      • 按钮
      • 图片按钮
      • 标签
      • 图片
      • 文本框
      • 开关
      • 数字微调器
      • 复选框
      • 下拉框
      • 进度条
      • Lottie动画
      • 帧动画
      • 图表
      • 滑动条
      • 弧线
      • 仪表盘
      • 相册
    • 3.3.菜单控件

      • 圆弧菜单
      • 齿轮菜单
      • 列表菜单
      • 曲线菜单
      • 网格菜单
      • 万花筒菜单
      • 多边形菜单
      • 轮盘菜单
    • 3.4.容器控件

      • 通用容器
      • Flex布局
  • 4.高级功能

    • 4.1.时间轴动画
    • 4.2.模型绑定
    • 4.3.资源管理
    • 4.4.国际化
    • 4.5.硬件仿真
    • 4.6.自动化测试
    • 4.7.控件组
    • 4.8.菜单管理
    • 4.9.页面管理
    • 4.10.主题
    • 4.11.动态页面
  • 5.插件系统

    • 5.1.插件说明
    • 5.2.开发指南
    • 5.3.字体合并
    • 5.4.图片编辑
    • 5.5.截图
    • 5.6.项目合并
    • 5.7.项目属性编辑
    • 5.8.视频转图片
    • 5.9.图片转换
  • 6.使用案例

    • 6.1.倒计时案例
    • 6.2.键盘和鼠标滚轮控制菜单滑动
    • 6.3.自定义Symbol
    • 6.4.暗色键盘主题
  • 7.常见问题

    • 7.1.问题说明
    • 7.2.编译问题
    • 7.3.LVGL问题
    • 7.4.仿真问题
    • 7.5.UI工具问题
    • 7.6.其他问题
  • 8.工具杂项

    • 8.1.杂项1
    • 8.2.杂项2
    • 8.3.工具生成API接口

键盘和鼠标滚轮控制菜单滑动

使用 网格菜单 和 列表菜单 实现菜单循环滚动,键盘方向键和鼠标滚轮控制菜单滑动,菜单项点击事件触发菜单项对应的事件。

本章目录
  • 1. 菜单配置
  • 2. 事件配置
  • 3. 创建控件
  • 4. 实现事件代码
    • 4.1. 代码逻辑说明
  • 5. 仿真
    • 5.1. 网格菜单
    • 5.2. 列表菜单
本章正文

菜单配置

参考 菜单 来配置这样一份菜单。

事件配置

在 工程 - 事件 上,点击 添加 按钮,填写事件信息,添加好下面这几个事件,之后会在custom/custom.c文件中实现这些事件的代码。

/**
 * 菜单项点击事件
 */
void menu_item_press_event_cb(lv_event_t * e);
/**
 * 在页面加载完成后,将焦点设置为当前对象,并进入编辑模式
 */
void focus_in_editing_event_cb(lv_event_t * e);
/**
 * 列表菜单按键回调函数,根据列表菜单的滚动方向和按键/滚轮产生的事件进行不同的处理
 */
void listmenu_key_cb(lv_event_t * e);
/**
 * 网格菜单按键回调函数,根据网格滚动方向和按键/滚轮产生的事件进行不同的处理
 */
void gridmenu_key_cb(lv_event_t * e);

创建控件

  • 创建了 home 和 page1 页面,分别添加了 网格菜单 和 列表菜单 控件。

  • 网格菜单 和 列表菜单 按照如下方式配置控件属性

  • 网格菜单 绑定上 menu_item_press_event_cb 、 gridmenu_key_cb 事件。

  • 列表菜单 绑定上 menu_item_press_event_cb 、 listmenu_key_cb 事件。

  • home 和 page1 页面 绑定 focus_in_editing_event_cb 事件,让页面在加载完成后,将焦点设置为目标菜单对象,并进入编辑模式,方便键盘和鼠标滚轮能够正常触发、发送Key事件给目标菜单对象。

实现事件代码

在 custom/custom.c 文件中,实现上面配置的事件代码,具体实现如下:

static void listmenu_idle_timer_cb(lv_timer_t * t);
static void gridmenu_idle_timer_cb(lv_timer_t * t);
static void menu_item_click_cb(lv_obj_t * obj);

typedef struct {
    uint32_t last_key;
    uint32_t last_ts;
    uint8_t speed_level;
} key_scroll_state_t;

static key_scroll_state_t s_listmenu_scroll_state; // 列表菜单滚动状态
static lv_timer_t * s_listmenu_idle_tmr = NULL; // 列表菜单空闲定时器

static key_scroll_state_t s_gridmenu_scroll_state; // 网格菜单滚动状态
static lv_timer_t * s_gridmenu_idle_tmr = NULL; // 网格菜单空闲定时器

#define GRIDMENU_IDLE_STOP_MS 200 // 按键/滚轮停止后,最多200ms内停住
#define LISTMENU_IDLE_STOP_MS 200

// 用于跟踪按下位置和滑动状态的静态变量
static lv_point_t press_point = {0, 0};
static bool is_dragging = false;
static lv_obj_t * pressed_btn = NULL;
#define DRAG_THRESHOLD 10 // 滑动阈值,超过这个距离认为是滑动而不是点击
#define LONG_PRESS_THRESHOLD 500 // 长按判定阈值(毫秒)
static uint32_t press_start_tick = 0;
static bool is_long_press = false;

void menu_item_press_event_cb(lv_event_t * e)
{
    lv_event_code_t code = lv_event_get_code(e);
    lv_obj_t * obj = lv_event_get_target(e);
    lv_indev_t * indev = lv_indev_get_act();

    if(!indev)
        return;

    switch(code) {
        case LV_EVENT_PRESSED: {
                // 记录按下时的位置
                lv_indev_get_point(indev, &press_point);
                is_dragging = false;
                is_long_press = false;
                press_start_tick = lv_tick_get();
                pressed_btn = obj;
                break;
            }

        case LV_EVENT_PRESSING: {
                // 检查是否已经开始滑动
                if(!is_dragging && pressed_btn == obj) {
                    lv_point_t current_point;
                    lv_indev_get_point(indev, &current_point);

                    // 计算滑动距离
                    int32_t dx = LV_ABS(current_point.x - press_point.x);
                    int32_t dy = LV_ABS(current_point.y - press_point.y);

                    // 如果滑动距离超过阈值,标记为滑动状态
                    if(dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
                        is_dragging = true;
                    }
                    // 时间超过长按阈值则视为长按
                    if(!is_long_press &&
                       lv_tick_elaps(press_start_tick) >= LONG_PRESS_THRESHOLD) {
                        is_long_press = true;
                    }
                }
                break;
            }

        case LV_EVENT_LONG_PRESSED:
        case LV_EVENT_LONG_PRESSED_REPEAT: {
                // 内置的长按事件也标记为长按,阻止触发点击
                if(pressed_btn == obj) {
                    is_long_press = true;
                }
                break;
            }

        case LV_EVENT_RELEASED: {
                // 只有在没有滑动的情况下才触发点击事件
                if(!is_dragging && !is_long_press && pressed_btn == obj) {
                    menu_item_click_cb(obj);
                }

                // 重置状态
                is_dragging = false;
                is_long_press = false;
                pressed_btn = NULL;
                break;
            }

        default:
            break;
    }
}

/**
 * @brief 在页面加载完成后,将焦点设置为当前对象,并进入编辑模式
 * @param e 事件指针
 */
void focus_in_editing_event_cb(lv_event_t * e)
{
    lv_obj_t *obj = lv_event_get_target(e);
    lv_group_t *group = lv_group_get_default();
    if(!group) return;

    if(lv_group_get_focused(group) != obj) {
        lv_group_focus_obj(obj);
    }
    /* 让group处于编辑模式,这样鼠标滚轮滚动时,就会发送LV_EVENT_KEY事件给聚焦的控件了 */
    lv_group_set_editing(group, true);
}

/**
 * @brief
 * 列表菜单按键回调函数,根据列表菜单的滚动方向和按键/滚轮产生的事件进行不同的处理
 * @param e 事件指针
 */
void listmenu_key_cb(lv_event_t * e)
{
    lv_obj_t * menu = lv_event_get_target(e);
    uint32_t raw_key = lv_event_get_key(e);
    uint8_t from_encoder = 0;
    uint32_t key = raw_key;
    LV_LOG_WARN("listmenu_key_cb: raw_key = %d, key = %d", raw_key, key);

    /* 将编码器的左右旋转键映射为上下滚动,并标记来自编码器 */
    if(raw_key == LV_KEY_LEFT) {
        key = LV_KEY_UP;
        from_encoder = 1;
    }
    else if(raw_key == LV_KEY_RIGHT) {
        key = LV_KEY_DOWN;
        from_encoder = 1;
    }

    if(key != LV_KEY_UP && key != LV_KEY_DOWN) {
        return;
    }

    uint32_t now = lv_tick_get();
    uint32_t elapsed = now - s_listmenu_scroll_state.last_ts;
    const uint32_t accel_threshold = 300;

    if(key == s_listmenu_scroll_state.last_key && elapsed < accel_threshold) {
        if(s_listmenu_scroll_state.speed_level < 3) {
            s_listmenu_scroll_state.speed_level++;
        }
    }
    else {
        s_listmenu_scroll_state.speed_level = 0;
    }

    s_listmenu_scroll_state.last_key = key;
    s_listmenu_scroll_state.last_ts = now;

    lv_coord_t pad_row = lv_obj_get_style_pad_row(menu, LV_PART_MAIN);

    /* 获取首个可见项高度 */
    lv_coord_t item_h = 0;
    uint32_t child_cnt = lv_obj_get_child_cnt(menu);
    for(uint32_t i = 0; i < child_cnt; i++) {
        lv_obj_t * child = lv_obj_get_child(menu, i);
        if(lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN))
            continue;
        item_h = lv_obj_get_height(child);
        break;
    }
    if(item_h == 0)
        return;

    /* 键盘方向键固定一步,编码器按加速倍数 */
    int32_t step_mul =
        from_encoder ? (1 << s_listmenu_scroll_state.speed_level) : 1;

    /* 直接对齐到最近中心并步进到下一个中心,随后交给内部动画 */
    lv_coord_t row_h = item_h + pad_row;
    lv_coord_t center_y = lv_obj_get_height(menu) / 2;
    lv_coord_t cur_offset = lv_listmenu_get_scroll_offset(menu);

    /* 计算当前对齐到哪一行(使用四舍五入) */
    int32_t num = (int32_t)center_y - (int32_t)cur_offset - (int32_t)(item_h / 2);
    int32_t k_cur =
        (num >= 0) ? (num + row_h / 2) / row_h : (num - row_h / 2) / row_h;

    /* 计算目标行 */
    int32_t k_tar = (key == LV_KEY_UP) ? (k_cur + step_mul) : (k_cur - step_mul);

    /* 计算目标偏移量 */
    lv_coord_t target_offset = center_y - (k_tar * row_h + item_h / 2);

    /* 选择速度 */
    static const uint32_t speed_table[] = {300, 350, 400, 500};
    uint32_t speed_idx = s_listmenu_scroll_state.speed_level;
    if(speed_idx >= (sizeof(speed_table) / sizeof(speed_table[0]))) {
        speed_idx = (sizeof(speed_table) / sizeof(speed_table[0])) - 1;
    }
    uint32_t speed = speed_table[speed_idx];

    lv_listmenu_set_scroll_offset(menu, target_offset, true, speed);

    s_listmenu_scroll_state.last_ts = lv_tick_get();
    if(s_listmenu_idle_tmr == NULL) {
        s_listmenu_idle_tmr =
            lv_timer_create(listmenu_idle_timer_cb, LISTMENU_IDLE_STOP_MS, menu);
    }
    else {
        s_listmenu_idle_tmr->user_data = menu;
        lv_timer_reset(s_listmenu_idle_tmr);
        lv_timer_resume(s_listmenu_idle_tmr);
    }
}

/**
 * @brief 列表菜单空闲定时器回调函数
 * 在一段时间不再收到按键/滚轮后,立即停止滚动并吸附
 * @param t 定时器对象指针
 */
static void listmenu_idle_timer_cb(lv_timer_t * t)
{
    lv_obj_t * menu = (lv_obj_t *)t->user_data;
    uint32_t now = lv_tick_get();
    if(now - s_listmenu_scroll_state.last_ts >= LISTMENU_IDLE_STOP_MS) {
        lv_listmenu_stop_scroll(menu, true, 300);
        lv_timer_pause(t);
    }
}

/**
 * @brief
 * 网格菜单按键回调函数,根据网格菜单的滚动方向和按键/滚轮产生的事件进行不同的处理
 * @param e 事件指针
 */
void gridmenu_key_cb(lv_event_t * e)
{
    lv_obj_t * menu = lv_event_get_target(e);
    uint32_t raw_key = lv_event_get_key(e);
    uint8_t from_encoder = 0;
    uint32_t key = raw_key;

    lv_dir_t dir = lv_gridmenu_get_scroll_dir(menu);

    /* 针对垂直网格:将编码器的左右映射为上下;针对水平网格:接受滚轮产生的
     * UP/DOWN 并映射为 LEFT/RIGHT */
    if((dir & LV_DIR_VER) && !(dir & LV_DIR_HOR)) {
        if(raw_key == LV_KEY_LEFT) {
            key = LV_KEY_UP;
            from_encoder = 1;
        }
        else if(raw_key == LV_KEY_RIGHT) {
            key = LV_KEY_DOWN;
            from_encoder = 1;
        }
        if(key != LV_KEY_UP && key != LV_KEY_DOWN)
            return;
    }
    else if((dir & LV_DIR_HOR) && !(dir & LV_DIR_VER)) {
        if(key == LV_KEY_UP)
            key = LV_KEY_LEFT; /* 将滚轮上映射为向左 */
        else if(key == LV_KEY_DOWN)
            key = LV_KEY_RIGHT; /* 将滚轮下映射为向右 */
        if(key != LV_KEY_LEFT && key != LV_KEY_RIGHT)
            return;
        if(raw_key == LV_KEY_LEFT || raw_key == LV_KEY_RIGHT)
            from_encoder = 1;
    }
    else {
        /* 未明确的滚动方向,不处理 */
        return;
    }

    uint32_t now = lv_tick_get();
    uint32_t elapsed = now - s_gridmenu_scroll_state.last_ts;
    const uint32_t accel_threshold = 300;

    if(key == s_gridmenu_scroll_state.last_key && elapsed < accel_threshold) {
        if(s_gridmenu_scroll_state.speed_level < 3)
            s_gridmenu_scroll_state.speed_level++;
    }
    else {
        s_gridmenu_scroll_state.speed_level = 0;
    }
    s_gridmenu_scroll_state.last_key = key;
    s_gridmenu_scroll_state.last_ts = now;

    /* 直接基于行高/列宽进行偏移步进,避免依赖“可见项索引” */
    bool hex = lv_gridmenu_is_hex_layout(menu);
    lv_coord_t pad_row = lv_obj_get_style_pad_row(menu, LV_PART_ITEMS);
    lv_coord_t pad_col = lv_obj_get_style_pad_column(menu, LV_PART_ITEMS);

    /* 获取首个可见项尺寸 */
    lv_coord_t item_w = 0, item_h = 0;
    uint32_t child_cnt = lv_obj_get_child_cnt(menu);
    for(uint32_t i = 0; i < child_cnt; i++) {
        lv_obj_t * child = lv_obj_get_child(menu, i);
        if(lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN))
            continue;
        item_w = lv_obj_get_width(child);
        item_h = lv_obj_get_height(child);
        break;
    }
    if(item_w == 0 || item_h == 0)
        return;

    /* 键盘方向键固定一步,编码器按加速倍数 */
    int32_t step_mul =
        from_encoder ? (1 << s_gridmenu_scroll_state.speed_level) : 1;

    /* 直接对齐到最近中心并步进到下一个中心,随后交给内部动画) */
    lv_point_t off = {
        .x = lv_gridmenu_get_scroll_hor_offset(menu),
        .y = lv_gridmenu_get_scroll_ver_offset(menu),
    };

    if((dir & LV_DIR_VER) && !(dir & LV_DIR_HOR)) {
        lv_coord_t row_h = hex ? (lv_coord_t)(item_h - 0.14f * item_w + pad_row)
                           : (lv_coord_t)(item_h + pad_row);
        lv_coord_t center_y = lv_obj_get_height(menu) / 2;
        int32_t num = (int32_t)center_y - (int32_t)off.y - (int32_t)(item_h / 2);
        int32_t k_cur = (num >= 0) ? (num + row_h / 2) / row_h
                        : (num - row_h / 2) / row_h; /* round */
        int32_t k_tar =
            (key == LV_KEY_UP) ? (k_cur + step_mul) : (k_cur - step_mul);
        off.y = center_y - (k_tar * row_h + item_h / 2);
    }
    else if((dir & LV_DIR_HOR) && !(dir & LV_DIR_VER)) {
        lv_coord_t col_w = item_w + pad_col;
        lv_coord_t center_x = lv_obj_get_width(menu) / 2;
        int32_t num = (int32_t)center_x - (int32_t)off.x - (int32_t)(item_w / 2);
        int32_t k_cur = (num >= 0) ? (num + col_w / 2) / col_w
                        : (num - col_w / 2) / col_w; /* round */
        int32_t k_tar =
            (key == LV_KEY_LEFT) ? (k_cur + step_mul) : (k_cur - step_mul);
        off.x = center_x - (k_tar * col_w + item_w / 2);
    }
    else {
        return;
    }

    static const uint32_t speed_table[] = {300, 350, 400, 500}; /* px/s */
    uint32_t idx = s_gridmenu_scroll_state.speed_level;
    if(idx >= (sizeof(speed_table) / sizeof(speed_table[0])))
        idx = (sizeof(speed_table) / sizeof(speed_table[0])) - 1;
    uint32_t speed = speed_table[idx];

    lv_gridmenu_set_scroll_offset(menu, off, true, speed);

    /* 触发/续期“空闲停止”定时器:在一段时间不再收到按键/滚轮后,立即停止滚动并吸附
     */
    s_gridmenu_scroll_state.last_ts = lv_tick_get();
    if(s_gridmenu_idle_tmr == NULL) {
        s_gridmenu_idle_tmr =
            lv_timer_create(gridmenu_idle_timer_cb, GRIDMENU_IDLE_STOP_MS, menu);
    }
    else {
        s_gridmenu_idle_tmr->user_data = menu;
        lv_timer_reset(s_gridmenu_idle_tmr);
        lv_timer_resume(s_gridmenu_idle_tmr);
    }
}

/**
 * @brief 网格菜单空闲定时器回调函数
 * 在一段时间不再收到按键/滚轮后,立即停止滚动并吸附
 * @param t 定时器对象指针
 */
static void gridmenu_idle_timer_cb(lv_timer_t * t)
{
    lv_obj_t * menu = (lv_obj_t *)t->user_data;
    uint32_t now = lv_tick_get();
    if(now - s_gridmenu_scroll_state.last_ts >= GRIDMENU_IDLE_STOP_MS) {
        /* 停止滚动并就地吸附,模拟“旋钮松手即停” */
        lv_gridmenu_stop_scroll(menu, true, 300);
        lv_timer_pause(t);
    }
}

/**
 * @brief 菜单项点击事件回调函数,根据菜单项的user_data进行不同的处理
 * @param obj 菜单项对象
 */
static void menu_item_click_cb(lv_obj_t * obj)
{
    char * user_data = (char *)lv_obj_get_user_data(obj);
    if(user_data == NULL) {
        LV_LOG_WARN("user_data is NULL");
        return;
    }

    if(strcmp(user_data, "item") == 0) {
        LV_LOG_WARN("item");
    }
    else if(strcmp(user_data, "item1") == 0) {
        LV_LOG_WARN("item1");
    }
    else if(strcmp(user_data, "item2") == 0) {
        LV_LOG_WARN("item2");
    }
    else if(strcmp(user_data, "item3") == 0) {
        LV_LOG_WARN("item3");
    }
    else if(strcmp(user_data, "item4") == 0) {
        LV_LOG_WARN("item4");
    }
    else if(strcmp(user_data, "item5") == 0) {
        LV_LOG_WARN("item5");
    }
    else if(strcmp(user_data, "item6") == 0) {
        LV_LOG_WARN("item6");
    }
    else if(strcmp(user_data, "item7") == 0) {
        LV_LOG_WARN("item7");
    }
    else if(strcmp(user_data, "item8") == 0) {
        LV_LOG_WARN("item8");
    }
    else if(strcmp(user_data, "item9") == 0) {
        LV_LOG_WARN("item9");
    }
    else if(strcmp(user_data, "item10") == 0) {
        LV_LOG_WARN("item10");
    }
    else if(strcmp(user_data, "item11") == 0) {
        LV_LOG_WARN("item11");
    }
    else if(strcmp(user_data, "item12") == 0) {
        LV_LOG_WARN("item12");
    }
    else if(strcmp(user_data, "item13") == 0) {
        LV_LOG_WARN("item13");
    }
    else if(strcmp(user_data, "item14") == 0) {
        LV_LOG_WARN("item14");
    }
    else if(strcmp(user_data, "item15") == 0) {
        LV_LOG_WARN("item15");
    }
    else if(strcmp(user_data, "item16") == 0) {
        LV_LOG_WARN("item16");
    }
    else if(strcmp(user_data, "item17") == 0) {
        LV_LOG_WARN("item17");
    }
    else if(strcmp(user_data, "item18") == 0) {
        LV_LOG_WARN("item18");
    }
    else if(strcmp(user_data, "item19") == 0) {
        LV_LOG_WARN("item19");
    }
    else if(strcmp(user_data, "item20") == 0) {
        LV_LOG_WARN("item20");
    }
}

代码逻辑说明

  • 状态变量:key_scroll_state_t 记录最近一次按键与加速度档位;s_listmenu_idle_tmr / s_gridmenu_idle_tmr 用于“按键停止后立即吸附”的定时器;按压/滑动相关的静态变量用来区分点击、滑动与长按。
  • menu_item_press_event_cb:跟踪按下位置与时长,过滤滑动和长按,只在真正点击时调用 menu_item_click_cb。
  • focus_in_editing_event_cb:页面加载完成后,将焦点设置为当前对象,并进入编辑模式。
  • listmenu_key_cb / listmenu_idle_timer_cb:把编码器左右映射为上下滚动,按连续触发自动提速,基于行高计算目标偏移并启动滚动动画,空闲 200ms 后定时器触发停止并吸附。
  • gridmenu_key_cb / gridmenu_idle_timer_cb:根据网格滚动方向把编码器或滚轮映射为横/竖滚动,支持六边形布局行高计算,同样带加速、动画滚动和空闲吸附逻辑。
  • menu_item_click_cb:按 user_data 分支输出日志,实际项目可在各分支里替换为真实业务处理。

仿真

网格菜单

列表菜单

上一页
6.1.倒计时案例
下一页
6.3.自定义Symbol