鸿蒙音乐应用开发:从收藏功能实现看状态管理与交互设计
在移动应用开发中,收藏功能是用户体验的重要组成部分。本文将以鸿蒙OS音乐应用为例,详细解析如何实现具有动画效果的收藏功能,涉及状态管理、组件通信和交互动画等核心技术点。

一、收藏功能的核心数据模型设计
首先定义Song
数据模型,通过@Observed
装饰器实现数据响应式:
@Observed
class Song {
id: string = ''
title: string = ''
singer: string = ''
mark: string = "1" // 音乐品质标识,1:sq, 2:vip
label: Resource = $r('app.media.ic_music_icon') // 歌曲封面
src: string = '' // 播放路径
lyric: string = '' // 歌词路径
isCollected: boolean = false // 收藏状态
constructor(id: string, title: string, singer: string, mark: string,
label: Resource, src: string, lyric: string, isCollected: boolean) {
this.id = id
this.title = title
this.singer = singer
this.mark = mark
this.label = label
this.src = src
this.lyric = lyric
this.isCollected = isCollected
}
}
isCollected
字段作为收藏状态的核心标识,配合@Observed
实现数据变更时的UI自动更新。
二、首页与收藏页的整体架构
应用采用标签页架构,通过Tabs
组件实现"首页"与"我的"页面切换:
@Entry
@Component
struct Index {
@State currentIndex: number = 0
@State songList: Array<Song> = [
// 歌曲列表初始化
new Song('1', '海阔天空', 'Beyond', '1', $r('app.media.ic_music_icon'),
'common/music/1.mp3', 'common/music/1.lrc', false),
// 省略其他歌曲...
]
@Builder
tabStyle(index: number, title: string, selectedImg: Resource, unselectedImg: Resource) {
Column() {
Image(this.currentIndex == index ? selectedImg : unselectedImg)
.width(20).height(20)
Text(title).fontSize(16).fontColor(this.currentIndex == index ? "#ff1456" : '#ff3e4040')
}
}
build() {
Tabs() {
TabContent() {
RecommendedMusic({ songList: this.songList })
}
.tabBar(this.tabStyle(0, '首页', $r('app.media.home_selected'), $r("app.media.home")))
TabContent() {
CollectedMusic({ songList: this.songList })
}
.tabBar(this.tabStyle(1, '我的', $r('app.media.userfilling_selected'), $r("app.media.userfilling")))
}
.barPosition(BarPosition.End)
.width("100%").height("100%")
}
}
通过@State
管理标签页索引currentIndex
,当用户切换标签时,自动更新UI显示推荐歌曲或收藏歌曲。
三、收藏功能的核心实现:状态切换与动画效果
在歌曲列表项组件中,实现收藏状态切换逻辑与动画效果:
@Component
export struct SongListItem {
@Prop song: Song
@Link songList: Array<Song>
@Prop index: number
// 动画状态标识
@State isAnimating: boolean = false
/**
* 收藏状态切换方法
*/
collectStatusChange(): void {
// 1. 切换当前歌曲收藏状态
this.song.isCollected = !this.song.isCollected
// 2. 更新列表中对应歌曲的状态
this.songList = this.songList.map(item => {
if (item.id === this.song.id) {
item.isCollected = this.song.isCollected
}
return item
})
// 3. 触发收藏动画
this.isAnimating = true
setTimeout(() => {
this.isAnimating = false
}, 300)
// 4. 发送收藏事件(可用于全局状态同步)
getContext(this).eventHub.emit('collected', this.song.id,
this.song.isCollected ? '1' : '0')
}
build() {
Column() {
Row() {
Column() {
Text(this.song.title).fontWeight(500).fontSize(16).margin({ bottom: 4 })
Row({ space: 4 }) {
Image(this.song.mark === '1' ? $r('app.media.ic_vip') : $r('app.media.ic_sq'))
.width(16).height(16)
Text(this.song.singer).fontSize(12)
}
}.alignItems(HorizontalAlign.Start)
// 收藏图标带动画效果
Image(this.song.isCollected ? $r('app.media.ic_item_collected') : $r('app.media.ic_item_uncollected'))
.width(50).height(50).padding(13)
.scale({ x: this.isAnimating ? 1.8 : 1, y: this.isAnimating ? 1.8 : 1 })
.animation({ duration: 300, curve: Curve.EaseOut })
.onClick(() => this.collectStatusChange())
}
.width("100%").padding({ left: 16, right: 16, top: 12, bottom: 12 })
}.width("100%")
}
}
核心动画效果通过scale
变换和animation
配置实现:
- 点击时图标放大至1.8倍
- 300ms的缓出动画(
Curve.EaseOut
) isAnimating
状态控制动画的触发与结束
四、收藏列表的筛选与展示
"我的"页面通过filter
方法筛选已收藏歌曲:
@Component
export struct CollectedMusic {
@Link songList: Array<Song>
build() {
Column(){
Text('收藏').fontSize(26).fontWeight(700)
.height(56).width('100%').padding({ left: 16, right: 16 })
List(){
ForEach(this.songList.filter(song => song.isCollected), (song: Song, index: number) => {
ListItem() {
SongListItem({ song: song, songList: this.songList })
}
}, (song: Song) => song.id)
}
.cachedCount(3)
.divider({ strokeWidth: 1, color: "#E5E5E5" })
.scrollBar(BarState.Off)
}.width("100%").height("100%")
}
}
通过filter(song => song.isCollected)
实现已收藏歌曲的精准筛选,确保"我的"页面只显示用户收藏的内容。
五、附:代码
import { promptAction } from "@kit.ArkUI"
@Observed
class Song {
id: string = ''
title: string = ''
singer: string = ''
// 音乐品质标识,1:sq,2:vip
mark: string = "1"
// 歌曲封面图片
label: Resource = $r('app.media.ic_music_icon')
// 歌曲播放路径
src: string = ''
// 歌词文件路径
lyric: string = ''
// 收藏状态, true:已收藏 false:未收藏
isCollected: boolean = false
constructor(id: string, title: string, singer: string, mark: string, label: Resource, src: string, lyric: string, isCollected: boolean) {
this.id = id
this.title = title
this.singer = singer
this.mark = mark
this.label = label
this.src = src
this.lyric = lyric
this.isCollected = isCollected
}
}
@Entry
@Component
struct Index {
@State currentIndex: number = 0
// 存储收藏歌曲列表
@State songList: Array<Song> = [
new Song('1', '海阔天空', 'Beyond', '1', $r('app.media.ic_music_icon'), 'common/music/1.mp3', 'common/music/1.lrc', false),
new Song('2', '夜空中最亮的星', '逃跑计划', '1', $r('app.media.ic_music_icon'), 'common/music/2.mp3', 'common/music/2.lrc', false),
new Song('3', '光年之外', 'GAI周延', '2', $r('app.media.ic_music_icon'), 'common/music/3.mp3', 'common/music/3.lrc', false),
new Song('4', '起风了', '买辣椒也用券', '1', $r('app.media.ic_music_icon'), 'common/music/4.mp3', 'common/music/4.lrc', false),
new Song('5', '孤勇者', '陈奕迅', '2', $r('app.media.ic_music_icon'), 'common/music/5.mp3', 'common/music/5.lrc', false)
]
@Builder
tabStyle(index: number, title: string, selectedImg: Resource, unselectedImg: Resource) {
Column() {
Image(this.currentIndex == index ? selectedImg : unselectedImg)
.width(20)
.height(20)
Text(title)
.fontSize(16)
.fontColor(this.currentIndex == index ? "#ff1456" : '#ff3e4040')
}
}
build() {
Tabs() {
TabContent() {
// 首页标签内容
RecommendedMusic({ songList: this.songList})
}
.tabBar(this.tabStyle(0, '首页', $r('app.media.home_selected'), $r("app.media.home")))
TabContent() {
// 我的标签内容占位
CollectedMusic({ songList: this.songList})
}
.tabBar(this.tabStyle(1, '我的', $r('app.media.userfilling_selected'), $r("app.media.userfilling")))
}
.barPosition(BarPosition.End)
.width("100%")
.height("100%")
.onChange((index: number) => {
this.currentIndex = index
})
}
}
// 推荐歌单
@Component
export struct RecommendedMusic {
// 创建路由栈管理导航
@Provide('navPath') pathStack: NavPathStack = new NavPathStack()
// 热门歌单标题列表
@State playListsTiles: string[] = ['每日推荐', '热门排行榜', '经典老歌', '流行金曲', '轻音乐精选']
@Link songList: Array<Song>
@Builder
shopPage(name: string, params:string[]) {
if (name === 'HotPlaylist') {
HotPlayList({
songList: this.songList
});
}
}
build() {
Navigation(this.pathStack) {
Scroll() {
Column() {
// 推荐标题栏
Text('推荐')
.fontSize(26)
.fontWeight(700)
.height(56)
.width('100%')
.padding({ left: 16, right: 16 })
// 水平滚动歌单列表
List({ space: 10 }) {
ForEach(this.playListsTiles, (item: string, index: number) => {
ListItem() {
HotListPlayItem({ title: item })
.margin({
left: index === 0 ? 16 : 0,
right: index === this.playListsTiles.length - 1 ? 16 : 0
})
.onClick(() => {
this.pathStack.pushPathByName('HotPlaylist', [item])
})
}
}, (item: string) => item)
}
.height(200)
.width("100%")
.listDirection(Axis.Horizontal)
.edgeEffect(EdgeEffect.None)
.scrollBar(BarState.Off)
// 热门歌曲标题栏
Text('热门歌曲')
.fontSize(22)
.fontWeight(700)
.height(56)
.width('100%')
.padding({ left: 16, right: 16 })
// 歌曲列表
List() {
ForEach(this.songList, (song: Song, index: number) => {
ListItem() {
SongListItem({ song: song, index: index,songList: this.songList})
}
}, (song: Song) => song.id)
}
.cachedCount(3)
.divider({
strokeWidth: 1,
color: "#E5E5E5",
startMargin: 16,
endMargin: 16
})
.scrollBar(BarState.Off)
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
}
.width("100%")
}
.scrollBar(BarState.Off)
}
.hideTitleBar(true)
.mode(NavigationMode.Stack)
.navDestination(this.shopPage)
}
}
// 热门歌单
@Component
export struct HotListPlayItem {
@Prop title: string // 接收外部传入的标题
build() {
Stack() {
// 背景图片
Image($r('app.media.cover5'))
.width("100%")
.height('100%')
.objectFit(ImageFit.Cover)
.borderRadius(16)
// 底部信息栏
Row() {
Column() {
// 歌单标题
Text(this.title)
.fontSize(20)
.fontWeight(700)
.fontColor("#ffffff")
// 辅助文本
Text("这个歌单很好听")
.fontSize(16)
.fontColor("#efefef")
.height(12)
.margin({ top: 5 })
}
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 播放按钮
SymbolGlyph($r('sys.symbol.play_round_triangle_fill'))
.fontSize(36)
.fontColor(['#99ffffff'])
}
.backgroundColor("#26000000")
.padding({ left: 12, right: 12 })
.height(72)
.width("100%")
}
.clip(true)
.width(220)
.height(200)
.align(Alignment.Bottom)
.borderRadius({ bottomLeft: 16, bottomRight: 16 })
}
}
// 歌曲列表项
@Component
export struct SongListItem {
//定义当前歌曲,此歌曲是由前面通过遍历出来的单个数据
@Prop song: Song
@Link songList: Array<Song>
//当前点击歌曲的index值
@Prop index: number
/**
* 点击红心收藏效果
*/
collectStatusChange(): void {
const songs = this.song
// 切换收藏状态
this.song.isCollected = !this.song.isCollected
// 更新收藏列表
this.songList = this.songList.map((item) => {
if (item.id === songs.id) {
item.isCollected = songs.isCollected
}
return item
})
promptAction.showToast({
message: this.song.isCollected ? '收藏成功' : '已取消收藏',
duration: 1500
});
// 触发全局收藏事件
getContext(this).eventHub.emit('collected', this.song.id, this.song.isCollected ? '1' : '0')
}
aboutToAppear(): void {
// 初始化歌曲数据(如果需要)
}
build() {
Column() {
Row() {
Column() {
Text(this.song.title)
.fontWeight(500)
.fontColor('#ff070707')
.fontSize(16)
.margin({ bottom: 4 })
Row({ space: 4 }) {
Image(this.song.mark === '1' ? $r('app.media.ic_vip') : $r('app.media.ic_sq'))
.width(16)
.height(16)
Text(this.song.singer)
.fontSize(12)
.fontWeight(400)
.fontColor('#ff070707')
}
}.alignItems(HorizontalAlign.Start)
Image(this.song.isCollected ? $r('app.media.ic_item_collected') : $r('app.media.ic_item_uncollected'))
.width(50)
.height(50)
.padding(13)
.onClick(() => {
this.collectStatusChange()
})
}
.width("100%")
.justifyContent(FlexAlign.SpaceBetween)
.padding({
left: 16,
right: 16,
top: 12,
bottom: 12
})
.onClick(() => {
// 设置当前播放歌曲
this.song = this.song
// todo: 添加播放逻辑
})
}.width("100%")
}
}
@Component
export struct HotPlayList {
@Prop title:string
@Link songList: Array<Song>
build() {
NavDestination(){
List(){
ForEach(this.songList,(song:Song)=>{
ListItem(){
SongListItem({song:song,songList:this.songList})
}
},(song:Song)=>song.id)
}.cachedCount(3)
.divider({
strokeWidth:1,
color:"#E5E5E5",
startMargin:16,
endMargin:16
})
.scrollBar(BarState.Off)
}.width("100%").height("100%")
.title(this.title)
}
}
@Component
export struct CollectedMusic {
@Link songList: Array<Song>
build() {
Column(){
Text('收藏')
.fontSize(26)
.fontWeight(700)
.height(56)
.width('100%')
.padding({ left: 16, right: 16 })
List(){
ForEach(this.songList.filter((song) => song.isCollected),(song:Song,index:number)=>{
ListItem(){
SongListItem({song:song,songList:this.songList})
}
},(song:Song)=>song.id)
}.cachedCount(3)
.layoutWeight(1)
.divider({
strokeWidth:1,
color:"#E5E5E5",
startMargin:16,
endMargin:16
})
.scrollBar(BarState.Off)
}.width("100%").height("100%")
}
}