18、状态机在用户界面中的应用:菜单导航状态机、触摸屏手势识别

用户界面开发,说白了就是跟「状态」打交道。你按下一个按钮,界面变了;你滑动一下屏幕,内容滚了。这些变化背后,其实都是一套清晰的状态迁移逻辑。我个人习惯用状态机来管理UI,因为它让代码变得可预测、可测试。

今天咱们就聊聊两个实战场景:菜单导航和触摸屏手势识别。这两个场景,我都在产品里实际跑过,踩过坑,也总结了一些经验。

18.1 菜单导航状态机

先说说菜单导航。你想想看,一个嵌入式设备的菜单系统,无非就是几个状态:主菜单、子菜单、设置项、返回。用状态机来建模,再合适不过了。

核心思路:每个菜单页面是一个状态,按键或触摸事件是触发条件,状态迁移就是页面跳转。

18.1.1 状态定义

我一般这样定义菜单状态:

typedef enum {
    MENU_MAIN,      // 主菜单
    MENU_SETTINGS,  // 设置菜单
    MENU_DISPLAY,   // 显示设置
    MENU_SOUND,     // 声音设置
    MENU_ABOUT,     // 关于
    MENU_IDLE       // 空闲/待机
} MenuState_t;

嗯,这里要注意,状态枚举的顺序最好跟实际导航逻辑对应,方便后面查表。

18.1.2 事件定义

事件就是用户的操作。我习惯用位掩码来定义,方便组合判断:

typedef enum {
    EVT_NONE    = 0x00,
    EVT_UP      = 0x01,
    EVT_DOWN    = 0x02,
    EVT_LEFT    = 0x04,
    EVT_RIGHT   = 0x08,
    EVT_ENTER   = 0x10,
    EVT_BACK    = 0x20,
    EVT_TIMEOUT = 0x40
} MenuEvent_t;

18.1.3 状态迁移表

我个人最喜欢用表格来定义状态迁移,清晰、好维护。你看这个:

当前状态 事件 下一状态 动作
MENU_MAIN EVT_DOWN MENU_SETTINGS 高亮下一项
MENU_MAIN EVT_ENTER MENU_SETTINGS 进入子菜单
MENU_SETTINGS EVT_BACK MENU_MAIN 返回上级
MENU_SETTINGS EVT_DOWN MENU_DISPLAY 高亮下一项
MENU_DISPLAY EVT_BACK MENU_SETTINGS 返回上级
MENU_ANY EVT_TIMEOUT MENU_IDLE 进入待机

看到最后一行没?「MENU_ANY」表示通配状态,任何状态下超时都回到待机。这个技巧我在项目中用过很多次,省了不少重复代码。

18.1.4 查表实现

有了迁移表,代码就简单了。我直接用一个二维数组来查:

typedef struct {
    MenuState_t  nextState;
    void (*action)(void);
} Transition_t;

// 状态迁移表:[当前状态][事件]
static const Transition_t menuTable[MENU_IDLE + 1][EVT_TIMEOUT + 1] = {
    [MENU_MAIN] = {
        [EVT_DOWN]  = {MENU_SETTINGS, highlightNext},
        [EVT_ENTER] = {MENU_SETTINGS, enterSubMenu},
        [EVT_TIMEOUT] = {MENU_IDLE, enterIdle}
    },
    [MENU_SETTINGS] = {
        [EVT_BACK]  = {MENU_MAIN, goBack},
        [EVT_DOWN]  = {MENU_DISPLAY, highlightNext},
        [EVT_TIMEOUT] = {MENU_IDLE, enterIdle}
    },
    // ... 其他状态
};

MenuState_t processMenuEvent(MenuState_t current, MenuEvent_t evt) {
    Transition_t t = menuTable[current][evt];
    if (t.action) {
        t.action();  // 执行动作
    }
    return t.nextState;
}

小技巧:如果事件无效(比如主菜单下按LEFT),就把迁移表的下一状态设为自己,动作设为空。这样代码不会崩溃,用户操作也没反应,体验上很自然。

18.2 触摸屏手势识别

触摸屏的手势识别,其实也是一个状态机。你想想,一个手指从按下到抬起,中间经历了什么?按下、移动、可能还有长按、滑动、双击……每个阶段都是一个状态。

我曾经在一个智能家居面板上做手势识别,一开始用if-else硬写,结果代码越改越乱。后来换成状态机,一周就搞定了。

18.2.1 手势状态机模型

我定义的手势状态机长这样:

typedef enum {
    GESTURE_IDLE,       // 空闲
    GESTURE_TOUCH,      // 触摸(刚按下)
    GESTURE_DRAG,       // 拖动中
    GESTURE_LONG_PRESS, // 长按
    GESTURE_RELEASE     // 释放(过渡状态)
} GestureState_t;

事件呢,就是触摸屏的原始数据:

typedef enum {
    TOUCH_DOWN,   // 按下
    TOUCH_MOVE,   // 移动
    TOUCH_UP,     // 抬起
    TOUCH_TIMEOUT // 超时
} TouchEvent_t;

18.2.2 状态迁移逻辑

这里我画了一张图,把整个识别流程串起来:

触摸屏手势识别状态机 GESTURE_IDLE GESTURE_TOUCH GESTURE_DRAG GESTURE_LONG_PRESS GESTURE_RELEASE TOUCH_DOWN TOUCH_MOVE TOUCH_TIMEOUT TOUCH_UP TOUCH_UP 自动回到 IDLE TOUCH_UP(单击)

你看这张图,从IDLE开始,手指按下进入TOUCH状态。如果手指移动了,就进入DRAG;如果一直没动且超时了,就进入LONG_PRESS。手指抬起时,统一进入RELEASE,然后自动回到IDLE。

注意:TOUCH状态下如果直接抬起(不移动、不超时),那就是一次「单击」。这个逻辑在图上我用红色虚线标出来了。很多新手会漏掉这个分支,导致单击事件识别不出来。

18.2.3 代码实现

代码实现上,我习惯用一个结构体来保存手势上下文:

typedef struct {
    GestureState_t state;
    uint32_t       touchStartTime;  // 按下时的时间戳
    uint16_t       startX, startY;  // 按下时的坐标
    uint16_t       lastX, lastY;    // 上一次的坐标
    uint16_t       thresholdMove;   // 移动阈值(像素)
    uint32_t       longPressTime;   // 长按阈值(毫秒)
} GestureContext_t;

GestureResult_t processTouch(GestureContext_t *ctx, TouchEvent_t evt, 
                             uint16_t x, uint16_t y, uint32_t now) {
    switch (ctx->state) {
        case GESTURE_IDLE:
            if (evt == TOUCH_DOWN) {
                ctx->touchStartTime = now;
                ctx->startX = x;
                ctx->startY = y;
                ctx->state = GESTURE_TOUCH;
            }
            break;
            
        case GESTURE_TOUCH:
            if (evt == TOUCH_MOVE) {
                uint16_t dx = abs(x - ctx->startX);
                uint16_t dy = abs(y - ctx->startY);
                if (dx > ctx->thresholdMove || dy > ctx->thresholdMove) {
                    ctx->state = GESTURE_DRAG;
                    // 触发拖动开始回调
                    onDragStart(ctx->startX, ctx->startY);
                }
            } else if (evt == TOUCH_UP) {
                ctx->state = GESTURE_IDLE;
                return GESTURE_RESULT_CLICK;  // 单击
            } else if (evt == TOUCH_TIMEOUT) {
                ctx->state = GESTURE_LONG_PRESS;
                onLongPress(ctx->startX, ctx->startY);
            }
            break;
            
        case GESTURE_DRAG:
            if (evt == TOUCH_MOVE) {
                onDragMove(x, y);
            } else if (evt == TOUCH_UP) {
                ctx->state = GESTURE_IDLE;
                onDragEnd(x, y);
                return GESTURE_RESULT_DRAG;
            }
            break;
            
        case GESTURE_LONG_PRESS:
            if (evt == TOUCH_UP) {
                ctx->state = GESTURE_IDLE;
                return GESTURE_RESULT_LONG_PRESS;
            }
            break;
            
        default:
            break;
    }
    return GESTURE_RESULT_NONE;
}

避坑指南:我曾经在DRAG状态下忘记更新lastX/lastY,结果每次移动都从起点算位移,导致手势识别特别「跳」。后来加了个ctx->lastX = x; ctx->lastY = y; 就稳了。细节决定成败啊。

18.3 状态机在UI中的设计原则

做了这么多年UI状态机,我总结了几条原则,分享给你:

  1. 状态要少而精——每个状态代表一个「用户能感知到的界面形态」。别把内部计算状态也塞进来。
  2. 事件要明确——事件来自用户操作或系统定时器,不要混入业务逻辑事件。
  3. 动作要轻量——状态迁移时执行的动作,只做UI更新,别做耗时计算。
  4. 要有默认处理——每个状态都要处理所有可能的事件,处理不了的就忽略。
  5. 可测试性——状态机代码应该能脱离硬件单独测试。我经常写单元测试来验证迁移表。

嗯,说到测试,我记得有一次在客户现场调试,菜单在某个子页面下按返回键没反应。我查了半天,发现是迁移表里漏了一行。从那以后,我每次写完状态机都会画一张完整的迁移图,对着图检查一遍代码。

好了,菜单导航和手势识别这两个场景,说白了都是「状态 + 事件 + 迁移」的套路。你掌握了这个思路,以后做任何UI交互都能游刃有余。


公众号:蓝海资料掘金营,微信deep3321