HarmonyOS 水果词语学习卡片——基于状态管理V2的层叠动画卡片实战指南
关键词:HarmonyOS、ArkTS、状态管理V2、@ComponentV2、Stack层叠布局、animateTo隐式动画、PanGesture拖动手势、Media Kit音频播放
适合人群:有ArkTS基础的HarmonyOS开发者,希望掌握状态管理V2迁移与高级UI交互实现
效果

一、前言
在儿童启蒙教育类应用中,"词语学习卡片"是一种直观且高效的交互形式。本文将以水果词语学习卡片为案例,基于 HarmonyOS 6.1 + ArkTS 平台,全面使用状态管理V2(@ComponentV2、@Local、@Param、@Event)重构原有的V1版本,同时深入讲解 Stack层叠布局、animateTo隐式动画、PanGesture拖动手势和Media Kit音频播放四大核心能力的实战应用。
通过本文,你将学会:
- 如何使用状态管理V2构建可维护的组件化架构
- 如何用Stack + offset + zIndex实现卡片层叠效果
- 如何用
PanGesture+animateTo打造丝滑的卡片切换动画 - 如何用Media Kit的AVPlayer播放rawfile音频资源
- V1状态管理到V2的完整迁移对照表
二、环境准备
| 工具/SDK | 版本要求 |
|---|---|
| DevEco Studio | 6.1 Release 及以上 |
| HarmonyOS SDK | API 22 及以上 |
| 运行设备 | 手机 / 平板 / 模拟器(横屏模式) |
创建项目时选择 Empty Ability 模板,语言选择 ArkTS。
三、项目架构设计
3.1 目录结构
entry/src/main/
├── ets/
│ ├── constants/
│ │ └── CommonConstants.ets # 公共常量(尺寸、比例)
│ ├── entryability/
│ │ └── EntryAbility.ets # Ability入口,沉浸式窗口配置
│ ├── model/
│ │ └── FruitCardModel.ets # 水果数据模型 + 数据源
│ ├── pages/
│ │ ├── MainPage.ets # 主页面(@Entry + @ComponentV2)
│ │ └── SwiperStackComponent.ets # 层叠卡片组件(核心交互)
│ └── utils/
│ └── MediaPlayer.ets # Media Kit音频播放封装
├── resources/
│ ├── base/
│ │ ├── element/
│ │ │ ├── color.json # 主题色定义
│ │ │ └── string.json # 字符串资源
│ │ ├── media/ # 图片资源
│ │ │ ├── apple.png # 苹果
│ │ │ ├── banana.png # 香蕉
│ │ │ ├── watermelon.png # 西瓜
│ │ │ ├── orange.png # 橙子
│ │ │ ├── grape.png # 葡萄
│ │ │ ├── strawberry.png # 草莓
│ │ │ ├── peach.png # 桃子
│ │ │ ├── pear.png # 梨
│ │ │ ├── cherry.png # 樱桃
│ │ │ ├── chinese_pronunciation.png
│ │ │ └── english_pronunciation.png
│ │ └── profile/
│ │ └── main_pages.json # 页面路由注册
│ ├── dark/element/color.json # 深色模式颜色
│ └── rawfile/ # 音频原始文件
│ ├── apple.mp3 / appleChinese.mp3
│ ├── banana.mp3 / bananaChinese.mp3
│ ├── watermelon.mp3 / watermelonChinese.mp3
│ ├── orange.mp3 / orangeChinese.mp3
│ ├── grape.mp3 / grapeChinese.mp3
│ ├── strawberry.mp3 / strawberryChinese.mp3
│ ├── peach.mp3 / peachChinese.mp3
│ ├── pear.mp3 / pearChinese.mp3
│ └── cherry.mp3 / cherryChinese.mp3
└── module.json5 # 模块配置(横屏、设备类型)
3.2 模块职责划分
| 模块 | 职责 |
|---|---|
FruitCardModel | 定义数据接口 FruitCardModel 和静态数据源 FRUITS_DATA |
CommonConstants | 集中管理布局尺寸常量 |
MediaPlayer | 基于Media Kit AVPlayer的单例音频播放器 |
MainPage | 页面入口,管理当前卡片索引状态,组装子组件 |
SwiperStackComponent | 核心交互组件,实现层叠渲染、手势识别、动画驱动 |
四、核心知识点详解
4.1 状态管理V2:从@State到@Local
HarmonyOS 6.1 推出的状态管理V2提供了更精确、更类型安全的状态管理方案。核心装饰器对照如下:
| V1 装饰器 | V2 装饰器 | 说明 |
|---|---|---|
@Component | @ComponentV2 | 组件声明 |
@State | @Local | 组件自身响应式状态 |
@Prop | @Param | 父→子单向同步 |
@Link | @Param + @Event | 父子双向通信(拆分为读+写) |
@Provide | @Provider | 跨层级状态提供 |
@Consume | @Consumer | 跨层级状态消费 |
@Watch | @Monitor | 属性变化监听 |
| computed getter | @Computed | 派生计算属性 |
关键设计思想:V2 将V1中@Link的"双向绑定"拆分为 @Param(读)+ @Event(写),数据流向更加清晰明确,符合单向数据流的最佳实践。
4.2 Stack层叠布局
Stack是ArkUI中的层叠容器,子组件按照zIndex值从下到上堆叠。本项目利用Stack实现多张卡片的前后层叠效果:
Stack() {
ForEach(this.swiperData, (item, index) => {
// 卡片内容
})
}
.alignContent(Alignment.Center)
每张卡片通过以下属性控制视觉层级:
offset({ x }):水平偏移,当前卡片居中,左右相邻卡片分别偏移±127vpzIndex:层级深度,越靠近当前卡片zIndex越高blur(12):非当前卡片添加模糊效果,突出焦点
4.3 animateTo隐式动画
animateTo是ArkUI提供的隐式动画API,在闭包中修改状态变量,框架自动为受影响的UI属性生成平滑过渡动画:
this.getUIContext().animateTo({
duration: 300, // 动画时长300ms
}, () => {
this.onIndexChange(newIndex); // 修改索引,触发卡片位移动画
});
注意:在V2组件中,必须通过
this.getUIContext().animateTo()调用,不能直接使用全局animateTo()。
4.4 PanGesture拖动手势
PanGesture用于识别拖动手势,配合direction参数可限定拖动方向:
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionStart((event: GestureEvent) => {
// event.offsetX < 0 表示向左滑动
this.startAnimation(event.offsetX < 0, 300);
})
)
4.5 Media Kit音频播放
Media Kit 提供的 AVPlayer 是HarmonyOS推荐的音视频播放器。本项目使用它播放 rawfile 目录下的 mp3 音频:
import { media } from '@kit.MediaKit';
// 创建播放器
let avPlayer = await media.createAVPlayer();
// 通过文件描述符设置音频源
let fileDescriptor = context.resourceManager.getRawFdSync('apple.mp3');
avPlayer.fdSrc = {
fd: fileDescriptor.fd,
offset: fileDescriptor.offset,
length: fileDescriptor.length
};
AVPlayer 的状态机流程:idle → initialized → prepared → playing → completed → stopped → idle
五、分步实现
Step 1:项目配置——module.json5
在module.json5中设置横屏模式和设备类型支持:
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone", "tablet", "2in1"],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"orientation": "landscape", // 横屏模式
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
// ...
}
]
}
}
Step 2:沉浸式窗口配置——EntryAbility.ets
在onWindowStageCreate中实现全屏沉浸式布局,并将安全区域高度存入AppStorage:
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
onWindowStageCreate(windowStage: window.WindowStage): void {
// 隐藏系统状态栏和导航栏
windowStage.getMainWindowSync().setWindowSystemBarEnable([]);
let windowClass: window.Window = windowStage.getMainWindowSync();
// 设置全屏布局
windowClass.setWindowLayoutFullScreen(true);
// 获取并存储安全区域高度
let avoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
AppStorage.setOrCreate('topRectHeight', avoidArea.topRect.height);
avoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('bottomRectHeight', avoidArea.bottomRect.height);
// 加载主页面
windowStage.loadContent('pages/MainPage');
}
Step 3:数据模型设计——FruitCardModel.ets
定义水果卡片的数据接口和静态数据源:
/**
* 水果卡片数据接口
*/
export interface FruitCardModel {
id: number;
image: Resource;
pinyin: string;
chineseName: string;
englishName: string;
chineseAudioUrl: string;
englishAudioUrl: string;
}
/**
* 水果数据源:9种常见水果
* 苹果、香蕉、西瓜、橙子、葡萄、草莓、桃子、梨、樱桃
*/
export const FRUITS_DATA: FruitCardModel[] = [
{ id: 1, image: $r('app.media.apple'), pinyin: 'píng guǒ', chineseName: '苹 果', englishName: 'Apple', ... },
{ id: 2, image: $r('app.media.banana'), pinyin: 'xiāng jiāo', chineseName: '香 蕉', englishName: 'Banana', ... },
{ id: 3, image: $r('app.media.watermelon'), pinyin: 'xī guā', chineseName: '西 瓜', englishName: 'Watermelon', ... },
{ id: 4, image: $r('app.media.orange'), pinyin: 'chéng zi', chineseName: '橙 子', englishName: 'Orange', ... },
{ id: 5, image: $r('app.media.grape'), pinyin: 'pú tao', chineseName: '葡 萄', englishName: 'Grape', ... },
{ id: 6, image: $r('app.media.strawberry'), pinyin: 'cǎo méi', chineseName: '草 莓', englishName: 'Strawberry', ... },
{ id: 7, image: $r('app.media.peach'), pinyin: 'táo zi', chineseName: '桃 子', englishName: 'Peach', ... },
{ id: 8, image: $r('app.media.pear'), pinyin: 'lí', chineseName: '梨', englishName: 'Pear', ... },
{ id: 9, image: $r('app.media.cherry'), pinyin: 'yīng tao', chineseName: '樱 桃', englishName: 'Cherry', ... },
];
设计说明:数据模型使用interface而非class,因为水果数据在运行期间不会动态修改,无需响应式追踪。这是V2开发中的一个重要优化思路——只为真正需要变化的数据添加状态装饰器。
Step 4:音频播放工具类——MediaPlayer.ets
MediaPlayer是基于Media Kit AVPlayer封装的单例播放器,核心设计:
export default class MediaPlayer {
private static instance: MediaPlayer | null;
public avPlayer: media.AVPlayer | null = null;
public static audioUrl: string = '';
public static isPlaying: boolean = false;
public static async getInstance(): Promise<MediaPlayer> {
if (!MediaPlayer.instance) {
MediaPlayer.instance = new MediaPlayer();
}
return MediaPlayer.instance;
}
public async getAVPlayer(context: common.UIAbilityContext): Promise<media.AVPlayer> {
if (!this.avPlayer) {
this.avPlayer = await media.createAVPlayer();
this.setupCallbacks(this.avPlayer, context);
}
return this.avPlayer;
}
}
关键逻辑在 setupCallbacks 中监听 stateChange 事件,实现自动化的状态流转:
idle(设置fdSrc)→ initialized(自动prepare)→ prepared(自动play)→ playing
Step 5:层叠卡片组件——SwiperStackComponent.ets(核心)
这是整个项目最核心的组件,集中体现了V2状态管理、Stack布局、手势识别和动画的综合运用。
5.1 V2状态声明
@ComponentV2
export struct SwiperStackComponent {
// V2: @Param 替代 V1的 @Prop(父→子单向同步)
@Param currentIndex: number = 0;
@Param swiperData: FruitCardModel[] = [];
// V2: @Event 替代 V1的回调prop(子→父事件通知)
@Event onIndexChange: (value: number) => void = () => {};
private halfCount: number = Math.floor(FRUITS_DATA.length / 2);
private uiContext = this.getUIContext();
private context = this.getUIContext().getHostContext() as common.UIAbilityContext;
}
V1→V2 关键变化:原来V1中@Link currentIndex既读又写,V2拆分为@Param(读)和@Event(写),数据流向一目了然。
5.2 卡片位置计算
/** 计算图片层级系数:当前=0,左=-1,右=1 */
getImgCoefficients(index: number): number {
const coefficient: number = this.currentIndex - index;
const tempCoefficient: number = Math.abs(coefficient);
if (tempCoefficient <= this.halfCount) {
return coefficient;
}
// 环形排列:超出半圈范围的卡片需要折返计算
const dataLength: number = this.swiperData.length;
let tempOffset: number = dataLength - tempCoefficient;
if (tempOffset <= this.halfCount) {
return coefficient > 0 ? -tempOffset : tempOffset;
}
return 0;
}
/** 计算水平偏移:当前卡片0,左右相邻±127vp */
getOffSetX(index: number): number {
let offsetIndex = this.getImgCoefficients(index);
return Math.abs(offsetIndex) === 1 ? -127 * offsetIndex : 0;
}
5.3 动画驱动切换
startAnimation(isLeft: boolean, duration: number): void {
this.uiContext.animateTo({ duration }, () => {
let dataLength = this.swiperData.length;
let tempIndex = isLeft
? this.currentIndex + 1
: this.currentIndex - 1 + dataLength;
// 通过@Event通知父组件更新@Local currentIndex
this.onIndexChange(tempIndex % dataLength);
});
}
执行流程:PanGesture触发 → startAnimation() → animateTo闭包中调用onIndexChange → 父组件@Local currentIndex更新 → 新值通过@Param回流到子组件 → 卡片offset/zIndex/blur变化产生动画。
5.4 Stack渲染结构
build() {
Column() {
Stack() {
ForEach(this.swiperData, (item: FruitCardModel, index: number) => {
Column({ space: (index !== this.currentIndex ? 10 : 20) }) {
// 图片 + 拼音 + 中文名 + 英文名
// 中文发音按钮 + 英文发音按钮 + 序号
}
.offset({ x: this.getOffSetX(index) })
.blur(index !== this.currentIndex ? 12 : 0)
.zIndex(/* 层级计算 */)
.width(CommonConstants.LARGE_WIDTH)
.height(index !== this.currentIndex ? 240 : 340);
}, (item: FruitCardModel, index: number) => item.id.toString());
}
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionStart((event: GestureEvent) => {
this.startAnimation(event.offsetX < 0, 300);
})
)
.alignContent(Alignment.Center);
}
}
重要:
ForEach必须提供稳定的keyGenerator,这里使用item.id.toString()作为唯一键值,确保列表渲染性能最优。
Step 6:主页面组装——MainPage.ets
@Entry
@ComponentV2
struct MainPage {
@Local currentIndex: number = 0;
@Local topRectHeight: number = 0;
private uiContext = this.getUIContext();
aboutToAppear(): void {
// @StorageProp 不兼容 @ComponentV2,改用 AppStorage.get() 手动读取
let topHeight = AppStorage.get<number>('topRectHeight');
if (topHeight !== undefined) {
this.topRectHeight = topHeight;
}
}
build() {
Flex({ direction: FlexDirection.Row }) {
SwiperStackComponent({
currentIndex: this.currentIndex,
swiperData: FRUITS_DATA,
onIndexChange: (value: number) => {
this.currentIndex = value;
}
});
}
.safeAreaPadding({ top: this.uiContext.px2vp(this.topRectHeight) })
.height('100%')
.width('100%')
.backgroundColor($r('app.color.orange'));
}
}
重要提示:
@StorageProp和@StorageLink是V1专属装饰器,不兼容@ComponentV2。在V2组件中,需要通过@Local+AppStorage.get()在aboutToAppear()中手动读取全局存储值。
V2模式下的父子数据流:
MainPage (@Local currentIndex)
│
├── 读取方向 ──→ SwiperStackComponent (@Param currentIndex)
│
└── 写入方向 ←── SwiperStackComponent (@Event onIndexChange)
Step 7:资源配置
color.json(base):
{
"color": [
{ "name": "orange", "value": "#FFB74D" },
{ "name": "dark_orange", "value": "#E65100" },
{ "name": "blue", "value": "#0A59F7" }
]
}
color.json(dark):深色模式自动适配:
{
"color": [
{ "name": "orange", "value": "#FF8F00" },
{ "name": "dark_orange", "value": "#BF360C" },
{ "name": "blue", "value": "#448AFF" }
]
}
六、V1 vs V2 完整对照表
以下是在本项目中实际发生的V1→V2迁移对照:
| 位置 | V1写法 | V2写法 | 变化原因 |
|---|---|---|---|
| 组件声明 | @Component | @ComponentV2 | V2组件装饰器 |
| 页面入口 | @Entry @Component | @Entry @ComponentV2 | 同上 |
| 页面状态 | @State currentIndex: number = 0 | @Local currentIndex: number = 0 | V2局部状态 |
| 父→子数据 | @Prop swiperData: CardsModel[] | @Param swiperData: FruitCardModel[] | V2单向参数 |
| 父子双向 | @Link currentIndex: number | @Param currentIndex + @Event onIndexChange | V2拆分读写 |
| 修改索引 | this.currentIndex = newIndex | this.onIndexChange(newIndex) | 通过事件通知父组件 |
| AppStorage | @StorageProp('key') | @Local + AppStorage.get() 在 aboutToAppear 中读取 | @StorageProp不兼容V2 |
为什么V2要拆分@Link?
V1的@Link同时承担读和写的职责,违反了单向数据流原则。V2将其拆分:
@Param:子组件只能读取父组件传来的值,不能直接修改@Event:子组件通过事件机制请求父组件修改值
这种设计让数据流向变得可预测、可追踪,极大降低了状态管理的复杂度。
七、关键代码深度解析
7.1 环形卡片排列算法
本项目的卡片排列是一个环形队列模型。以3张卡片为例:
索引: 0 1 2
位置: 当前 右边 左边(环形回绕)
getImgCoefficients的核心思路是:
- 计算当前索引与目标索引的差值
coefficient - 如果差值的绝对值 ≤ 半圈数(
halfCount),直接返回差值作为层级系数 - 否则进行环形折返计算:
dataLength - |coefficient|得到环形另一侧的距离
这个算法保证了无论多少张卡片,始终只有当前卡片和左右相邻卡片可见,其余卡片zIndex=0被遮挡。
7.2 animateTo + @Event 的协同时序
用户左滑
└─→ PanGesture.onActionStart
└─→ startAnimation(isLeft=true, 300)
└─→ animateTo({ duration: 300 })
└─→ this.onIndexChange(1) // 通知父组件
└─→ MainPage: this.currentIndex = 1 // @Local更新
└─→ SwiperStackComponent: @Param currentIndex = 1 // 回流
└─→ 每张卡片的offset/zIndex/blur重新计算
└─→ 框架生成300ms平滑过渡动画
7.3 MediaPlayer状态机自动流转
当设置fdSrc时,AVPlayer的状态机自动流转,无需手动调用prepare()和play():
// stateChange回调中的自动流转逻辑
case 'idle':
if (MediaPlayer.audioUrl !== '') {
avPlayer.fdSrc = avFileDescriptor; // idle → initialized
}
break;
case 'initialized':
avPlayer.prepare(); // initialized → prepared
break;
case 'prepared':
avPlayer.play(); // prepared → playing
break;
八、资源文件准备清单
在正式运行前,需要准备以下资源文件:
| 目录 | 文件名 | 说明 |
|---|---|---|
resources/base/media/ | apple.png | 苹果图片(建议 1024×1024px 儿童插画) |
resources/base/media/ | banana.png | 香蕉图片 |
resources/base/media/ | watermelon.png | 西瓜图片 |
resources/base/media/ | orange.png | 橙子图片 |
resources/base/media/ | grape.png | 葡萄图片 |
resources/base/media/ | strawberry.png | 草莓图片 |
resources/base/media/ | peach.png | 桃子图片 |
resources/base/media/ | pear.png | 梨图片 |
resources/base/media/ | cherry.png | 樱桃图片 |
resources/base/media/ | chinese_pronunciation.png | 中文发音按钮图标 |
resources/base/media/ | english_pronunciation.png | 英文发音按钮图标 |
resources/rawfile/ | apple.mp3 | "Apple"英文发音 |
resources/rawfile/ | appleChinese.mp3 | "苹果"中文发音 |
resources/rawfile/ | banana.mp3 / bananaChinese.mp3 | 香蕉发音 |
resources/rawfile/ | watermelon.mp3 / watermelonChinese.mp3 | 西瓜发音 |
resources/rawfile/ | orange.mp3 / orangeChinese.mp3 | 橙子发音 |
resources/rawfile/ | grape.mp3 / grapeChinese.mp3 | 葡萄发音 |
resources/rawfile/ | strawberry.mp3 / strawberryChinese.mp3 | 草莓发音 |
resources/rawfile/ | peach.mp3 / peachChinese.mp3 | 桃子发音 |
resources/rawfile/ | pear.mp3 / pearChinese.mp3 | 梨发音 |
resources/rawfile/ | cherry.mp3 / cherryChinese.mp3 | 樱桃发音 |
提示:音频文件可使用微软 Edge TTS 免费生成,中文语音用
zh-CN-XiaoxiaoNeural,英文语音用en-US-JennyNeural。图片可从免费图标网站下载或使用 AI 生成。
九、总结与扩展
9.1 技术要点回顾
- 状态管理V2:
@ComponentV2+@Local+@Param+@Event构建了清晰的数据流 - Stack层叠布局:通过
offset、zIndex、blur三重属性控制卡片视觉层级 - animateTo动画:闭包内修改状态,框架自动插值生成平滑过渡
- PanGesture手势:
PanDirection.Horizontal限定水平拖动,event.offsetX判断方向 - Media Kit音频:
AVPlayer+fdSrc+ 状态机回调实现自动化播放
9.2 扩展建议
- 增加水果种类:在
FRUITS_DATA中追加数据即可,环形排列算法自动适配 - 添加翻转动画:点击卡片使用
rotate+animateTo展示背面详细信息 - 学习进度记录:使用
@Provider/@Consumer跨组件共享已学/未学状态 - 发音高亮效果:播放音频时同步高亮对应的发音按钮
- 适配竖屏模式:通过断点检测(
@StorageProp('breakpoint'))切换横竖屏布局
网站下载或使用 AI 生成。
九、总结与扩展
9.1 技术要点回顾
- 状态管理V2:
@ComponentV2+@Local+@Param+@Event构建了清晰的数据流 - Stack层叠布局:通过
offset、zIndex、blur三重属性控制卡片视觉层级 - animateTo动画:闭包内修改状态,框架自动插值生成平滑过渡
- PanGesture手势:
PanDirection.Horizontal限定水平拖动,event.offsetX判断方向 - Media Kit音频:
AVPlayer+fdSrc+ 状态机回调实现自动化播放
9.2 扩展建议
- 增加水果种类:在
FRUITS_DATA中追加数据即可,环形排列算法自动适配 - 添加翻转动画:点击卡片使用
rotate+animateTo展示背面详细信息 - 学习进度记录:使用
@Provider/@Consumer跨组件共享已学/未学状态 - 发音高亮效果:播放音频时同步高亮对应的发音按钮
- 适配竖屏模式:通过断点检测(
@StorageProp('breakpoint'))切换横竖屏布局
1821

被折叠的 条评论
为什么被折叠?



