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 状态迁移逻辑
这里我画了一张图,把整个识别流程串起来:
你看这张图,从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状态机,我总结了几条原则,分享给你:
- 状态要少而精——每个状态代表一个「用户能感知到的界面形态」。别把内部计算状态也塞进来。
- 事件要明确——事件来自用户操作或系统定时器,不要混入业务逻辑事件。
- 动作要轻量——状态迁移时执行的动作,只做UI更新,别做耗时计算。
- 要有默认处理——每个状态都要处理所有可能的事件,处理不了的就忽略。
- 可测试性——状态机代码应该能脱离硬件单独测试。我经常写单元测试来验证迁移表。
嗯,说到测试,我记得有一次在客户现场调试,菜单在某个子页面下按返回键没反应。我查了半天,发现是迁移表里漏了一行。从那以后,我每次写完状态机都会画一张完整的迁移图,对着图检查一遍代码。
好了,菜单导航和手势识别这两个场景,说白了都是「状态 + 事件 + 迁移」的套路。你掌握了这个思路,以后做任何UI交互都能游刃有余。
公众号:蓝海资料掘金营,微信deep3321