文章配图:透明覆盖层设计、onClick 事件回调、回调里 this 绑定、点击层与渲染层 z 序

页面预览

前言

上一篇我们用 position 把猫咪精确摆到了棋盘格子里。但玩家要怎么「投放」猫咪到指定列?答案是在棋盘上方盖一层透明点击层——5 个等宽透明 Column,每个绑定 onClick 处理对应列的投放逻辑。这是棋盘类游戏的经典套路:渲染层只负责画,点击层独立负责交互,两层职责分离,互不干扰。

本篇以「猫猫大作战」游戏页第 3 层(列点击层)为锚点,把透明覆盖层设计onClick 事件回调回调里 this 绑定点击层与渲染层 z 序四大要点讲透。读完本篇你将能独立写出:棋盘列点击层透明热区覆盖点击事件传递三种实战套路。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–16 篇。本篇是阶段一「棋盘渲染」的交互收尾篇。

一、场景拆解:列点击层

打开 entry/src/main/ets/pages/Index.etsGameView,找到 Stack 第 3 层:

// 来源:entry/src/main/ets/pages/Index.ets  GameView() 第 3 层
Row() {
  ForEach(this.cols, (col: number) => {
    Column()
      .width(GameConfig.CELL_SIZE)
      .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
      .backgroundColor('rgba(0,0,0,0)')
      .onClick(() => {
        this.handleColumnClick(col);
      })
  }, (col: number) => `click_${col}`)
}

这条透明覆盖层规格拆解:

元素 类型 作用
Row 外壳 横向容器 5 列横向并列
ForEach 循环渲染 遍历 [0,1,2,3,4]
Column 透明块 单列热区 60×480vp 透明矩形
.backgroundColor('rgba(0,0,0,0)') 完全透明 不挡下层视觉
.onClick(() => ...) 点击回调 投放到第 col

关键经验点击层必须透明且置于渲染层之上——透明保证视觉不被遮,置于上层保证点击事件先被它捕获。

二、透明覆盖层的结构

2.1 三层 Stack 叠加

回顾整个 Stack 结构(第 9 篇讲过):

Stack() {
  // 第 1 层:棋盘背景网格(zIndex 默认 0)
  Column() { ForEach(this.rows, ...) }

  // 第 2 层:猫咪渲染层(zIndex 默认 0,后声明覆盖在上)
  ForEach(this.cats, (cat: Cat) => {
    Column() { /* ... */ }.position({ x: ..., y: ... })
  }, (cat: Cat) => cat.id)

  // 第 3 层:列点击层(本篇重点,最后声明在最顶层)
  Row() {
    ForEach(this.cols, (col: number) => {
      Column()
        .width(GameConfig.CELL_SIZE)
        .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
        .backgroundColor('rgba(0,0,0,0)')
        .onClick(() => { this.handleColumnClick(col); })
    }, (col: number) => `click_${col}`)
  }
}
.width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE)
.height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
.borderRadius(12)
.clip(true)
.backgroundColor('#D6EEF5')

叠层顺序:在 Stack 内,后声明的组件默认覆盖在先声明的组件之上。本项目声明顺序是「背景 → 猫咪 → 点击层」,所以点击层在最顶部,能捕获所有点击。

2.2 透明背景的两种写法

// 写法 1:rgba 第四位 alpha=0(本项目)
.backgroundColor('rgba(0,0,0,0)')

// 写法 2:Color.Transparent
.backgroundColor(Color.Transparent)

两种效果相同,Color.Transparent 更直观,推荐用写法 2

提示:不要用 .opacity(0) 做透明点击层——opacity(0) 会让组件「完全不可见也不可点击」,点击事件会被丢弃。.backgroundColor(Color.Transparent) 只是背景透明,组件本身依然可点击。

2.3 为什么用 Row + Column 而不是单个透明 Stack

// 方案 A:5 个 Column 横向并列(本项目)
Row() {
  ForEach(this.cols, (col: number) => {
    Column().width(CELL_SIZE).height(BOARD_H * CELL_SIZE)
      .onClick(() => this.handleColumnClick(col))
  }, ...)
}

// 方案 B:单个透明 Stack + onClick 算列号
Stack() {
  /* 整个棋盘热区 */
}.onClick((event) => {
  const col = Math.floor(event.x / CELL_SIZE)
  this.handleColumnClick(col)
})
方案 优点 缺点
A:5 个 Column 每个 Column 独立 onClick,逻辑简单 多建 5 个组件
B:单个 Stack + 算列号 只建 1 个组件,性能更优 onClick 事件要算 event.x / CELL_SIZE,触摸坐标 API 较繁琐

实战经验棋盘列数 ≤ 10 用方案 A(逻辑简单可读性高),列数 > 10 或需要精确像素热区时用方案 B

三、onClick 事件回调

3.1 onClick 的回调签名

.onClick((event: GestureEvent) => {
  // event.fingerList[0].localX  → 触摸点相对组件左上角的 x
  // event.fingerList[0].localY  → 触摸点相对组件左上角的 y
  // event.source                 → 触发源(SourceType.Mouse / Touch / etc.)
})

本项目回调不读 event,只关心 col(闭包捕获),所以省略了 event 参数:

.onClick(() => {
  this.handleColumnClick(col);
})

3.2 闭包捕获 col

ForEach(this.cols, (col: number) => {
  Column()
    .onClick(() => {
      this.handleColumnClick(col);   // 闭包捕获当前 col
    })
}, (col: number) => `click_${col}`)

关键:ForEach 每次迭代都创建一个新的闭包,捕获当时的 col 值。第 0 列的回调里 col = 0,第 4 列的回调里 col = 4——闭包是「按值捕获」,不是「按引用捕获」

提示:JavaScript/TypeScript 用 var 时会有「闭包共享变量」的坑,但 ArkTS 的 ForEach 第二参数 (col: number) => {...} 每次迭代都传一个新的 col,所以不会共享。

3.3 onClick 触发时机

ArkUI 的 onClick点击抬起时触发(down → up 算一次 click):

手势阶段 触发回调
手指按下(down) onTouchDown
手指移动(move) onTouchMove
手指抬起(up) onClick 触发

实战经验长按要响应用 onTouch + 计时器,单纯点击用 onClick 就够了。本系列第 100 篇会专讲 Menu 长按菜单。

四、回调里 this 绑定(箭头函数)

4.1 箭头函数 this 保留外层

.onClick(() => {
  this.handleColumnClick(col);   // this 指向 Index struct
})

这里用箭头函数 () => {...}箭头函数没有自己的 this,继承外层作用域的 this。在 ArkUI 的 @Component 里,外层 this 就是组件实例(Index struct),所以能直接调 this.handleColumnClick

4.2 普通函数 this 丢失(踩坑)

// ❌ 错误:普通函数 this 指向触发事件的组件,不是 Index
.onClick(function () {
  this.handleColumnClick(col);   // this 不是 Index,报错
})

// ✅ 正确:箭头函数保留外层 this
.onClick(() => {
  this.handleColumnClick(col);
})

// ✅ 也可以:用 bind 显式绑定
.onClick(function () {
  this.handleColumnClick(col);
}.bind(this))

实战经验ArkUI 事件回调统一用箭头函数——避免 this 丢失。本系列第 38 篇会专讲箭头函数 this 绑定。

4.3 handleColumnClick 的实现

// 来源:entry/src/main/ets/pages/Index.ets
handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  if (this.gameEngine.dropCat(column)) {
    this.cats = this.gameEngine.getAllCats();
    this.nextCatLevel = this.gameEngine.getNextCatLevel();
  }
}

拆解:

步骤 代码 作用
1 if (this.gameState !== PLAYING) return 仅在游戏中接受点击
2 this.gameEngine.dropCat(column) 引擎投放猫到第 column 列
3 this.cats = this.gameEngine.getAllCats() 同步最新猫数组到 state
4 this.nextCatLevel = ... 更新下一只猫预览

关键经验UI 层只负责调引擎、同步 state,不直接改棋盘逻辑——这是 MVC 分层的最小实践。

五、点击层与渲染层的 z 序管理

5.1 Stack 后声明覆盖前声明

Stack() {
  Column() { /* 背景网格 */ }            // 第 1 声明:底层
  ForEach(this.cats, ...) { /* 猫咪 */ } // 第 2 声明:中层
  Row() { /* 透明点击层 */ }              // 第 3 声明:顶层
}

Stack 默认叠层规则:后声明的子组件覆盖在先声明的子组件之上。本项目点击层最后声明,所以在最顶层——玩家点击事件先被点击层捕获,下层猫咪和背景网格感知不到点击

5.2 显式 zIndex 控制

Stack() {
  Column() { /* 背景 */ }.zIndex(0)
  ForEach(...) { /* 猫咪 */ }.zIndex(1)
  Row() { /* 点击层 */ }.zIndex(2)
}

显式 zIndex 比「声明顺序」更清晰,团队协作时建议显式写。

5.3 弹窗层叠在所有之上

build() {
  Stack() {
    if (this.gameState === GameState.IDLE) {
      this.MainMenuView()
    } else {
      this.GameView()                       // 含棋盘+点击层
    }

    if (this.gameState === GameState.PAUSED) {
      this.PauseOverlay()                   // 暂停弹窗
    }

    if (this.gameState === GameState.GAME_OVER) {
      this.GameOverOverlay()                // 结束弹窗
    }
  }
  .width('100%').height('100%')
}

外层 Stack 里:游戏页先声明,弹窗后声明——弹窗永远覆盖游戏页,无论游戏页内部点击层 zIndex 多高,弹窗都在它之上。本系列第 93 篇会专讲暂停遮罩。

六、完整代码:列点击层

// 来源:entry/src/main/ets/pages/Index.ets  GameView() 第 3 层
@Builder
GameView() {
  Column() {
    this.GameHUD()

    Column() {
      // 预告区(第 12 篇讲)
      Row() { /* ... */ }

      // 游戏棋盘 Stack(3 层叠)
      Stack() {
        // 第 1 层:棋盘背景网格(第 13 篇讲)
        Column() { /* ForEach rows × cols */ }

        // 第 2 层:猫咪渲染(第 15、16 篇讲)
        ForEach(this.cats, (cat: Cat) => {
          Column() { Text(CatConfig[cat.level].emoji) }
            .position({ x: ..., y: ... })
            .animation({ duration: 100 })
        }, (cat: Cat) => cat.id)

        // 第 3 层:列点击层(本篇重点)
        Row() {
          ForEach(this.cols, (col: number) => {
            Column()
              .width(GameConfig.CELL_SIZE)                          // 60vp 宽
              .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE) // 480vp 高
              .backgroundColor('rgba(0,0,0,0)')                     // 完全透明
              .onClick(() => {
                this.handleColumnClick(col);                        // 投放到第 col 列
              })
          }, (col: number) => `click_${col}`)
        }
      }
      .width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE)         // 300vp
      .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)       // 480vp
      .borderRadius(12)
      .clip(true)
      .backgroundColor('#D6EEF5')
    }
    .alignItems(HorizontalAlign.Center)

    Spacer()

    // 底部控制栏(第 18 篇讲)
    Row() { /* 暂停 / 重新开始 */ }
  }
}

handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  if (this.gameEngine.dropCat(column)) {
    this.cats = this.gameEngine.getAllCats();
    this.nextCatLevel = this.gameEngine.getNextCatLevel();
  }
}

七、踩坑提示

7.1 透明点击层挡住下层动画的点击

// ❌ 错误:背景网格也想响应点击,但被点击层挡了
Stack() {
  Column() { /* 背景网格 */ }.onClick(() => { /* 想响应 */ })
  Row() { /* 透明点击层 */ }   // 后声明,挡住背景的 onClick
}

解法:把背景的 onClick 删掉,点击层统一处理;或者点击层用 .hitTestBehavior(HitTestMode.Transparent) 让点击穿透到下层。

7.2 onClick 在 Row 上不触发

// ❌ 错误:onClick 挂在 Row 外壳,但 Row 内的 Column 占满了空间
Row() {
  ForEach(this.cols, (col: number) => {
    Column().width(CELL_SIZE).height(480)
  }, ...)
}.onClick(() => { /* 永远不触发 */ })

原因:ArkUI 的 onClick 只在组件自身的可点击区域触发,子组件占据的区域点击事件归子组件。Row 内的 Column 占满了 Row,所以 Row 的 onClick 永远不触发。

解法:把 onClick 挂在每个 Column 上(本项目做法),或者把 Row 改成 .hitTestBehavior(HitTestMode.Block) 强制拦截。

7.3 闭包 col 共享的坑

// ❌ 错误(JS 经典坑):用 var 共享变量
for (var col = 0; col < 5; col++) {
  Column().onClick(() => { this.handleColumnClick(col) })
}
// 5 个 onClick 都捕获 col=5(循环结束后的值)

ArkTS 中不会发生:ArkTS 默认禁 var,强制用 let/const,且 ForEach 的迭代变量是每次新建。本系列第 38 篇会专讲箭头函数与闭包。

八、调试技巧

  1. 临时给点击层加半透明背景.backgroundColor('rgba(255,0,0,0.3)'),看清 5 列热区是否等宽对齐棋盘格。
  2. onClick 里打 logconsole.info('click col', col),追点击是否正确分发到对应列。
  3. 点击无响应排查:检查 gameState === PLAYING;检查上层是否有弹窗 Stack 挡住;检查 .hitTestBehavior
  4. 触摸坐标读不到onClick 的 event 参数在 ArkUI 严格模式下需要显式类型 GestureEvent,否则 TS 报错。

九、性能与最佳实践

  1. 点击层透明用 Color.Transparent,不要用 .opacity(0)——后者会让组件不可点击。
  2. 5 列热区用 5 个 Column + 独立 onClick,逻辑清晰;列数多用单 Stack 算列号。
  3. 箭头函数保留外层 this,回调里能直接调 this.handleColumnClick
  4. 闭包按值捕获 col,ForEach 每次迭代都传新的 col,不会共享。
  5. z 序用显式 zIndex,比声明顺序更易维护,团队协作首选。
  6. MVC 分层:UI 只调引擎 + 同步 state,不写棋盘逻辑——本系列第 111 篇会专讲 GameEngine 引擎类。

十、阶段一收尾:UI 组件与布局实战总结

本篇是阶段一「UI 组件与布局实战(1-30)」中棋盘渲染的最后一篇。回顾阶段一已覆盖的内容:

主题 核心组件
1-5 主菜单排版 Text、Button、Spacer、Column
6-10 主菜单视觉 linearGradient、justifyContent、Row、Stack、if/else
11-12 游戏页 HUD + 预告 GameHUD、Circle 预告
13-14 棋盘背景 ForEach 双层循环、隔行变色
15-16 猫咪渲染 Emoji Text、Circle 背景、position 绝对定位
17(本篇) 列点击层 透明 onClick 覆盖层

接下来第 18-20 篇会继续收尾游戏页:底部控制栏、暂停按钮样式、SymbolGlyph 系统图标。然后进入阶段一中后段:RelativeContainer、Blank、layoutWeight、displayPriority、Scroller、Badge、Image 资源、暗色模式、i18n、AttributeModifier。

总结

本篇我们从列点击层切入,掌握了透明覆盖层设计(5 个等宽透明 Column)onClick 闭包按值捕获 col箭头函数保留外层 thisStack 后声明覆盖前声明的 z 序四大要点,并给出了列点击层 + handleColumnClick 的完整代码。核心要点:点击层透明且置于渲染层之上;onClick 用箭头函数保留 this;闭包按值捕获列号;UI 只调引擎不写逻辑

下一篇我们将继续游戏页,拆解底部控制栏——Row 双按钮布局(暂停 / 重新开始)。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐