【HarmonyOS】View点击穿透,层叠View点击事件控制
一、什么是点击穿透?
很多开发者第一次听到"点击穿透",都会理解成:
上面的 View 不处理点击,让下面的 View 收到点击。
例如:
┌──────────────────────┐ │ Mask(View) │ ├──────────────────────┤ │ Button │ └──────────────────────┘点击 Mask:
希望 Button 被点击。
这就是最典型的:
Click Through(点击穿透)
很多 Loading、蒙层、引导页都会用到这种效果。
常见应用场景
例如:
Loading 蒙层
Loading ──────────── Button有两种需求:
方案一:
Loading 出现后
禁止点击 Button。
方案二:
Loading 只是动画
允许点击 Button。
这就是点击穿透。
水印
例如:
WaterMark ──────────── List希望:
可以滑动 List
而不是水印拦截事件。
悬浮提示
例如:
Toast ──────────── 地图Toast 不应该影响地图拖动。
引导层
例如:
遮罩 ──────────── Button希望:
Button 可以继续点击。
二、HarmonyOS 点击事件流程
用户点击屏幕后,并不是直接调用onClick()。
实际上,中间会经历多个阶段。
用户点击 ↓ PointerEvent ↓ HitTest ↓ TouchEvent ↓ GestureRecognizer ↓ ClickEvent ↓ onClick()整个过程如下:
| 阶段 | 作用 |
|---|---|
| PointerEvent | 采集触摸坐标 |
| HitTest | 寻找命中的 View |
| TouchEvent | 分发触摸事件 |
| GestureRecognizer | 识别点击、长按等手势 |
| ClickEvent | 生成点击事件 |
| onClick | 开发者收到事件 |
因此:
点击穿透的核心就在于 HitTest。
三、什么是 HitTest?
HitTest(命中测试)就是:
根据用户点击的位置,找到真正应该接收事件的组件。
例如:
Stack ├── Image ├── Text └── Button用户点击:
Button区域HitTest:
第一步:
检查 Button
如果:
命中
停止继续查找。
所以:
Button 收到点击。
如果 Button 不存在:
Stack ├── Image └── Text点击:
Image区域Image 收到事件。
所以:
HitTest 永远都是:
从最上层开始查找。
四、Stack 为什么最容易出现点击穿透问题?
因为 Stack 本身就是:
层叠布局
例如:
Stack() { Image($r('app.media.bg')) Text("HarmonyOS") .fontSize(28) Button("登录") } .width('100%') .height(300)显示效果:
Button Text ImageButton 在最上层。
点击:
Button。
Image 根本不会收到事件。
五、事件命中的顺序
假设:
Stack() { Image() Column() Button() }HitTest:
不是代码顺序。
而是:
显示顺序。
实际:
Button ↓ Column ↓ Image谁最上面,
谁先检测。
六、Button 为什么挡住下面的 Image?
例如:
Button("点击")覆盖 Image。
HitTest:
点击 ↓ Button ↓ 命中成功 ↓ 停止查找Image:
根本不知道有人点击。
七、为什么 enabled(false) 不能点击穿透?
很多人会写:
Button("登录") .enabled(false)希望:
Button 不可点击。
下面:
Image 可以收到事件。
实际:
完全不会。
为什么?
因为:
Button:
仍然参与:
HitTest。
只是:
不会触发:
onClick()它仍然挡住下面。
流程:
Hit ↓ Button ↓ Disabled ↓ 消费事件所以:
下面不会收到点击。
这是 HarmonyOS 最容易踩坑的地方之一。
八、opacity(0) 为什么还能点击?
例如:
Text("Hello") .opacity(0) .onClick(() => { console.info("点击") })页面:
已经完全透明。
很多人认为:
应该不能点。
实际上:
可以。
为什么?
因为:
opacity 只影响绘制 不影响 HitTest组件:
虽然透明。
但是:
仍然存在 RenderNode。
所以:
仍然参与命中测试。
正确理解
透明:
RenderNode 存在所以:
还能点击。
九、visibility 与点击的关系
例如:
.visibility(Visibility.Visible)正常:
显示
可点击。
如果:
.visibility(Visibility.Hidden)特点:
- 不显示
- 保留布局
- 通常仍参与布局计算
如果:
.visibility(Visibility.None)特点:
- 不参与布局
- 不参与 HitTest
因此:
真正想让 View 完全失效,
应该考虑Visibility.None(具体行为请结合当前 API 版本验证)。
十、如何实现真正的点击穿透?
假设:
Mask ↓ Button目标:
点击:
Mask。
Button 收到事件。
可以采用以下几种方案:
方案一:避免使用覆盖层
如果只是展示提示、动画、水印等,优先调整布局,让装饰元素不要覆盖可交互区域,这是最简单、性能也最好的方案。
方案二:动态移除遮罩
例如:
if (this.showMask) { // 显示遮罩 }需要点击穿透时:
直接隐藏或移除遮罩。
这样:
HitTest 就不会命中遮罩。
方案三:合理控制 Overlay
例如:
Loading:
Overlay ↓ PageLoading 结束:
立即关闭 Overlay。
不要长期保留透明蒙层。
方案四:拆分交互区域
例如:
Stack ├── Image ├── Decoration └── ButtonDecoration:
不要覆盖 Button。
这样无需点击穿透。
十一、zIndex 对点击有没有影响?
有。
例如:
Image() .zIndex(1) Button() .zIndex(10)HitTest:
优先:
Button ↓ Image因此:
zIndex 不仅影响显示顺序,也会影响命中顺序。
十二、企业开发最佳实践
建议遵循以下原则:
1、不要依赖透明实现点击穿透
错误:
.opacity(0)正确:
移除组件
或者:
不要参与布局。
2、不要把整个页面放进一个 Stack
层级越复杂:
HitTest:
越复杂。
建议:
分层。
3、Loading 使用统一组件
例如:
LoadingManager统一:
开启
关闭
避免:
透明蒙层遗留。
4、合理使用 zIndex
不要:
99999满天飞。
统一规划:
0~99 页面 100 Overlay 200 Dialog 300 Popup维护更加容易。
