文章配图:Row 横向布局、Spacer 占位、Button 双态样式(暂停灰 / 重新开始红)、底部 paddin

页面预览

前言

棋盘和点击层搞定后,游戏页还缺一个底部控制栏——玩家在游戏中需要随时「暂停」或「重新开始」。这是 ArkUI 里最经典的 Row 双按钮布局:两个 ButtonSpacer 撑开分列两端,整体一行铺满底部。

本篇以「猫猫大作战」游戏页底部控制栏为锚点,把 Row 横向布局Spacer 占位Button 双态样式(暂停灰 / 重新开始红)、底部 padding 留白四大要点讲透。读完本篇你将能独立写出:底部操作栏双按钮等距布局按钮色彩语义三种实战套路。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–17 篇。本篇继续游戏页底部收尾。

一、场景拆解:底部控制栏

打开 entry/src/main/ets/pages/Index.etsGameView,找到最底部的控制栏:

// 来源:entry/src/main/ets/pages/Index.ets  GameView() 底部
Row() {
  Button('暂停')
    .width(100)
    .height(44)
    .fontSize(15)
    .fontColor('#FFFFFF')
    .backgroundColor('#95A5A6')
    .borderRadius(22)
    .onClick(() => {
      this.pauseGame();
    })

  Spacer()

  Button('重新开始')
    .width(100)
    .height(44)
    .fontSize(15)
    .fontColor('#FFFFFF')
    .backgroundColor('#E74C3C')
    .borderRadius(22)
    .onClick(() => {
      this.clearTimers();
      this.startGame();
    })
}
.width('100%')
.padding({ left: 24, right: 24, bottom: 24, top: 12 })

这条底部栏规格拆解:

元素 类型 作用
Row 外壳 横向容器 width('100%') 铺满底部
Button('暂停') 灰色按钮 100×44vp,圆角 22
Spacer() 弹性占位 把两按钮推向左右两端
Button('重新开始') 红色按钮 100×44vp,圆角 22
.padding({...}) 内边距 左右 24、底 24、顶 12

关键经验Row + Spacer 是「两端对齐」的最简组合——Spacer 吃掉中间所有剩余空间,把两按钮顶到两端。

二、Row 横向布局

2.1 Row 的核心属性

Row() {
  /* 子组件横向排列 */
}
.width('100%')                                    // 容器宽度
.height(60)                                       // 容器高度(可选)
.justifyContent(FlexAlign.SpaceBetween)          // 主轴对齐
.alignItems(VerticalAlign.Center)                // 交叉轴对齐
.padding({ left: 24, right: 24 })                // 内边距

Row 的两个轴:

方向 对齐属性
主轴 水平 justifyContent(FlexAlign.xxx)
交叉轴 垂直 alignItems(VerticalAlign.xxx)

2.2 主轴对齐方式

justifyContent 控制子组件在主轴(水平方向)的排布:

FlexAlign 值 视觉效果 适用场景
Start 靠左排列 左对齐组
Center 居中排列 居中按钮组
End 靠右排列 右对齐组
SpaceBetween 两端对齐,中间不留白 两端按钮(推荐)
SpaceAround 每个按钮左右各留半个间距 等距分布
SpaceEvenly 任意两按钮间距相等(含端点) 均匀分布

本项目底部栏用了 Spacer() 手动撑开,所以 Row 没显式设 justifyContent——Spacer 已经把两按钮顶到两端了。

2.3 交叉轴对齐

alignItems 控制子组件在交叉轴(垂直方向)的对齐:

Row() {
  Button('暂停').height(44)
  Spacer()
  Button('重新开始').height(44)
}
.alignItems(VerticalAlign.Center)    // 两按钮垂直居中
VerticalAlign 值 视觉
Top 顶部对齐
Center 垂直居中(推荐)
Bottom 底部对齐

提示:两按钮高度一致时 alignItems 几乎无视觉差异,但保留 Center 是好习惯——后续按钮高度变化时不会错位。

三、Spacer 弹性占位

3.1 Spacer 的本质

Row() {
  Button('暂停')
  Spacer()           // 占据两按钮之间的所有剩余空间
  Button('重新开始')
}
.width('100%')

Spacer 是一个弹性空白组件——它会自动撑开,吃掉父容器中所有剩余空间。在 Row 里它把后续组件推向右端,实现「两端对齐」。

3.2 Spacer 高度的坑

Row() {
  Button('暂停').height(44)
  Spacer()                                    // 默认 height = 父高度
  Button('重新开始').height(44)
}
.height(60)                                   // Row 高度 60
// Spacer 自动占满 60 高度(与 Row 等高)

关键SpacerRow 里默认高度等于父容器高度,在 Column 里默认宽度等于父容器宽度。本项目 Row 没设 height,由内部 Button 的 44vp 撑开,Spacer 高度自然也是 44vp。

3.3 Spacer vs layoutWeight

「两端对齐」有两种写法:

// 写法 1:Spacer(本项目)
Row() {
  Button('暂停'); Spacer(); Button('重新开始')
}

// 写法 2:layoutWeight(第 23 篇会讲)
Row() {
  Button('暂停').layoutWeight(0)
  Blank().layoutWeight(1)     // 占满剩余
  Button('重新开始').layoutWeight(0)
}
写法 优点 缺点
Spacer 简洁直观 只能撑开,不能按比例
layoutWeight 可按比例分配 语法稍繁

实战经验两端对齐用 Spacer,按比例分宽用 layoutWeight。本系列第 22 篇会专讲 Blank 占位,第 23 篇专讲 layoutWeight。

3.4 多 Spacer 实现等距分布

Row() {
  Spacer()
  Button('A')
  Spacer()
  Button('B')
  Spacer()
  Button('C')
  Spacer()
}
.width('100%')
// 4 个 Spacer 平分剩余空间,3 个按钮等距分布

关键Spacer 平分剩余空间——4 个 Spacer 就把剩余空间 4 等分,3 个按钮自然等距。

四、Button 双态样式

4.1 暂停按钮(灰色次要操作)

Button('暂停')
  .width(100)
  .height(44)
  .fontSize(15)
  .fontColor('#FFFFFF')
  .backgroundColor('#95A5A6')    // 灰色
  .borderRadius(22)              // 高度的一半 = 胶囊形
  .onClick(() => {
    this.pauseGame();
  })

色彩语义

颜色 hex 含义
#95A5A6 中性灰 次要操作(暂停)
#E74C3C 警告红 危险操作(重新开始会丢进度)
绿 #2ECC71 成功绿 主操作(开始游戏)
#F39C12 提醒橙 中性重置

提示:按钮颜色就是用户引导——绿色「开始」最显眼,灰色「暂停」温和,红色「重新开始」警示。本系列第 19 篇会专讲暂停按钮样式。

4.2 重新开始按钮(红色危险操作)

Button('重新开始')
  .width(100)
  .height(44)
  .fontSize(15)
  .fontColor('#FFFFFF')
  .backgroundColor('#E74C3C')    // 红色
  .borderRadius(22)              // 胶囊形
  .onClick(() => {
    this.clearTimers();
    this.startGame();
  })

点击逻辑拆解

步骤 代码 作用
1 this.clearTimers() 清掉旧的 3 个定时器
2 this.startGame() 重置引擎 + 启动新定时器

关键经验重新开始前必须 clearTimers——否则旧的 gameLoopTimer/spawnTimer/timeTimer 还在跑,会与新循环冲突,导致猫「闪现」或计时「跳变」。

4.3 圆角 22 = 胶囊形

.borderRadius(22)    // height(44) 的一半 → 胶囊形

胶囊形公式borderRadius = height / 2。本项目按钮高 44vp,圆角 22vp,左右两端是半圆,整体呈胶囊形。

圆角值 视觉 适用
0 直角 表格行
8 小圆角 卡片
12 中圆角 棋盘背景
22(=height/2) 胶囊 按钮(本项目)
999 超大圆角 头像

五、padding 内边距

5.1 底部栏 padding 配置

Row() { /* ... */ }
.width('100%')
.padding({ left: 24, right: 24, bottom: 24, top: 12 })
原因
left / right 24 两端按钮离屏幕边缘留 24vp 呼吸空间
bottom 24 底部留白,避开手势条/虚拟导航栏
top 12 顶部留白少一点,紧凑感

关键经验底部栏的 bottom padding 必须够大——HarmonyOS 设备屏幕底部有手势条或虚拟导航条,padding 太小按钮会被遮挡。

5.2 padding vs margin

// padding:内边距,在容器内部撑开空白
Row() { /* ... */ }.padding({ left: 24 })

// margin:外边距,在容器外部撑开空白
Row() { /* ... */ }.margin({ left: 24 })
属性 位置 影响布局
padding 容器内 子组件被「挤」向中心
margin 容器外 容器整体被推开

本项目底部栏用 padding——Row 铺满 100% 后,内部左右各留 24vp,让按钮不贴屏幕边缘。

六、底部栏与上方棋盘的联动

6.1 整体 GameView 结构

@Builder
GameView() {
  Column() {
    this.GameHUD()              // 顶部 HUD(第 11 篇)

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

      Stack() {                 // 游戏棋盘(第 13-17 篇)
        /* 背景 + 猫咪 + 点击层 */
      }
      .width(BOARD_W * CELL_SIZE)
      .height(BOARD_H * CELL_SIZE)
    }

    Spacer()                    // 关键:弹性占位把底部栏推到最底

    Row() { /* 暂停 / 重新开始 */ }   // 底部控制栏(本篇)
      .padding({ left: 24, right: 24, bottom: 24, top: 12 })
  }
  .width('100%').height('100%')
}

关键结构

  1. Column 主容器:从上到下依次是 HUD、棋盘、Spacer、底部栏。
  2. Spacer 在中间:吃掉棋盘下方所有剩余空间,把底部栏推到最底。
  3. 底部栏 padding bottom 24:避开屏幕底部手势条。

6.2 pauseGame 实现

// 来源:entry/src/main/ets/pages/Index.ets
pauseGame() {
  if (this.gameState === GameState.PLAYING) {
    this.gameState = GameState.PAUSED;
  }
}

逻辑:仅在 PLAYING 状态切到 PAUSED。切 PAUSED 后:

  • 主循环 gameLoopTimerif (gameState !== PLAYING) return物理暂停
  • spawnTimer 同理 → 生成暂停
  • timeTimer 里 if (gameState === PLAYING) gameTime++计时暂停
  • build() 检测 gameState === PAUSED → 渲染 PauseOverlay(本系列第 93 篇)。

七、完整代码:底部控制栏

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

    Column() {
      // 预告区
      Row() { /* ... */ }

      // 游戏棋盘
      Stack() { /* 3 层叠 */ }
        .width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE)
        .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
        .borderRadius(12)
        .clip(true)
        .backgroundColor('#D6EEF5')
    }
    .alignItems(HorizontalAlign.Center)

    Spacer()                       // 弹性占位,推底部栏到最底

    // 底部控制栏(本篇重点)
    Row() {
      Button('暂停')
        .width(100)
        .height(44)
        .fontSize(15)
        .fontColor('#FFFFFF')
        .backgroundColor('#95A5A6')
        .borderRadius(22)
        .onClick(() => {
          this.pauseGame();
        })

      Spacer()                    // 撑开两按钮到两端

      Button('重新开始')
        .width(100)
        .height(44)
        .fontSize(15)
        .fontColor('#FFFFFF')
        .backgroundColor('#E74C3C')
        .borderRadius(22)
        .onClick(() => {
          this.clearTimers();
          this.startGame();
        })
    }
    .width('100%')
    .padding({ left: 24, right: 24, bottom: 24, top: 12 })
  }
  .width('100%').height('100%')
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]]
  })
  .alignItems(HorizontalAlign.Center)
}

pauseGame() {
  if (this.gameState === GameState.PLAYING) {
    this.gameState = GameState.PAUSED;
  }
}

八、踩坑提示

8.1 Row 不铺满宽度导致按钮挤一起

// ❌ 错误:Row 没设 width,默认 wrap_content
Row() {
  Button('暂停'); Spacer(); Button('重新开始')
}
// Row 宽度 = 两按钮宽 + Spacer 默认宽,挤在左侧

// ✅ 正确:Row width('100%')
Row() { /* ... */ }.width('100%')

8.2 Spacer 忘记写导致两按钮紧贴

// ❌ 错误:没 Spacer,两按钮紧贴在 Row 左侧
Row() {
  Button('暂停')
  Button('重新开始')
}.width('100%')

// ✅ 正确:Spacer 撑开
Row() {
  Button('暂停')
  Spacer()
  Button('重新开始')
}.width('100%')

8.3 重新开始忘清定时器

// ❌ 错误:直接 startGame,旧定时器还在跑
Button('重新开始').onClick(() => {
  this.startGame();       // 旧的 gameLoopTimer 还在跑!
})

// ✅ 正确:先 clearTimers 再 startGame
Button('重新开始').onClick(() => {
  this.clearTimers();
  this.startGame();
})

后果:不清旧定时器,新游戏开始后会有两套主循环并行——猫会「闪现」(两个循环都改 cats),计时会「双倍跳」。

九、调试技巧

  1. 临时给 Row 加 border.border({ width: 1, color: Color.Red }),看清底部栏范围。
  2. 按钮点击无响应:检查 gameState;检查上层 Spacer 是否意外覆盖按钮区域。
  3. 重新开始后猫闪现:打开 DevEco Profiler 看 timer 数量,应该是 3 个,闪现说明有 6 个(旧 3 + 新 3)。
  4. 底部按钮被手势条挡:增大 padding.bottom 到 32 或 48,避开屏幕底部安全区。

十、性能与最佳实践

  1. 底部栏 = Row + Spacer + 双 Button——最简两端对齐布局。
  2. 圆角 = height / 2——胶囊形按钮的统一公式。
  3. 重新开始前必须 clearTimers——否则新旧定时器并行,状态错乱。
  4. 底部 padding 留够——避开手势条/虚拟导航栏,至少 24vp。
  5. 按钮颜色遵循语义——绿主操作、灰次要、红危险、橙中性。
  6. MVC 分层延续:底部栏 onClick 只调 pauseGame/startGame,不直接改 gameState

总结

本篇我们从底部控制栏切入,掌握了 Row 横向布局的主轴/交叉轴对齐Spacer 弹性占位撑出两端对齐Button 双态样式(暂停灰 / 重新开始红)+ 胶囊形公式padding 内边距避开底部手势条四大要点,并给出了底部栏 + pauseGame 的完整代码。核心要点:Row width(‘100%’) + Spacer 实现两端对齐;圆角 = height/2 胶囊形;重新开始必须 clearTimers;底部 padding 留够避开安全区

下一篇我们将继续拆解底部栏里的暂停按钮——Button 颜色与圆角的进阶样式。

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


相关资源:

Logo

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

更多推荐