编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

  • Linux应用开发

    • Linux应用开发
    • QML基础入门

      • QML基础入门
      • 嵌入式GUI技术全景
      • QML引擎与渲染原理
      • QML语法与类型系统
      • 属性绑定与响应式原理
      • 可视元素与布局原理
      • 事件处理与传播机制
        • 6.1 案例引入
          • 6.1.1 触摸穿透
          • 6.1.2 根因分析
          • 6.1.3 三大问题
        • 6.2 事件架构
          • 6.2.1 穿透探索
          • 6.2.2 完整路径
          • 6.2.3 事件全景
          • 6.2.4 分发机制
        • 6.3 触摸事件
          • 6.3.1 信号与拖拽
          • 6.3.2 穿透控制
          • 6.3.3 光标管理
          • 6.3.4 可拖拽控件
        • 6.4 多点触摸
          • 6.4.1 多点区域
          • 6.4.2 触摸链路
          • 6.4.3 双指缩放
        • 6.5 键盘焦点
          • 6.5.1 Keys 附加属性
          • 6.5.2 焦点隔离
          • 6.5.3 焦点链
        • 6.6 手势识别
          • 6.6.1 三种手势
          • 6.6.2 事件过滤器
          • 6.6.3 定时器交互
        • 6.7 双屏隔离
        • 6.8 性能与陷阱
          • 事件处理的性能要点
        • 6.9 新手陷阱
        • 6.10 训练题
        • 6.11 思考题
        • 6.12 速查表
      • 模型视图架构原理
      • 动画与状态机原理
      • Canvas与自定义渲染
      • QML与C++集成原理
      • 自定义SceneGraph节点
      • 交叉编译与部署
      • 嵌入式渲染后端
      • 性能优化与真机调试
    • QT核心库实践

    • Linux系统编程

    • 综合项目实战

  • IoT智能硬件开发

  • Apps
  • Linux应用开发
  • QML基础入门
杨充
2025-06-24
目录

事件处理与传播机制

# 第 6 章 事件处理与传播机制

本章定位:从"能点按钮"到"懂事件流转"。QML 的事件系统不是"写个 onClicked 就能干活"那么简单——MouseArea 的信号传播链、触摸事件的 MultiPointTouchArea、键盘焦点系统的 FocusScope 隔离——理解这些,才能在车机双屏互不干扰、触摸穿透、"按钮点了没反应"等问题出现时 30 秒定位根因。

# 目录介绍

  • 6.1 案例引入
    • 6.1.1 触摸穿透
    • 6.1.2 根因分析
    • 6.1.3 三大问题
  • 6.2 事件架构
    • 6.2.1 穿透探索
    • 6.2.2 完整路径
    • 6.2.3 事件全景
    • 6.2.4 分发机制
  • 6.3 触摸事件
    • 6.3.1 信号与拖拽
    • 6.3.2 穿透控制
    • 6.3.3 光标管理
    • 6.3.4 可拖拽控件
  • 6.4 多点触摸
    • 6.4.1 多点区域
    • 6.4.2 触摸链路
    • 6.4.3 双指缩放
  • 6.5 键盘焦点
    • 6.5.1 Keys 附加属性
    • 6.5.2 焦点隔离
    • 6.5.3 焦点链
  • 6.6 手势识别
    • 6.6.1 三种手势
    • 6.6.2 事件过滤器
    • 6.6.3 定时器交互
  • 6.7 双屏隔离
  • 6.8 性能与陷阱
  • 6.9 新手陷阱
  • 6.10 训练题
  • 6.11 思考题
  • 6.12 速查表

# 6.1 案例引入

# 6.1.1 触摸穿透

某车机 HMI 工程师在导航地图上盖了一个弹出菜单。点菜单里的"关闭"按钮,菜单关掉了——但地图上被菜单挡住的"目的地"按钮也触发了:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 800; height: 480; visible: true

    // ───── 地图层(底层) ─────
    Rectangle {
        id: mapView
        anchors.fill: parent
        color: "#2d2d44"

        Button {
            id: destBtn
            text: "设为目的地"
            anchors.centerIn: parent
            onClicked: console.log("导航启动!")      // ← 不该触发!
        }
    }

    // ───── 弹窗层(覆盖在地图上) ─────
    Popup {
        id: menu
        x: 200; y: 100; width: 400; height: 280
        modal: false                                 // ⚠️ 罪魁祸首

        Rectangle {
            anchors.fill: parent
            color: "white"; radius: 12

            Column {
                anchors.centerIn: parent; spacing: 12
                Text { text: "菜单"; font.pixelSize: 24; font.bold: true }
                Button {
                    text: "关闭"
                    onClicked: {
                        console.log("菜单关闭")
                        menu.close()                 // 用户点这里
                    }
                }
            }
        }
    }

    Button {
        anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter; margins: 20 }
        text: "打开菜单"
        onClicked: menu.open()
    }
}

操作步骤与现象:

1. 点击"打开菜单" → 弹出层出现在地图上方 ✅
2. 点击弹出层里的"关闭"按钮
   → 控制台先是 "菜单关闭" ✅
   → 紧接着 "导航启动!"        ← ❌ 地图上的按钮也被点击了!

疑惑:

  • 菜单明明盖在地图上面,为什么点击能穿透到地图?
  • Popup 的 modal 属性到底管什么?
  • 有没有办法手动控制"事件吞掉"还是"放行"?

# 6.1.2 根因分析

QML 的事件分发从 z-order 最高 的 Item 开始。Popup { modal: false } 的意思是"弹窗不拦截穿透事件"——点击的坐标同时落在菜单按钮和地图按钮上,两个 MouseArea 都收到了事件:

事件流:
  QQuickWindow::sendEvent()
    → 遍历 z-order 降序的子元素列表
    → Popup(Button: "关闭") → 坐标包含 → 触发 onClicked → mouse.accepted = true
      → 按理说这里应该停止传播了...
    
    但 Popup modal: false 意味着 Popup 的"遮罩层"没开启
    → 坐标同时命中 Popup 内的 Button 和 MapView 内的 Button
    → MapView 的 Button 也触发了 onClicked

修复:

Popup {
    modal: true       // ✅ 开启模态——Popup 外的所有坐标事件被吞掉
}

或者手动控制——在弹窗放置一个"遮罩" MouseArea 吃掉穿透事件:

Popup {
    // 背景遮罩:吞掉所有穿透的触摸事件
    MouseArea {
        anchors.fill: parent
        onPressed: mouse.accepted = true   // 吃了,别往下传
    }
}

# 6.1.3 三大问题

问题 在哪节回答
QQuickWindow 的分发链是如何遍历 z-order 并决定"谁先收到事件"? §6.2
mouse.accepted 和 Popup modal 在底层分别对应什么机制? §6.3
车机双屏(仪表+中控)如何做到事件完全隔离? §6.5 + §6.7

读完本章你会亲手写一个车机双屏事件隔离系统,并理解 evdev → QPA → QTouchEvent → QQuickWindow 的完整触摸事件流转路径。


# 6.2 事件架构

# 6.2.1 穿透探索

在深入事件系统之前,先用一个探索性问题热身:两个重叠的 Rectangle 都绑定了 onClicked,点击在重叠区域时,谁先收到事件?又为什么可能两个都收到?

Rectangle { width: 200; height: 200; color: "red"   // 底层
    MouseArea { onClicked: console.log("底层") }
    Rectangle { width: 100; height: 100; color: "blue"  // 上层
        MouseArea { onClicked: console.log("上层") }
    }
}

在未设置 propagateComposedEvents 的情况下,点击蓝色区域:

  • 上层被触发(视觉上在"前面"的 Item 优先命中)
  • 如果底层 z 值更大,底层先抢到

但真正有趣的是:视觉上的前后(子元素在前)≠ 事件分发顺序——顺序由 QQuickWindow 的全局命中测试(Hit Test) 决定。理解这个过程,就要看下一节的事件分发了。

# 6.2.2 完整路径

在嵌入式 ARM 板上,从手指触碰屏幕到 QML 的 onClicked 触发,经历五层:

Layer 1: 硬件层
  触摸屏电容感应 → I2C/SPI 数字信号 → 触摸控制器固件

Layer 2: Linux Input 子系统
  触摸驱动 → /dev/input/eventX
  内核通过 evdev 协议向用户空间报告:
    struct input_event { type=EV_ABS, code=ABS_X, value=320 }
    struct input_event { type=EV_ABS, code=ABS_Y, value=200 }
    struct input_event { type=EV_KEY, code=BTN_TOUCH, value=1 }

Layer 3: Qt QPA 平台抽象层
  eglfs 的 evdevtouch 插件读取 /dev/input/event*
  → 把原始坐标转换为屏幕坐标
  → 把多个 event 合成为一个 QTouchEvent

Layer 4: QQuickWindow 分发
  QQuickWindow::event(event)
    → QQuickWindow::sendEvent(item, event)  ← 遍历 Item 树

Layer 5: QML Item / MouseArea / MultiPointTouchArea
  ItemA.contains(event.pos()) → true → 触发 onClicked / onPressed
  ItemB.contains(event.pos()) → 触发...
  mouse.accepted = true → 停止继续向下分发

# 6.2.3 事件全景

事件类型 C++ 类 QML 处理元素 嵌入式是否常用
鼠标/单点触摸 QMouseEvent MouseArea ✅ 主力
多点触摸 QTouchEvent MultiPointTouchArea ✅ 双指缩放
键盘/物理按键 QKeyEvent Keys / KeyNavigation ✅ 方向盘按键
滚轮 QWheelEvent MouseArea.onWheel ❌ 嵌入式少见
手势 QNativeGestureEvent PinchArea / SwipeView ✅ 车机翻页
定时器 QTimerEvent Timer ✅ 周期性任务
拖拽 QDragMoveEvent DropArea / Drag ⚠️ 有限使用

# 6.2.4 分发机制

每个 QQuickWindow 内部维护一个 Item 树的 z-order 列表。事件分发从列表中自上而下(z-order 降序)遍历:

// QQuickWindow 事件分发核心路径(简化)
bool QQuickWindow::sendEvent(QQuickItem* item, QEvent* event) {
    // ① 先让当前 item 的子元素按 z-order 降序处理
    QList<QQuickItem*> children = item->childItems();
    std::sort(children.begin(), children.end(),
              [](auto* a, auto* b) { return a->z() > b->z(); });

    for (auto* child : children) {
        if (!child->isVisible() || !child->isEnabled()) continue;

        // ② 检查坐标是否在 child 的矩形范围内
        QPointF localPos = child->mapFromItem(item, event->pos());
        if (child->contains(localPos)) {
            event->setAccepted(false);       // 重置接受标志
            sendEvent(child, event);          // 递归分发
            if (event->isAccepted()) return true;  // ← 被吃掉了,停止
        }
    }

    // ③ 子元素都没接受 → 分发给 item 自己的事件处理器
    return item->event(event);
}

关键点:

  • z 值决定遍历顺序(越大越先收到事件)
  • contains() 只检查矩形边界——圆形/不规则按钮要额外处理
  • event->isAccepted() 是"事件被消费了"的标志——由 mouse.accepted 控制

# 6.3 触摸事件

# 6.3.1 信号与拖拽

MouseArea 的完整信号清单——每一个信号对应一次事件循环内的状态变迁:

MouseArea {
    id: ma
    anchors.fill: parent

    // ─── 基础点击信号 ───
    onClicked:         console.log("clicked")       // 按下+释放 都在区域内
    onPressed:         console.log("pressed", mouse.x, mouse.y)
    onReleased:        console.log("released")
    onCanceled:        console.log("被系统取消(如来电话)")
    onDoubleClicked:   console.log("双击")
    onPressAndHold:    console.log("长按(默认800ms)")

    // ─── 移动信号 ───
    onPositionChanged: console.log("移动", mouse.x, mouse.y)

    // ─── 滚动信号 ───
    onWheel:           console.log("滚轮", wheel.angleDelta.y)

    // ─── Hover(需显式启用) ───
    hoverEnabled: true
    onEntered:         console.log("鼠标进入")
    onExited:          console.log("鼠标离开")

    // ─── 拖拽 ───
    drag.target: draggableItem
    drag.axis: Drag.XAndYAxis
    drag.minimumX: 0
    drag.maximumX: parent.width - draggableItem.width
}

mouse 对象——每个信号处理器里隐式传入的参数:

属性 类型 含义
mouse.x / mouse.y real 相对于 MouseArea 的坐标
mouse.button Qt.LeftButton / Qt.RightButton 哪个键
mouse.buttons bitmask 当前所有按下的键
mouse.modifiers bitmask Ctrl/Shift/Alt 等
mouse.accepted bool 是否接受事件——false = 放行给下层 Item
mouse.wasHeld bool 是否是长按后释放

信号时序——点击的生命周期:

用户触摸屏幕
  → onPressed  ──── 手指按下
  →              (可能触发 onPositionChanged)
  → onReleased ──── 手指抬起
  → onClicked  ──── 当 pressed+released 在同一区域时
     ↑
     └─ 如果 pressed 和 released 之间有长按(默认 800ms)
        → 先触发 onPressAndHold,然后 onClicked 不再触发

# 6.3.2 穿透控制

Column {
    spacing: 0

    // 顶层:菜单面板
    Rectangle {
        width: 200; height: 100; color: "white"
        z: 2
        MouseArea {
            anchors.fill: parent
            onClicked: {
                console.log("菜单被点击")
                mouse.accepted = true       // 吃了,不往下传
            }
        }
    }

    // 底层:地图
    Rectangle {
        width: 200; height: 200; color: "#2d2d44"
        z: 1
        MouseArea {
            anchors.fill: parent
            onClicked: {
                console.log("地图被点击")
                // 这里默认 mouse.accepted = true(隐式接受)
            }
        }
    }
}
// 点击菜单重叠区域 → 仅打印 "菜单被点击"
// 点击菜单之外的区域 → 打印 "地图被点击"

mouse.accepted 的默认行为:

信号 默认值 含义
onClicked true 自动接受——事件到此为止
onPressed false 不自动接受——让 onClicked 来做决定
onReleased false 同上
onPositionChanged false 不自动接受——父级仍可见移动
// 典型场景:拖拽时吞掉事件
MouseArea {
    anchors.fill: parent
    drag.target: slider

    onPressed:  mouse.accepted = true   // 显式接受,不让 ListView 滑动
    onReleased: mouse.accepted = true
    // onClicked 默认 true——无需额外设
}

# 6.3.3 光标管理

嵌入式触摸屏不需要 hover——但开发调试阶段用鼠标在 x86 桌面跑 QML 时,hover 很有用:

MouseArea {
    anchors.fill: parent
    hoverEnabled: true               // 默认 false——必须显式开启

    onEntered:  parent.color = "#e0e0e0"   // 鼠标进入时变灰
    onExited:   parent.color = "white"
    onPositionChanged: {
        if (containsMouse) {
            tooltip.x = mouse.x + 10
            tooltip.y = mouse.y + 10
        }
    }
}

注意:hoverEnabled: true 会在每帧额外检查鼠标位置——在高频触发的场景(如 100 个 ListView delegate 全开 hover)有性能开销。

# 6.3.4 可拖拽控件

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 600; height: 400; visible: true

    Rectangle {
        id: gauge
        width: 120; height: 120; radius: 60
        color: "#2196F3"; z: 10

        // 拖拽
        MouseArea {
            anchors.fill: parent
            drag.target: parent
            drag.axis: Drag.XAndYAxis

            onPressed: {
                parent.z = 100                       // 抬起——盖住其他控件
                mouse.accepted = true                // 吃掉事件,不让下层处理
            }
            onReleased: parent.z = 10                // 放下——恢复层级
        }

        Text {
            anchors.centerIn: parent
            text: "拖动我"; color: "white"; font.pixelSize: 14
        }
    }

    // 背景的 ListView——gauge 拖拽时不应该触发列表滚动
    ListView {
        anchors.fill: parent
        model: 20
        delegate: Rectangle {
            width: parent.width; height: 40
            color: index % 2 === 0 ? "#f0f0f0" : "white"
            Text {
                anchors.centerIn: parent
                text: "Item " + index
            }
        }
    }
}

案例知识融合:本案例演示了 drag.target + mouse.accepted + z 三维度协作——①拖拽时 z=100 保证视觉层级;②mouse.accepted=true 保证拖拽事件不泄露到 ListView(否则 ListView 跟着滑动);③释放时恢复 z 层级。

思考题:

  1. 如果把 drag.axis: Drag.XAndYAxis 改为 Drag.XAxis,竖直拖拽 gauge 时会发生什么?ListView 会滚动吗?
  2. onPressed 里设了 mouse.accepted = true,为什么 onClicked 不需要重复设?查阅 §6.3.2 的默认值表回答。

# 6.4 多点触摸

# 6.4.1 多点区域

嵌入式触摸屏通常支持 5~10 点触控。QML 的 MultiPointTouchArea 直接暴露原始触摸点:

MultiPointTouchArea {
    id: mta
    anchors.fill: parent
    minimumTouchPoints: 1
    maximumTouchPoints: 5

    touchPoints: [
        TouchPoint { id: tp1 },
        TouchPoint { id: tp2 },
        TouchPoint { id: tp3 },
        TouchPoint { id: tp4 },
        TouchPoint { id: tp5 }
    ]

    onPressed: (points) => {
        for (var i = 0; i < points.length; i++) {
            console.log("手指", points[i].pointId,
                        "按下于", points[i].x.toFixed(1),
                        points[i].y.toFixed(1))
        }
    }

    onUpdated: (points) => {
        // 典型应用:双指缩放 / 双指旋转
        if (points.length === 2) {
            var dx = points[0].x - points[1].x
            var dy = points[0].y - points[1].y
            var dist = Math.sqrt(dx * dx + dy * dy)
            // dist 变化 → 缩放比例变化
        }
    }

    onReleased: (points) => {
        console.log("手指", points[0].pointId, "抬起")
    }
}

TouchPoint 的核心属性:

属性 类型 含义
pointId int 手指唯一标识(按下到抬起保持不变)
x / y real 当前坐标
previousX / previousY real 上一帧坐标
startX / startY real 按下时的起始坐标
pressure real 压力值(0.0~1.0)——仅部分硬件支持
rotation real 触摸角度——电容屏可检测手指方向

MouseArea vs MultiPointTouchArea 互斥问题:

// ❌ 同一区域同时用 MouseArea 和 MultiPointTouchArea
MouseArea { anchors.fill: parent; onClicked: ... }
MultiPointTouchArea { anchors.fill: parent; onPressed: ... }
// → 两个都收到事件,行为不确定

// ✅ 分层——上层用 MultiPointTouchArea,下层用 MouseArea
MultiPointTouchArea {
    anchors.fill: parent
    // 单指按下时手动模拟 MouseArea 行为
    onPressed: (points) => {
        if (points.length === 1) handleSingleTouch()
        else                     handleMultiTouch()
    }
}

# 6.4.2 触摸链路

触摸屏硬件
  → 触摸控制器(如 GT911 / FT5x06 / ADS7846)
    → I2C / SPI / USB 接口
      → Linux 内核驱动(drivers/input/touchscreen/)
        → /dev/input/eventX(evdev 字符设备)
          → Qt QPA egfls evdevtouch 插件读取
            → 把 ABS_X / ABS_Y / ABS_MT_POSITION_X 等 raw 事件
              合成为 QTouchEvent
              → QQuickWindow::event(QTouchEvent*)
                → 分发给 z-order 最高的 MultiPointTouchArea / MouseArea

调试触摸事件——在 ARM 板上直接读 raw 数据:

# 查看触摸设备
cat /proc/bus/input/devices | grep -A5 -i touch

# 实时读取触摸事件(hexdump)
hexdump /dev/input/event1
# 输出:type=3(ABS) code=53(X) value=320
#       type=3(ABS) code=54(Y) value=200
#       type=1(KEY) code=330(TOUCH) value=1  ← 手指按下

# 用 evtest 读取(推荐)
evtest /dev/input/event1

# 6.4.3 双指缩放

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 800; height: 600; visible: true

    Rectangle {
        id: mapContainer
        anchors.fill: parent
        color: "#1a1a2e"

        Rectangle {
            id: mapContent
            width: 800; height: 600; color: "#2d2d44"
            scale: 1.0

            Grid {
                rows: 8; columns: 8; anchors.centerIn: parent; spacing: 4
                Repeater {
                    model: 64
                    Rectangle {
                        width: 80; height: 60
                        color: index % 2 === 0 ? "#555" : "#444"
                        Text {
                            anchors.centerIn: parent; text: "点" + index
                            color: "white"
                        }
                    }
                }
            }

            Behavior on scale { NumberAnimation { duration: 100 } }
        }

        MultiPointTouchArea {
            anchors.fill: parent
            minimumTouchPoints: 1; maximumTouchPoints: 5

            property real startDist: 0
            property real startScale: 1.0

            onPressed: (points) => {
                if (points.length === 2) {
                    // 记录初始距离
                    var dx = points[0].x - points[1].x
                    var dy = points[0].y - points[1].y
                    startDist = Math.sqrt(dx * dx + dy * dy)
                    startScale = mapContent.scale
                }
            }

            onUpdated: (points) => {
                if (points.length === 2) {
                    var dx = points[0].x - points[1].x
                    var dy = points[0].y - points[1].y
                    var dist = Math.sqrt(dx * dx + dy * dy)
                    var ratio = dist / startDist
                    mapContent.scale = Math.max(0.5, Math.min(3.0, startScale * ratio))
                }
            }
        }
    }

    Text {
        anchors { top: parent.top; left: parent.left; margins: 10 }
        text: "双指缩放: " + mapContent.scale.toFixed(2) + "x"
        color: "white"; font.pixelSize: 14
    }
}

案例知识融合:本案例把多点触摸的完整链路串起来——①MultiPointTouchArea 监听所有触点,onPressed 记录双指初始距离;②onUpdated 实时计算缩放比例并对 mapContent.scale 赋值;③Behavior on scale 提供插值动画,平滑过渡;④Math.max/min 限幅防止过度缩放。整个过程不经过 MouseArea,每条触摸消息从 evdev → QPA → QTouchEvent → MultiPointTouchArea 全在 C++ 路径内。

思考题:

  1. 如果同时在 mapContent 上放一个 MouseArea { onClicked: ... },双指缩放时会不会误触发 onClicked?
  2. MultiPointTouchArea 和 PinchArea 的区别是什么?分别适合哪种场景?

# 6.5 键盘焦点

# 6.5.1 Keys 附加属性

Rectangle {
    width: 200; height: 200
    color: activeFocus ? "steelblue" : "gray"
    focus: true                          // 主动请求焦点

    Keys.onPressed: (event) => {
        switch (event.key) {
            case Qt.Key_Left:   x -= 10; break
            case Qt.Key_Right:  x += 10; break
            case Qt.Key_Up:     y -= 10; break
            case Qt.Key_Down:   y += 10; break
            case Qt.Key_Return: console.log("回车"); break
            case Qt.Key_Escape: console.log("ESC");  break
        }
        event.accepted = true              // 吞掉按键,不传播到父级
    }

    Keys.onReleased: (event) => {
        if (event.key === Qt.Key_Space) console.log("空格松开")
    }
}

KeyNavigation——声明式焦点跳转:

Grid {
    columns: 3; spacing: 8
    Repeater {
        model: 9
        Rectangle {
            width: 60; height: 60; color: "lightgray"
            focus: true
            KeyNavigation.right: (index + 1) % 3 !== 0 ? repeater.itemAt(index+1) : null
            KeyNavigation.left:  index % 3 !== 0 ? repeater.itemAt(index-1) : null
            KeyNavigation.down:  index + 3 < 9 ? repeater.itemAt(index+3) : null
            KeyNavigation.up:    index - 3 >= 0 ? repeater.itemAt(index-3) : null
        }
    }
}

# 6.5.2 焦点隔离

车机典型场景:主驾屏(仪表盘)和副驾屏(导航娱乐)共用同一套 QML 代码。当副驾屏的 TextField 正在编辑时,方向盘上的"确认"键应该只作用于主驾屏——这就是 FocusScope 的价值:

ApplicationWindow {
    width: 1200; height: 480; visible: true

    // ───── 主驾屏:仪表盘 FocusScope ─────
    FocusScope {
        id: driverScope
        width: parent.width * 0.5; height: parent.height
        focus: true

        Rectangle {
            anchors.fill: parent; color: "#111"
            TextField {
                id: driverInput
                anchors.centerIn: parent
                focus: true
                text: "仪表搜索"
            }
        }
    }

    // ───── 副驾屏:导航 FocusScope ─────
    FocusScope {
        id: passengerScope
        x: parent.width * 0.5
        width: parent.width * 0.5; height: parent.height

        Rectangle {
            anchors.fill: parent; color: "#222"
            TextField {
                id: passengerInput
                anchors.centerIn: parent
                text: "导航搜索"
            }
        }
    }

    // ───── 方向盘按键:只发给当前活动的 FocusScope ─────
    Keys.onPressed: (event) => {
        if (event.key === Qt.Key_F1) {
            console.log("方向盘 F1 → 发送到活动 FocusScope 的焦点 Item")
            if (Window.activeFocusItem) {
                console.log("  当前焦点:", Window.activeFocusItem)
            }
        }
    }
}

FocusScope 的底层机制——每个 FocusScope 内部维护一个独立的焦点链:

Window (焦点根)
 ├── FocusScope "driver" ── TextField (当前焦点)
 └── FocusScope "passenger" ── TextField

当用户在副驾屏上触摸 TextField 时:

  1. MouseArea 收到触摸事件 → passengerInput.focus = true
  2. passengerScope 把焦点转移到自己内部的链
  3. driverScope 失去活动焦点
  4. 方向盘按键 → Window 的 activeFocusItem 切到 passengerInput

# 6.5.3 焦点链

Column {
    spacing: 8
    TextField { focus: true; placeholderText: "用户名" }
    TextField { placeholderText: "密码"; echoMode: TextInput.Password }
    CheckBox { text: "记住我" }
    Button { text: "登录" }
}
// 按 Tab → 焦点依次在 TextField → TextField → CheckBox → Button 之间切换

控制 Tab 顺序——用 KeyNavigation.tab:

TextField { id: user; KeyNavigation.tab: password }
TextField { id: password; KeyNavigation.tab: loginBtn }
Button { id: loginBtn; KeyNavigation.tab: user }

# 6.6 手势识别

# 6.6.1 三种手势

PinchArea——双指捏合的 QML 原生实现(封装了 MultiPointTouchArea):

PinchArea {
    anchors.fill: parent
    pinch.target: mapContent
    pinch.minimumScale: 0.5
    pinch.maximumScale: 3.0
    onPinchUpdated: (pinch) => {
        mapContent.scale *= pinch.scale
        mapContent.rotation += pinch.rotation
    }
}

PinchArea 内部等价于 §6.4.3 的手动 MultiPointTouchArea——它只是把双指距离/角度的计算包装好了。

SwipeView——滑动翻页(底层是 ListView + snapMode):

SwipeView {
    currentIndex: 0
    Page { title: "仪表"; Rectangle { color: "#111" } }
    Page { title: "导航"; Rectangle { color: "#222" } }
    Page { title: "音乐"; Rectangle { color: "#333" } }
}

Flickable——惯性拖拽:

Flickable {
    width: 400; height: 300
    contentWidth: image.width; contentHeight: image.height
    boundsBehavior: Flickable.StopAtBounds
    Image { id: image; source: "large_map.png" }
}

# 6.6.2 事件过滤器

C++ 侧可以在事件到达 QML 之前拦截——用于全局手势、调试日志、权限控制:

class GlobalTouchFilter : public QObject {
    Q_OBJECT
protected:
    bool eventFilter(QObject* obj, QEvent* event) override {
        if (event->type() == QEvent::TouchBegin) {
            auto* touch = static_cast<QTouchEvent*>(event);
            qDebug() << "全局拦截: TouchBegin, 触点:" << touch->pointCount();
            // return true → 吃掉事件,不让 QML 收到
        }
        return QObject::eventFilter(obj, event);
    }
};

// main.cpp
GlobalTouchFilter* filter = new GlobalTouchFilter();
app.installEventFilter(filter);     // 全局安装

# 6.6.3 定时器交互

Timer {
    interval: 16; repeat: true; running: true
    onTriggered: {
        // 定时器回调在当前事件循环迭代中同步执行
        // 如果这里写死循环 → 整个 GUI 卡死
        gauge.value = fetchSensorData()
    }
}

定时器与渲染帧的对齐——如果 Timer.interval = 16 且回调在 2ms 内完成,它和 VSync 并不同步。要想与帧对齐,使用 FrameAnimation:

FrameAnimation {
    running: true
    onTriggered: {
        // 每帧触发一次——与 VSync 同步
    }
}

# 6.7 双屏隔离

下面是一套完整的车机双屏事件隔离方案——主驾屏(仪表盘)只响应方向盘按键和主驾触摸;副驾屏(导航娱乐)只响应副驾触摸。方向盘上的"确认"键根据"当前谁的 FocusScope 有活动焦点"来路由:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 1200; height: 480; visible: true; title: "双屏车机"

    // ───── 主驾屏 FocusScope ─────
    FocusScope {
        id: driverScope
        width: parent.width * 0.5; height: parent.height
        focus: true

        Rectangle {
            anchors.fill: parent; color: "#0d0d1a"
            Column {
                anchors.centerIn: parent; spacing: 12
                Text { text: "仪表盘"; font.pixelSize: 24; color: "white" }
                TextField {
                    id: driverSearch
                    placeholderText: "搜索..."
                    focus: true
                }
            }
            MultiPointTouchArea {
                anchors.fill: parent
                onPressed: driverSearch.focus = true
            }
        }
    }

    // ───── 副驾屏 FocusScope ─────
    FocusScope {
        id: passengerScope
        x: parent.width * 0.5
        width: parent.width * 0.5; height: parent.height

        Rectangle {
            anchors.fill: parent; color: "#1a1a2e"
            Column {
                anchors.centerIn: parent; spacing: 12
                Text { text: "导航娱乐"; font.pixelSize: 24; color: "white" }
                TextField {
                    id: navSearch
                    placeholderText: "目的地..."
                }
            }
            MultiPointTouchArea {
                anchors.fill: parent
                onPressed: {
                    passengerScope.focus = true
                    navSearch.focus = true
                }
            }
        }
    }

    // ───── 方向盘按键 → 路由到当前活动 Scope 的焦点 Item ─────
    Keys.onPressed: (event) => {
        if (event.key === Qt.Key_Return) {
            var active = Window.activeFocusItem
            if (active) {
                if (active === driverSearch) console.log("[主驾] 搜索:", driverSearch.text)
                else if (active === navSearch) console.log("[副驾] 导航到:", navSearch.text)
            }
            event.accepted = true
        }
    }
}

案例知识融合:本案例凝聚了本章全部核心机制——①MultiPointTouchArea 分别绑定两个屏幕的触摸区域,互不干扰;②FocusScope 隔离两个屏幕的焦点链,方向盘按键 Keys.onPressed 通过 Window.activeFocusItem 路由到当前活动 Item;③触摸切换焦点、键盘操作、焦点域隔离三者协作,形成一套完整的双屏车载交互逻辑。


# 6.8 性能与陷阱

# 事件处理的性能要点

场景 问题 优化
100 个 ListView delegate 全开 hoverEnabled 每帧检查 100 个 MouseArea 的 hover 只在顶层组件开启 hover
onPositionChanged 里做复杂运算 手指每移动 1px 触发一次 节流:用 Timer 每 30ms 批量处理
MultiPointTouchArea.onUpdated 高频触发 5 个触点 = 每帧 5 次回调 在回调里只存值,在 FrameAnimation 里统一计算
多个重叠 MouseArea 同时 accepted = true z-order 冲突 明确设置 z 值
// ✅ 节流示例
MouseArea {
    anchors.fill: parent
    property var lastProcess: 0
    onPositionChanged: {
        var now = Date.now()
        if (now - lastProcess < 30) return   // 30ms 内不重复处理
        lastProcess = now
        // 真正处理逻辑
    }
}

# 6.9 新手陷阱

# 陷阱 说明 修复
1 Popup { modal: false } 导致事件穿透 Popup 是默认 non-modal——所有触摸事件直达底层 Item 弹出层必须 modal: true
2 hoverEnabled 默认 false 写 onEntered 不生效——因为没开 hover 显式 hoverEnabled: true
3 mouse.accepted 忘设导致事件一路穿透 onPressed 默认 accepted = false 处理完的事件设 accepted = true
4 MultiPointTouchArea 和 MouseArea 同区域冲突 两个都收到事件 同一区域二选一;或上层用 MultiPointTouchArea、下层用 MouseArea
5 FocusScope 不设初始焦点 键盘事件无法路由 至少一个子 Item 设 focus: true

# 6.10 训练题

训练题 1:修复触摸穿透

下面的弹出菜单点击"删除"时,底下的 ListView delegate 也响应了点击。修复:

ListView {
    model: 100
    delegate: MouseArea {
        width: parent.width; height: 40
        onClicked: console.log("item " + index + " clicked")
    }
}

Popup {
    y: 100; width: 200; height: 150
    // 缺什么?
    Button { text: "删除"; onClicked: popup.close() }
}
答案

加上 modal: true:

Popup {
    modal: true       // ← 修复
    y: 100; width: 200; height: 150
    Button { text: "删除"; onClicked: popup.close() }
}

训练题 2:可拖拽 + 可点击的控件

要求:写一个 Rectangle,可以被拖拽移动、同时也支持 onClicked。拖拽时不能触发 onClicked。

参考答案
Rectangle {
    id: box
    width: 100; height: 100; color: "steelblue"

    MouseArea {
        anchors.fill: parent
        drag.target: parent
        property bool moved: false

        onPressed:  { moved = false; mouse.accepted = true }
        onPositionChanged: moved = true
        onReleased: {
            if (!moved) console.log("clicked!")   // 没移动 = 点击
            moved = false
        }
    }
}

# 6.11 思考题

  1. QML 与 Android 事件分发:Android 的触摸事件分发是"dispatchTouchEvent → onInterceptTouchEvent → onTouchEvent"三层。QML 用 mouse.accepted + z-order 遍历。对比两种模型,在"父容器拦截子控件事件"和"子控件拒绝父容器拦截"两个场景上各有什么优劣?

  2. 双线程事件处理:第 2 章讲到渲染线程独立处理动画插值。触摸事件在 GUI 线程处理——这意味着"手指移动 → GUI 线程绑定求值 → Sync 阶段 → 渲染线程渲染"这一整条链路有延迟。什么场景下用户会感知到这个延迟?怎么优化?

  3. 物理按键 vs 触摸屏:车机中方向盘按键是物理按键、中控屏是触摸屏。物理按键走 Keys.onPressed 路径,触摸屏走 MouseArea / MultiPointTouchArea 路径。两条路径在底层有交叠吗(QKeyEvent vs QTouchEvent)?怎么设计才能让方向盘"确认键"同时能操作仪表盘和导航屏?


# 6.12 速查表

概念 一句话
MouseArea 鼠标/单点触摸事件——onClicked/onPressed/onReleased/drag
mouse.accepted 事件消费标志——true = 吃掉、不向下层传播
MultiPointTouchArea 多点触摸——touchPoints 数组,最低 1 触点最高 5
TouchPoint 单个触点对象——pointId/x/y/pressure/startX 等
Keys 附加属性——onPressed/onReleased 监听物理键盘
KeyNavigation 声明式焦点跳转——left/right/up/down/tab
FocusScope 独立焦点域——双屏车机各管各的焦点链
Window.activeFocusItem 全局当前持有焦点的 Item——键盘事件唯一的接收者
PinchArea 双指捏合手势——封装 MultiPointTouchArea
SwipeView 滑动翻页——底层 ListView + snapMode
Flickable 惯性拖拽容器——放大图片/地图的拖拽浏览
hoverEnabled MouseArea 默认 false——必须显式开启才触发 onEntered/onExited
FrameAnimation 与 VSync 同步的帧回调——替代 Timer 用于渲染对齐
eventFilter C++ 层全局事件拦截——在所有 QML 之前过滤

核心哲学:

QML 的事件系统 = z-order 降序分发 + mouse.accepted 冒泡终止 + FocusScope 焦点域隔离。

触摸从 evdev 到 QTouchEvent 到 MouseArea 层层抽象,
按键从 FocusScope 的焦点链层层路由——
理解这两条路径,就掌握了嵌入式 QML 所有交互场景。 

下一篇:07. 模型视图架构原理 (opens new window)

上次更新: 2026/07/12, 19:39:51
可视元素与布局原理
模型视图架构原理

← 可视元素与布局原理 模型视图架构原理→

最近更新
01
audit
07-27
02
C++入门教程全章思考题汇编
07-24
03
12.技术团队建设能力
07-21
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式