欢迎光临
我们一直在努力

学习卡片案例新特性接入

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 }):水平偏移,当前卡片居中,左右相邻卡片分别偏移±127vp
  • zIndex:层级深度,越靠近当前卡片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'))切换横竖屏布局

    赞(0)
    未经允许不得转载:171主机测评 » 学习卡片案例新特性接入
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址