HarmonyOS应用开发实战:猫猫大作战-transition 装饰绑定、TransitionType 推/拉/全部、与 if 条件渲染协同、与 H

前言

上一篇我们用 Hero 做了「单个元素跨页面」的连续动画——小猫图飞到大猫图。但整个页面的出现/消失怎么动?路由跳到新页面时新页面从右侧滑入、当前页面淡出;返回时反向。这种「整页面进/出」的转场动画用transition装饰器配合if条件渲染或路由切换触发。

本篇以「猫猫大作战」主菜单→游戏页、暂停弹窗进/出为场景,把transition 装饰绑定TransitionType 推/拉/全部与 if 条件渲染协同与 Hero 的差异四大要点讲透。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–57 篇。本篇是阶段三第八篇。

一、场景拆解:主菜单与暂停弹窗的转场

回顾「猫猫大作战」状态切换(第 31、39 篇):

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() // 结束弹窗 } } }

痛点if条件渲染的组件出现/消失是瞬间的——主菜单切游戏页瞬切、暂停弹窗瞬现/瞬隐。玩家感觉「弹窗突兀」,缺过渡。

transition 的解法

@Builder PauseOverlay() { Column() { /* ... 弹窗内容 ... */ } .transition({ type: TransitionType.All, opacity: 0, scale: 0.8 }, { duration: 300, curve: Curve.EaseInOut }) } // 出现:opacity 0→1, scale 0.8→1,300ms 淡入放大 // 消失:opacity 1→0, scale 1→0.8,300ms 淡出缩小

关键经验transition 让if出现/消失的组件有进/出动画——不再瞬切,过渡平滑。

二、transition 装饰绑定

2.1 基本用法

Column() { /* ... */ } .transition({ type: TransitionType.All, opacity: 0 }, { duration: 300, curve: Curve.EaseInOut })

拆解

片段含义
.transition(translateParam, animationParam)转场装饰器
{ type: TransitionType.All, opacity: 0 }TranslateParam:转场类型+起始属性
{ duration: 300, curve: Curve.EaseInOut }AnimationParam:时长+曲线

机制

  1. 组件出现if条件变 true):从opacity: 0补间到当前 opacity(1),300ms 淡入。
  2. 组件消失if条件变 false):从当前 opacity(1)补间到opacity: 0,300ms 淡出,完成后销毁。

2.2 TranslateParam 转场参数

interface TranslateParam { type?: TransitionType; // 转场类型(Push/Pop/All) opacity?: number; // 透明度起始值 scale?: number; // 缩放起始值(或 {x, y} 分轴) translate?: TranslateParam; // 平移起始值({x, y}) rotate?: number; // 旋转起始值(度) }

2.3 AnimationParam 动画参数

{ duration: 300, // 转场时长 curve: Curve.EaseInOut, // 缓动曲线 delay: 0 // 延迟 }

三、TransitionType 推/拉/全部

3.1 三种类型

enum TransitionType { Push, // 进:组件出现时动 Pop, // 出:组件消失时动 All // 全:出现和消失都动 }

3.2 场景对照

场景type效果
暂停弹窗进/出都动All淡入放大 + 淡出缩小
新页面只管进Push滑入,消失瞬切
旧页面只管出Pop滑出,出现瞬切

关键经验多数弹窗用All——进/出都动才协调;只在「单向场景」用 Push/Pop。

3.3 路由 Push/Pop 的语义

transition 的 Push/Pop 与路由跳转语义对应:

router.pushUrl(前进)→ 触发 Push 转场(新页面进) router.back(返回)→ 触发 Pop 转场(当前页面出)
// 新页面只做 Push 进动画 Column() { /* ... */ } .transition({ type: TransitionType.Push, translate: { x: 300 } }, { duration: 300 }) // pushUrl 时新页面从右侧 x=300 滑入到 x=0 // back 时新页面消失瞬切(没 Pop 转场)

四、与 if 条件渲染协同

4.1 transition 依附 if 触发

@State showPause: boolean = false; build() { Stack() { this.GameView() if (this.showPause) { // ← if 触发 this.PauseOverlay() .transition({ type: TransitionType.All, opacity: 0, scale: 0.8 }, { duration: 300 }) } } } // 出现:showPause=true,PauseOverlay 从 opacity 0/scale 0.8 淡入放大 // 消失:showPause=false,PauseOverlay 淡出缩小到 opacity 0/scale 0.8,完成后销毁

关键经验transition 必须依附if条件渲染——if 控制出现/消失,transition 补间过渡。没 if 的组件 transition 无意义。

4.2 全局 Stack 嵌套弹窗

「猫猫大作战」当前架构(第 31 篇):

build() { Stack() { if (this.gameState === GameState.IDLE) { this.MainMenuView() } else { this.GameView() } if (this.gameState === GameState.PAUSED) { this.PauseOverlay() // ← 加 transition } if (this.gameState === GameState.GAME_OVER) { this.GameOverOverlay() // ← 加 transition } } }

机制:全局 Stack 里多个if条件渲染,每个弹窗加 transition,切换 gameState 时弹窗淡入/淡出。

4.3 主菜单与游戏页的切换

if (this.gameState === GameState.IDLE) { this.MainMenuView() .transition({ type: TransitionType.All, opacity: 0, translate: { x: -300 } }, { duration: 300 }) } else { this.GameView() .transition({ type: TransitionType.All, opacity: 0, translate: { x: 300 } }, { duration: 300 }) }

效果

  • 开始游戏:IDLE→PLAYING,主菜单向左滑出淡出(x: 0→-300, opacity 1→0),游戏页从右滑入淡入(x: 300→0, opacity 0→1)。
  • 返回主菜单:PLAYING→IDLE,游戏页向右滑出,主菜单从左滑入。

关键经验主菜单和游戏页用相反方向的 translate——左出右进,视觉有「推走」感。

五、实战:暂停/结束弹窗转场

5.1 改造 PauseOverlay

// 来源:entry/src/main/ets/pages/Index.ets PauseOverlay(transition 改造后) @Builder PauseOverlay() { Column() { Column() { Text('⏸️').fontSize(48).margin({ bottom: 12 }) Text('游戏暂停').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#2C3E50').margin({ bottom: 8 }) Text(`当前得分: ${this.score}`).fontSize(16).fontColor('#7F8C8D').margin({ bottom: 24 }) Button('继续游戏') .width('80%').height(48) .fontSize(17).fontWeight(FontWeight.Bold) .fontColor('#FFFFFF').backgroundColor('#2ECC71') .borderRadius(24).margin({ bottom: 12 }) .onClick(() => { this.resumeGame(); }) Button('重新开始') .width('80%').height(48) .fontSize(17) .fontColor('#FFFFFF').backgroundColor('#F39C12') .borderRadius(24).margin({ bottom: 12 }) .onClick(() => { this.clearTimers(); this.startGame(); }) Button('返回主菜单') .width('80%').height(48) .fontSize(17) .fontColor('#7F8C8D').backgroundColor('#ECF0F1') .borderRadius(24) .onClick(() => { this.clearTimers(); this.gameState = GameState.IDLE; }) } .width('80%').padding(32) .backgroundColor('#FFFFFF').borderRadius(20) .shadow({ radius: 20, color: 'rgba(0,0,0,0.15)', offsetY: 8 }) .alignItems(HorizontalAlign.Center) } .width('100%').height('100%') .backgroundColor('rgba(44, 62, 80, 0.6)') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) // ← 本篇重点:弹窗进/出转场 .transition( { type: TransitionType.All, opacity: 0, scale: 0.8 }, { duration: 300, curve: Curve.EaseInOut } ) }

5.2 改造 GameOverOverlay

@Builder GameOverOverlay() { Column() { Column() { Text('🏆').fontSize(64).margin({ bottom: 16 }) Text('游戏结束').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#2C3E50').margin({ bottom: 24 }) Column() { Text('本局得分').fontSize(14).fontColor('#95A5A6') Text(this.score.toString()).fontSize(48).fontWeight(FontWeight.Bold).fontColor('#E67E22').margin({ bottom: 16 }) if (this.score > this.highScore) { Text('🎉 新纪录!').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E74C3C').margin({ bottom: 16 }) } }.margin({ bottom: 24 }) Row() { this.StatItem('最高连击', `x${this.maxCombo}`) this.StatItem('合并次数', `${this.mergeCount}次`) this.StatItem('最高等级', `${this.highestLevel}`) } .width('100%') .justifyContent(FlexAlign.SpaceEvenly) .margin({ bottom: 24 }) if (this.score >= this.highScore) { Text(`历史最高: ${this.score}`).fontSize(14).fontColor('#95A5A6').margin({ bottom: 16 }) } else { Text(`历史最高: ${this.highScore}`).fontSize(14).fontColor('#95A5A6').margin({ bottom: 16 }) } Button('再玩一次') .width('80%').height(48) .fontSize(17).fontWeight(FontWeight.Bold) .fontColor('#FFFFFF').backgroundColor('#3498DB') .borderRadius(24).margin({ bottom: 12 }) .onClick(() => { this.clearTimers(); this.startGame(); }) Button('返回主菜单') .width('80%').height(48) .fontSize(17) .fontColor('#7F8C8D').backgroundColor('#ECF0F1') .borderRadius(24) .onClick(() => { this.clearTimers(); this.gameState = GameState.IDLE; }) } .width('85%').padding(32) .backgroundColor('#FFFFFF').borderRadius(20) .shadow({ radius: 20, color: 'rgba(0,0,0,0.15)', offsetY: 8 }) .alignItems(HorizontalAlign.Center) } .width('100%').height('100%') .backgroundColor('rgba(44, 62, 80, 0.7)') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) // ← 本篇重点:结束弹窗进/出转场 .transition( { type: TransitionType.All, opacity: 0, scale: 0.85, translate: { y: 50 } }, { duration: 400, curve: Curve.EaseOut } ) }

5.3 改造 MainMenuView 和 GameView

@Builder MainMenuView() { Column() { /* ... 主菜单内容 ... */ } .width('100%').height('100%') .linearGradient({ direction: GradientDirection.Bottom, colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]] }) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) // ← 主菜单退出:向左滑出 .transition( { type: TransitionType.All, opacity: 0, translate: { x: -300 } }, { duration: 300, curve: Curve.EaseInOut } ) } @Builder GameView() { Column() { this.GameHUD() Column() { /* ... 棋盘内容 ... */ }.alignItems(HorizontalAlign.Center) Spacer() Row() { /* ... 底部控制栏 ... */ } .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) // ← 游戏页进入:从右滑入 .transition( { type: TransitionType.All, opacity: 0, translate: { x: 300 } }, { duration: 300, curve: Curve.EaseInOut } ) }

5.4 build 整体结构

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%') }

5.5 触发流程

主菜单 → 游戏页(点开始游戏):

  1. gameState: IDLE → PLAYING
  2. MainMenuView 的if变 false,触发 transition:opacity 1→0, translate x: 0→-300,300ms 向左滑出淡出。
  3. GameView 的if变 true,触发 transition:opacity 0→1, translate x: 300→0,300ms 从右滑入淡入。
  4. 300ms 后主菜单销毁,游戏页稳定显示。

游戏页 → 暂停弹窗(点暂停):

  1. gameState: PLAYING → PAUSED
  2. PauseOverlay 的if变 true,触发 transition:opacity 0→1, scale 0.8→1,300ms 淡入放大。
  3. GameView 的if还在(PAUSED 非 IDLE),不销毁,游戏页保持渲染(被弹窗遮盖)。

暂停弹窗 → 游戏页(点继续):

  1. gameState: PAUSED → PLAYING
  2. PauseOverlay 的if变 false,触发 transition:opacity 1→0, scale 1→0.8,300ms 淡出缩小,完成后销毁。
  3. GameView 露出,继续游戏。

六、transition vs Hero vs animation 对比

6.1 三种动画 API

API范围触发适合
animation单组件属性变化改属性自动循环改属性(猫咪下落)
animateTo单组件显式触发主动调 API点击触发特效(得分弹跳)
transitionif 控制的整组件if 出现/消失弹窗/页面进/出
Hero跨页面单元素路由跳转列表→详情图过渡

6.2 场景对照

// 场景 A:猫咪下落(循环改 position)→ animation .position({ x: cat.x, y: cat.y }) .animation({ duration: 100 }) // 场景 B:得分弹跳(点击触发一次)→ animateTo animateTo({ duration: 300 }, () => { this.scoreScale = 1.5; }) // 场景 C:暂停弹窗进/出(if 控制)→ transition(本篇) if (this.gameState === GameState.PAUSED) { this.PauseOverlay() .transition({ type: TransitionType.All, opacity: 0, scale: 0.8 }, { duration: 300 }) } // 场景 D:点击猫咪跳详情页(跨页面单元素)→ Hero(第 57 篇) .bindElements(`cat_${cat.id}`, this.heroAnimation)

关键经验四种动画 API 各管一场景——循环改属性用 animation,点击触发用 animateTo,if 进出用 transition,跨页面单元素用 Hero。

七、踩坑提示

7.1 transition 用在非 if 组件

// ❌ 错误:组件一直存在,transition 无触发时机 Column() { /* ... */ } .transition({ opacity: 0 }, { duration: 300 }) // 没进/出时机,transition 无效 // ✅ 正确:配合 if 控制出现/消失 if (this.showPause) { Column() { /* ... */ } .transition({ opacity: 0 }, { duration: 300 }) }

7.2 忘 AnimationParam 第二参数

// ❌ 错误:只传 TranslateParam,没 AnimationParam,用默认时长(可能太快) .transition({ opacity: 0 }) // ✅ 正确:两个参数都传 .transition({ opacity: 0 }, { duration: 300, curve: Curve.EaseInOut })

7.3 type 选错

// ❌ 错误:弹窗用 Push,消失时瞬切(没 Pop) .transition({ type: TransitionType.Push, opacity: 0 }, { duration: 300 }) // 出现淡入,消失瞬切,不协调 // ✅ 正确:弹窗用 All,进/出都动 .transition({ type: TransitionType.All, opacity: 0, scale: 0.8 }, { duration: 300 })

7.4 起始属性值设错

// ❌ 错误:opacity 起始设 1,出现时从 1→1 无补间 .transition({ opacity: 1 }, { duration: 300 }) // ✅ 正确:opacity 起始设 0,出现时从 0→1 淡入 .transition({ opacity: 0 }, { duration: 300 })

关键经验TranslateParam 的属性值是「起始值」——出现时从起始值补间到当前,消失时从当前补间到起始值。

7.5 忘 scale 分轴

// ⚠️ scale: 0.8 是 {x: 0.8, y: 0.8} 双轴,通常 OK .transition({ scale: 0.8 }, { duration: 300 }) // 要只缩一轴:用 {x, y} 对象 .transition({ scale: { x: 1, y: 0.8 } }, { duration: 300 }) // 只缩 y

八、调试技巧

  1. console.info在 gameState 切换时:追 if 触发时机,验证 transition。
  2. 瞬切不补间排查:检查是否在 if 内;检查 TranslateParam 起始值是否设对;检查 AnimationParam 是否传。
  3. 消失没过渡排查:检查 type 是否包含 Pop(All 或 Pop);检查 if 是否真的变 false。
  4. DevEco Animation Inspector:查看 transition 过程。

九、性能与最佳实践

  1. transition 必须依附 if 条件渲染——if 控制出现/消失,transition 补间。
  2. 弹窗用 TransitionType.All——进/出都动才协调;单向场景才用 Push/Pop。
  3. TranslateParam 属性是「起始值」——出现从起始补间到当前,消失从当前补间到起始。
  4. 两个参数都传——TranslateParam + AnimationParam,别忘第二个。
  5. 主菜单和游戏页用相反 translate——左出右进,有「推走」感。
  6. 四种动画各管一场景——循环改 animation,点击 animateTo,if 进出 transition,跨页面 Hero。

十、阶段三进度(51–58)

本篇是阶段三「交互与动画」第 8 篇:

主题核心要点
51onTouch手势三阶段
52onHover悬停反馈
53onKeyEvent键盘/遥控按键
54bindContextMenu上下文菜单
55animateTo显式动画触发
56animation隐式补间
57Hero共享元素跨页面
58(本篇)transitionif 进/出转场

接下来第 59–60 篇会覆盖:Spring 弹性物理、Hero Style Player。

总结

本篇我们从 transition 转场切入,掌握了装饰绑定与 if 协同TransitionType 推/拉/全(弹窗用 All)TranslateParam 起始值(出现从起始补到当前,消失反向)与 animation/animateTo/Hero 的差异四大要点,并给出了暂停/结束弹窗、主菜单/游戏页切换的完整 transition 改造代码。核心要点:transition 依附 if;弹窗用 All;起始值是过渡起点;四种动画各管一场景

下一篇我们将拆解 Spring——弹性物理动画。

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


相关资源:

  • 「猫猫大作战」项目源码:本仓库entry/src/main/ets/pages/Index.ets
  • ArkUI transition 转场动画官方指南
  • TransitionType 枚举官方文档
  • HarmonyOS 页面转场最佳实践
  • 开源鸿蒙跨平台社区
  • HarmonyOS 开发者官方文档首页
  • 系列索引:本仓库articles/INDEX.md