4、触摸事件响应:触摸点坐标获取、手势识别基础(单击、双击、长按、滑动)、多点触控处理
触摸事件响应,说白了就是让嵌入式GUI知道「用户的手指在屏幕上干了什么」。我刚开始做触摸屏驱动时,以为只要读到坐标就完事了。后来发现,真正的难点在于——你怎么判断用户是想单击、双击,还是只是不小心碰了一下?
这一章,我们就来拆解触摸响应的三个核心层次:坐标怎么拿、手势怎么认、多点怎么管。
4.1 触摸点坐标获取:从硬件到逻辑的「最后一公里」
触摸屏的原始数据通常是一对ADC值。电阻屏、电容屏的读取方式不同,但最终都要归一化到屏幕坐标系。
核心流程:
- 读取触摸控制器的X/Y原始ADC值
- 进行滤波(去抖动、去毛刺)
- 坐标变换(旋转、镜像、缩放)
- 映射到逻辑像素坐标
我个人习惯在驱动层就完成坐标归一化,上层应用只处理逻辑坐标。这样换屏时只需改底层参数,不用动手势逻辑。
经验之谈: 我曾经在一个项目里发现触摸总是偏右上角,查了半天,原来是ADC参考电压漂了。后来我在初始化时加了一段自校准流程——让用户点四个角,自动计算映射矩阵。从此再没出过类似问题。
下面是一个典型的坐标获取代码片段,我通常放在触摸中断服务函数里:
// 触摸点数据结构
typedef struct {
uint16_t x;
uint16_t y;
uint8_t pressure; // 0-255,部分电容屏用
uint32_t timestamp; // 系统滴答,用于手势计时
} TouchPoint_t;
// 坐标获取与滤波
TouchPoint_t GetFilteredPoint(void) {
TouchPoint_t raw, filtered;
static TouchPoint_t prev = {0};
// 读取硬件
raw.x = ReadTouchADC(X_CHANNEL);
raw.y = ReadTouchADC(Y_CHANNEL);
raw.timestamp = GetSysTick();
// 简单中值滤波(取3次采样中间值)
raw.x = MedianFilter3(raw.x);
raw.y = MedianFilter3(raw.y);
// 坐标变换:ADC -> 逻辑像素
filtered.x = (raw.x - X_OFFSET) * X_SCALE / 1000;
filtered.y = (raw.y - Y_OFFSET) * Y_SCALE / 1000;
// 边界裁剪
if (filtered.x >= SCREEN_WIDTH) filtered.x = SCREEN_WIDTH - 1;
if (filtered.y >= SCREEN_HEIGHT) filtered.y = SCREEN_HEIGHT - 1;
prev = filtered;
return filtered;
}
注意: 不要在中断里做复杂滤波或手势判断。中断里只做「取数+标记」,手势识别放到任务级或主循环里处理。否则中断响应时间一长,系统容易丢点。
4.2 手势识别基础:从「点」到「意」的转化
有了坐标点,接下来就是判断用户意图。嵌入式环境资源有限,我们不可能跑深度学习模型。常用的方法就是——状态机 + 计时器。
你想想看,单击、双击、长按、滑动,本质上就是「按下」「抬起」「移动」三个基本事件的组合,再加上时间约束。
| 手势 | 触发条件 | 典型参数 |
|---|---|---|
| 单击 | 按下后抬起,时间短,移动距离小 | 按下时长 < 200ms,移动 < 5px |
| 双击 | 两次单击,间隔短 | 两次抬起间隔 < 300ms |
| 长按 | 按下后不抬起,持续一段时间 | 按下时长 > 800ms |
| 滑动 | 按下后移动,超过阈值 | 移动距离 > 20px |
嗯,这里要注意:双击和长按的判断是有冲突的。用户长按时,系统会先检测到「按下」,然后等待。如果用户在800ms内抬起了,那就是单击或双击;如果没抬起,就是长按。所以状态机里必须有一个「等待超时」的分支。
我曾经在一个智能家居面板项目里,把双击的间隔设成了500ms,结果用户反馈「反应迟钝」。后来改成250ms,体验立刻好了。这个参数跟屏幕刷新率和触摸采样率都有关系,建议留一个配置接口,方便现场调试。
4.2.1 手势状态机实现
下面是我常用的手势识别状态机框架。它只有4个状态,但能覆盖90%的交互场景。
typedef enum {
GESTURE_IDLE, // 空闲
GESTURE_WAIT_RELEASE, // 等待抬起(判断单击/长按)
GESTURE_WAIT_DOUBLE, // 等待第二次单击
GESTURE_SCROLLING // 滑动中
} GestureState_t;
GestureState_t gestureState = GESTURE_IDLE;
TouchPoint_t touchDown; // 记录按下时的坐标和时间
void GestureFSM(TouchPoint_t *tp, uint8_t isPressed) {
switch (gestureState) {
case GESTURE_IDLE:
if (isPressed) {
touchDown = *tp;
gestureState = GESTURE_WAIT_RELEASE;
}
break;
case GESTURE_WAIT_RELEASE:
if (!isPressed) {
// 抬起,判断是单击还是双击
uint32_t elapsed = tp->timestamp - touchDown.timestamp;
if (elapsed < LONG_PRESS_MS) {
// 进入双击等待
gestureState = GESTURE_WAIT_DOUBLE;
}
} else {
// 还在按下,检查是否长按
uint32_t elapsed = tp->timestamp - touchDown.timestamp;
if (elapsed >= LONG_PRESS_MS) {
OnLongPress(&touchDown);
gestureState = GESTURE_IDLE;
}
// 检查是否滑动
int32_t dx = tp->x - touchDown.x;
int32_t dy = tp->y - touchDown.y;
if (abs(dx) > SWIPE_THRESHOLD || abs(dy) > SWIPE_THRESHOLD) {
OnSwipeStart(&touchDown);
gestureState = GESTURE_SCROLLING;
}
}
break;
case GESTURE_WAIT_DOUBLE:
if (isPressed) {
// 第二次按下,判定为双击
OnDoubleClick(&touchDown);
gestureState = GESTURE_IDLE;
} else {
// 超时未按下,判定为单击
uint32_t elapsed = tp->timestamp - touchDown.timestamp;
if (elapsed > DOUBLE_CLICK_INTERVAL) {
OnSingleClick(&touchDown);
gestureState = GESTURE_IDLE;
}
}
break;
case GESTURE_SCROLLING:
if (!isPressed) {
OnSwipeEnd(tp);
gestureState = GESTURE_IDLE;
} else {
OnSwipeMove(tp);
}
break;
}
}
避坑指南: 我曾经把双击等待状态设计成「阻塞等待」,结果整个UI线程卡死了。后来改成「超时轮询+状态回退」,才解决了问题。记住,手势识别绝不能阻塞主循环。
4.3 多点触控处理:从「单指」到「多指」的跨越
单点触控,说白了就是「一个点」。多点触控,你要处理的是「一组点」。电容屏通常支持2-10点,但嵌入式GUI一般用到2-3点就够了。
多点触控的核心难点在于:
- 触点跟踪: 这次读到的点,跟上次读到的点,哪个是同一个手指?
- 手势融合: 两指缩放、旋转,本质上是两个点的距离和角度变化。
- 资源管理: 每个触点都要分配一个状态机实例。
4.3.1 触点ID与跟踪
大多数触摸控制器会给每个触点分配一个硬件ID。如果硬件不支持,你就得自己算最近邻匹配——把当前帧的点跟上一帧的点做距离匹配,距离最小的认为是同一个手指。
我建议的做法是:
- 每个触点用一个结构体保存:ID、坐标、时间戳、状态(按下/移动/抬起)。
- 最多支持5个触点(预留余量)。
- 触点抬起后,标记为「空闲」,ID可以复用。
#define MAX_TOUCH_POINTS 5
typedef struct {
uint8_t id; // 触点ID
uint8_t active; // 1=有效
uint16_t x, y;
uint32_t timestamp;
} TouchSlot_t;
TouchSlot_t touchSlots[MAX_TOUCH_POINTS];
// 更新触点(由驱动层调用)
void UpdateTouchPoint(uint8_t id, uint16_t x, uint16_t y, uint8_t pressed) {
for (int i = 0; i < MAX_TOUCH_POINTS; i++) {
if (touchSlots[i].id == id || (!pressed && touchSlots[i].active)) {
touchSlots[i].active = pressed;
touchSlots[i].x = x;
touchSlots[i].y = y;
touchSlots[i].timestamp = GetSysTick();
break;
}
}
}
4.3.2 两指手势:缩放与旋转
两指手势的数学其实很简单。你只需要记住两个核心量:
- 距离: 两指之间的欧氏距离。距离变大 = 放大,变小 = 缩小。
- 角度: 两指连线与水平轴的夹角。角度变化 = 旋转。
下面是我在一个地图导航项目里用的两指手势处理逻辑:
typedef struct {
uint16_t x1, y1;
uint16_t x2, y2;
float distance;
float angle;
} TwoFingerState_t;
float CalcDistance(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) {
int32_t dx = x2 - x1;
int32_t dy = y2 - y1;
return sqrtf(dx*dx + dy*dy);
}
void HandleTwoFingerGesture(TwoFingerState_t *current, TwoFingerState_t *prev) {
float scale = current->distance / prev->distance;
float rotation = current->angle - prev->angle;
// 缩放阈值判断(防止抖动)
if (fabsf(scale - 1.0f) > 0.05f) {
OnPinch(scale);
}
// 旋转阈值判断
if (fabsf(rotation) > 5.0f) { // 5度以上才触发
OnRotate(rotation);
}
}
注意: 两指手势中,如果有一指抬起,要立即退出两指模式,回到单指模式。否则用户会觉得「卡住了」。我见过不少产品在这个细节上翻车。
4.4 本章知识体系总览
下面这张图总结了触摸事件响应的完整链路。从硬件采样到手势回调,每一层都有明确的职责。
从这张图可以看得很清楚:每一层只做自己的事,层与层之间通过结构体传递数据。这样设计的好处是——换触摸屏硬件时,只需要改第一层;新增手势时,只需要改第三层。互不干扰。
总结一下本章的核心要点:
- 坐标获取要经过「滤波→变换→裁剪」三步,缺一不可。
- 手势识别用状态机实现,核心是「按下/抬起/移动」+「时间约束」。
- 多点触控的关键是触点跟踪,硬件ID优先,没有就自己算最近邻。
- 两指手势关注距离和角度两个量,注意加阈值防抖。
嗯,触摸事件响应这块,说难不难,说简单也不简单。我见过很多团队在「双击 vs 长按」的冲突处理上翻车,也见过因为触点跟踪没做好导致两指缩放「跳来跳去」的。其实只要把状态机画清楚,把参数留成可配置的,大部分问题都能提前规避。