在实际项目中,界面会出现一个输入框嵌入在一个面板里,面板又套在一个更大的区域里。那按一下键盘,按键事件到底发给谁?会不会一层层都收到?这就是"事件传播"的话题。
QML 里键盘事件默认会从获得焦点的内层逐步向外层冒泡,并且可以在任意一层用 event.accepted = true 把它拦下来。这个 demo 用三层嵌套的矩形做演示:内层、中层、外层。点内层后按任意键,你会看到事件一层一层往外闪烁。打开"拦截"开关后,按键只在内层触发,不再外传。
一个开关,两种行为,本文把按键事件的传播和拦截一次讲清。
演示代码
import QtQuick import QtQuick.Controls import QtQuick.Layouts FadeInAnimation { ColumnLayout { anchors.fill: parent anchors.margins: 20 spacing: 15 TitleSeparator { title: "事件传播与拦截" description: "按键事件从获得焦点的内层向外层逐级传播,设置 event.accepted = true 可阻止继续传播" } // 外层 Rectangle { id: outer Layout.fillWidth: true Layout.preferredHeight: 220 radius: 10 color: "#FAFAFA" border.color: outer.hit ? "#E91E63" : "#ccc" border.width: 2 property bool hit: false SequentialAnimation { id: flashOuter running: false ColorAnimation { target: outer; property: "color"; to: "#FFE0E0"; duration: 80 } ColorAnimation { target: outer; property: "color"; to: "#FAFAFA"; duration: 300 } ScriptAction { script: outer.hit = false } } Keys.onPressed: (event) => { hit = true; flashOuter.restart() } // 中层 Rectangle { id: middle anchors.centerIn: parent width: 240; height: 130 radius: 8 color: "#FFF" border.color: middle.hit ? "#FF9800" : "#ccc" border.width: 2 property bool hit: false SequentialAnimation { id: flashMiddle running: false ColorAnimation { target: middle; property: "color"; to: "#FFF3E0"; duration: 80 } ColorAnimation { target: middle; property: "color"; to: "#FFF"; duration: 300 } ScriptAction { script: middle.hit = false } } Keys.onPressed: (event) => { hit = true; flashMiddle.restart() } // 内层 —— 唯一可以获取焦点 Rectangle { id: inner anchors.centerIn: parent width: 160; height: 60 radius: 6 color: inner.activeFocus ? "#EAF4FF" : "#FFF" border.color: inner.activeFocus ? "#1296FF" : "#ccc" border.width: 2 focus: true property bool hit: false SequentialAnimation { id: flashInner running: false ColorAnimation { target: inner; property: "color"; to: "#BBDEFB"; duration: 80 } ColorAnimation { target: inner; property: "color"; to: "#EAF4FF"; duration: 300 } ScriptAction { script: inner.hit = false } } Text { anchors.centerIn: parent text: "点击这里,然后按任意键" font.pointSize: 10 color: inner.activeFocus ? "#1296FF" : "#999" } MouseArea { anchors.fill: parent; onClicked: inner.forceActiveFocus() } Keys.onPressed: (event) => { hit = true; flashInner.restart() if (interceptSwitch.checked) event.accepted = true // 拦截:阻止向外传播 } } } } // 拦截开关 Switch { id: interceptSwitch Layout.alignment: Qt.AlignHCenter text: "内层拦截 (event.accepted = true)" checked: false } Text { Layout.alignment: Qt.AlignHCenter text: interceptSwitch.checked ? "拦截开启:按键只触发内层(蓝色闪烁)" : "拦截关闭:按键逐层传播(蓝→橙→红闪烁)" font.pointSize: 11 color: "#666" } Item { Layout.fillHeight: true } } }关键逻辑解析
为什么焦点在内层
键盘发送的目标是拥有焦点的元素。三层矩形里,只有最内层写了focus: true,所以按键会先送到内层。中层、外层都没有焦点,但它们照样能收到这个按键——这正是传播的体现,也是理解本文的关键。
事件从内向外冒泡
按下键盘,事件先到达内层inner,内层处理完(这里只是闪一下),事件继续向外层冒泡——穿过中层middle,再传到外层outer。三层都有各自的Keys.onPressed,所以三层会依次触发,视觉上就是内(蓝)→中(橙)→外(红)像涟漪一样连续闪。这就是键盘事件传播的默认路径:焦点元素 → 它的父级 → 更外层,一直往上冒泡。
能不能收到,取决于 ancestors 链
传播能一路走到外层,靠的是"内层在结构上嵌套在中层、外层里面"。信号会沿父链上传,所以中层、外层即使没焦点也能收到。取消这个嵌套关系,事件就到不了不相关的元素——传播是沿着父子链走的。
拦截 = event.accepted
打开拦截开关后,内层onPressed里多执行了这一行:
if (interceptSwitch.checked) event.accepted = trueevent.accepted = true的意思直白:这个事件我已经收下了,不用再往下传了。于是它在内层就被"截断",冒泡到此为止,中层、外层再也收不到,按键时只剩内层闪一下蓝光。
两层概念的区分
这里需要分清楚两个东西:“收到"和"拦下”。事件默认是"经过"外层(冒泡会经过它)还是会被"拦下"(内层accepted)?你可以在同一段传播里观察两种做法:不改accepted,事件一路冒泡全部能触发;设了accepted,它在外层的传播路径被切断。开关一开一关,对比一目了然。
闪现动画怎么实现的
每层用一个SequentialAnimation做"闪一下":先快速变到高亮色(80 毫秒),再慢速回到原色(300 毫秒)。被按键命中时flashX.restart()重新播放一次,视觉上就是被"点亮"了一下。用hit属性配合边框变色,让"这一层响应了"看得更清楚。
运行验证
- Qt Creator 打开示例的
CMakeLists.txt; - 按
Ctrl+R运行; - 保持拦截开关关闭,点最内层获得焦点,再按任意键,观察内→中→外三层依次闪烁(蓝→橙→红),底部文字提示"按键逐层传播";
- 打开拦截开关,再按键,看只有最内层闪蓝、中外层不再响应,底部文字提示"拦截开启"。
小结
想让某个面板对某些快捷键"整个包一层统一处理",可在父级写Keys.onPressed统一收口。对话框这类"屏蔽底层交互"的界面,常靠在上层拦截按键事件实现模态。需要区分"快捷键归按钮、普通输入归输入框"时,用accepted决定谁先谁后。
已验证环境:
- Qt 版本:Qt 6.11.1
- 操作系统:Windows 11
- 项目地址:QML 示例合集V2.0 - qml_keys_focus