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实现多张卡片的前后层叠效果:
typescript
Stack() {
ForEach(this.swiperData, (item, index) => {
// 卡片内容
})
}
.alignContent(Alignment.Center)
每张卡片通过以下属性控制视觉层级:
offset({ x }):水平偏移,当前卡片居中,左右相邻卡片分别偏移±127vpzIndex:层级深度,越靠近当前卡片zIndex越高blur(12):非当前卡片添加模糊效果,突出焦点
4.3 animateTo隐式动画
animateTo是ArkUI提供的隐式动画API,在闭包中修改状态变量,框架自动为受影响的UI属性生成平滑过渡动画:
typescript
this.getUIContext().animateTo({
duration: 300, // 动画时长300ms
}, () => {
this.onIndexChange(newIndex); // 修改索引,触发卡片位移动画
});
注意 :在V2组件中,必须通过
this.getUIContext().animateTo()调用,不能直接使用全局animateTo()。
4.4 PanGesture拖动手势
PanGesture用于识别拖动手势,配合direction参数可限定拖动方向:
typescript
.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 音频:
typescript
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中设置横屏模式和设备类型支持:
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:
typescript
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
定义水果卡片的数据接口和静态数据源:
typescript
/**
* 水果卡片数据接口
*/
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封装的单例播放器,核心设计:
typescript
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状态声明
typescript
@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 卡片位置计算
typescript
/** 计算图片层级系数:当前=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 动画驱动切换
typescript
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渲染结构
typescript
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
typescript
@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):
json
{
"color": [
{ "name": "orange", "value": "#FFB74D" },
{ "name": "dark_orange", "value": "#E65100" },
{ "name": "blue", "value": "#0A59F7" }
]
}
color.json(dark):深色模式自动适配:
json
{
"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():
typescript
// 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'))切换横竖屏布局