4、触摸事件响应:触摸点坐标获取、手势识别基础(单击、双击、长按、滑动)、多点触控处理

触摸事件响应,说白了就是让嵌入式GUI知道「用户的手指在屏幕上干了什么」。我刚开始做触摸屏驱动时,以为只要读到坐标就完事了。后来发现,真正的难点在于——你怎么判断用户是想单击、双击,还是只是不小心碰了一下?

这一章,我们就来拆解触摸响应的三个核心层次:坐标怎么拿、手势怎么认、多点怎么管。

4.1 触摸点坐标获取:从硬件到逻辑的「最后一公里」

触摸屏的原始数据通常是一对ADC值。电阻屏、电容屏的读取方式不同,但最终都要归一化到屏幕坐标系。

核心流程:

  1. 读取触摸控制器的X/Y原始ADC值
  2. 进行滤波(去抖动、去毛刺)
  3. 坐标变换(旋转、镜像、缩放)
  4. 映射到逻辑像素坐标

我个人习惯在驱动层就完成坐标归一化,上层应用只处理逻辑坐标。这样换屏时只需改底层参数,不用动手势逻辑。

经验之谈: 我曾经在一个项目里发现触摸总是偏右上角,查了半天,原来是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。如果硬件不支持,你就得自己算最近邻匹配——把当前帧的点跟上一帧的点做距离匹配,距离最小的认为是同一个手指。

我建议的做法是:

  1. 每个触点用一个结构体保存:ID、坐标、时间戳、状态(按下/移动/抬起)。
  2. 最多支持5个触点(预留余量)。
  3. 触点抬起后,标记为「空闲」,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 本章知识体系总览

下面这张图总结了触摸事件响应的完整链路。从硬件采样到手势回调,每一层都有明确的职责。

触摸事件响应知识体系 触摸事件响应知识体系 硬件层:触摸控制器 + ADC采样 原始ADC值 → 滤波 → 坐标变换 → 逻辑像素 触点管理层:多点跟踪 + ID分配 触点数组 → 最近邻匹配 → 状态更新 手势识别层:状态机 + 计时器 单击 / 双击 / 长按 / 滑动 / 两指缩放旋转 应用回调层:事件分发 + UI响应 OnSingleClick / OnDoubleClick / OnSwipe / OnPinch 关键参数 采样率 ≥ 60Hz 关键参数 最大触点数 5 关键参数 超时阈值可配置

从这张图可以看得很清楚:每一层只做自己的事,层与层之间通过结构体传递数据。这样设计的好处是——换触摸屏硬件时,只需要改第一层;新增手势时,只需要改第三层。互不干扰。

总结一下本章的核心要点:

  • 坐标获取要经过「滤波→变换→裁剪」三步,缺一不可。
  • 手势识别用状态机实现,核心是「按下/抬起/移动」+「时间约束」。
  • 多点触控的关键是触点跟踪,硬件ID优先,没有就自己算最近邻。
  • 两指手势关注距离和角度两个量,注意加阈值防抖。

嗯,触摸事件响应这块,说难不难,说简单也不简单。我见过很多团队在「双击 vs 长按」的冲突处理上翻车,也见过因为触点跟踪没做好导致两指缩放「跳来跳去」的。其实只要把状态机画清楚,把参数留成可配置的,大部分问题都能提前规避。