HarmonyOS5 音乐播放器app(一):歌曲展示与收藏功能(附代码)

鸿蒙音乐应用开发:从收藏功能实现看状态管理与交互设计

在移动应用开发中,收藏功能是用户体验的重要组成部分。本文将以鸿蒙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%")
  }
}
相关推荐
GeniuswongAir5 小时前
准备开始适配高德Flutter的鸿蒙版了
华为·harmonyos
安 当 加 密7 小时前
通过Radius认证服务器实现飞塔/华为防火墙二次认证:原理、实践与安全价值解析
服务器·安全·华为
Listennnn7 小时前
HarmonyOS 6 + 盘古大模型5.5
华为·harmonyos
大千AI助手8 小时前
饼图:数据可视化的“切蛋糕”艺术
大数据·人工智能·信息可视化·数据挖掘·数据分析·pie·饼图
Georgewu8 小时前
鸿蒙 6.0 引爆 AI 智能体革命:从交互重构到全场景智能觉醒,未来已至
harmonyos
今阳8 小时前
鸿蒙开发笔记-17-ArkTS并发
android·前端·harmonyos
liliangcsdn10 小时前
标书生成过程中的prompt解读
人工智能·python·信息可视化·语言模型·prompt
今阳12 小时前
鸿蒙开发笔记-16-应用间跳转
android·前端·harmonyos
qq_3863226912 小时前
华为网路设备学习-25(路由器OSPF - 特性专题 二)
网络·学习·华为