鸿蒙音乐应用开发:从收藏功能实现看状态管理与交互设计
在移动应用开发中,收藏功能是用户体验的重要组成部分。本文将以鸿蒙OS音乐应用为例,详细解析如何实现具有动画效果的收藏功能,涉及状态管理、组件通信和交互动画等核心技术点。
一、收藏功能的核心数据模型设计
首先定义Song
数据模型,通过@Observed
装饰器实现数据响应式:
@Observed
class Song {id: string = ''title: string = ''singer: string = ''mark: string = "1" // 音乐品质标识,1:sq, 2:viplabel: 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 = idthis.title = titlethis.singer = singerthis.mark = markthis.label = labelthis.src = srcthis.lyric = lyricthis.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),// 省略其他歌曲...]@BuildertabStyle(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 = truesetTimeout(() => {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:vipmark: string = "1"// 歌曲封面图片label: Resource = $r('app.media.ic_music_icon')// 歌曲播放路径src: string = ''// 歌词文件路径lyric: string = ''// 收藏状态, true:已收藏 false:未收藏isCollected: boolean = falseconstructor(id: string, title: string, singer: string, mark: string, label: Resource, src: string, lyric: string, isCollected: boolean) {this.id = idthis.title = titlethis.singer = singerthis.mark = markthis.label = labelthis.src = srcthis.lyric = lyricthis.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)]@BuildertabStyle(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>@BuildershopPage(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%")}
}