【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 Image

Button 在最上层。

点击:

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 ↓ Page

Loading 结束:

立即关闭 Overlay。

不要长期保留透明蒙层。


方案四:拆分交互区域

例如:

Stack ├── Image ├── Decoration └── Button

Decoration:

不要覆盖 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

维护更加容易。