uni-app x跨平台开发实战:HarmonyOS音乐播放列表弹窗实现详解

在音乐播放页面中,点击底部控制区左侧的「列表」按钮,会从底部弹出一个播放列表面板,并带有半透明遮罩层。很多同学会问:这是使用了 popmenu 之类的组件吗?答案是:没有用额外组件,完全基于 view + scroll-view + v-if 自己实现的“底部弹窗 + 遮罩”效果。**下面我们结合实际的 pages/music/player.uvue 代码,详细拆解这个播放列表弹窗的实现思路和关键代码。


在这里插入图片描述

该项目的开源地址https://gitcode.com/qq8864/uniappx_imovie

支持Android、IOS、HarmonyOS和Web、小程序等。

1. 整体设计思路

播放列表弹窗主要由三部分组成:

  • 状态管理层:使用 store/playlistStore.uts 管理全局播放列表数据(模块级单例),使用 ref 控制弹窗的显示/隐藏。
  • UI 结构层:在 player.uvue 中,通过 v-if 条件渲染一个全屏遮罩层和底部面板,内部使用 scroll-view 展示歌曲列表。
  • 交互逻辑层:点击「列表」按钮打开弹窗、点击遮罩或关闭按钮关闭弹窗、点击列表项切歌、点击删除按钮移除歌曲。

整个实现不依赖第三方弹窗组件,完全基于 uni-app x 的基础视图组件和 UTS 逻辑完成,跨端行为更可控、可维护性更高。


2. 播放列表数据管理:模块级单例

播放列表的数据统一由 store/playlistStore.uts 管理,采用模块级单例的方式在多个页面间共享:

// store/playlistStore.uts
export type PlayItem = {
  sid : string
  song : string
  sing : string
  url : string
  cover : string
}

let _playlist : PlayItem[] = []

// 添加歌曲(sid 相同则跳过,避免重复)
export const addToPlaylist = (item : PlayItem) : void => {
  for (let i = 0; i < _playlist.length; i++) {
    if (_playlist[i].sid === item.sid) return
  }
  _playlist.push(item)
}

// 获取当前完整播放列表
export const getPlaylist = () : PlayItem[] => {
  return _playlist
}

// 根据 sid 从列表中移除歌曲
export const removeFromPlaylist = (sid : string) : void => {
  for (let i = 0; i < _playlist.length; i++) {
    if (_playlist[i].sid === sid) {
      _playlist.splice(i, 1)
      return
    }
  }
}

// 根据 sid 获取在列表中的索引,找不到返回 -1
export const getIndexBySid = (sid : string) : number => {
  for (let i = 0; i < _playlist.length; i++) {
    if (_playlist[i].sid === sid) return i
  }
  return -1
}

要点说明:

  • _playlist 是模块级数组,只会在首次加载模块时初始化一次,后续所有页面引用到同一个实例。
  • 通过 addToPlaylist / getPlaylist / removeFromPlaylist / getIndexBySid 对列表做增删查改,封装良好。
  • 播放器页面和歌曲列表页面都通过这些函数访问同一份播放队列,实现“跨页面共享播放列表”。

3. 播放器页面中的弹窗状态与数据

pages/music/player.uvue 中,播放器页面使用 ref 定义了与播放列表弹窗相关的状态:

// 播放列表状态(player.uvue 中)
const showPlaylist = ref<boolean>(false)
const playlistItems = ref<PlayItem[]>([])

const togglePlaylist = () => {
  if (!showPlaylist.value) {
    // 打开时刷新列表
    playlistItems.value = getPlaylist()
  }
  showPlaylist.value = !showPlaylist.value
}

const closePlaylist = () => {
  showPlaylist.value = false
}

const removeItem = (itemSid : string) => {
  removeFromPlaylist(itemSid)
  // 删除后刷新显示列表
  playlistItems.value = getPlaylist()
}

const onPlaylistItemTap = (item : PlayItem) => {
  if (item.sid === sid.value) {
    closePlaylist()
    return
  }
  switchSong(item)
}

这里的关键点:

  • showPlaylist 控制弹窗是否显示;
  • playlistItems 是当前弹窗中显示的列表数据;
  • 打开弹窗前先调用 getPlaylist(),保证弹窗内容与全局播放队列同步;
  • 删除歌曲时调用 removeFromPlaylist 更新全局列表,再刷新 playlistItems
  • 点击列表项时,如果是当前播放歌曲只关闭弹窗,否则调用 switchSong 切歌并继续播放。

这些状态和方法,都是为模板层的“弹出层 UI”服务的。


4. 弹出层 UI 结构:遮罩 + 底部面板

播放列表弹窗的 UI 完全写在 player.uvue<template> 中,通过 v-if="showPlaylist" 条件渲染:

<!-- 播放列表浮层(player.uvue 中) -->
<view v-if="showPlaylist" class="pl-overlay">
  <!-- 点击上方空白区域关闭 -->
  <view class="pl-close-area" @click="closePlaylist" />
  <!-- 播放列表面板 -->
  <view class="pl-panel">
    <view class="pl-header">
      <text class="pl-title">播放列表 · {{ playlistItems.length }} 首</text>
      <text class="pl-close-btn" @click="closePlaylist"></text>
    </view>
    <scroll-view class="pl-scroll" direction="vertical">
      <view
        v-for="(item, index) in playlistItems"
        :key="item.sid"
        class="pl-item"
        @click="onPlaylistItemTap(item)"
      >
        <text :class="item.sid === sid ? 'pl-index pl-cur' : 'pl-index'">
          {{ item.sid === sid ? '♪' : (index + 1).toString() }}
        </text>
        <view class="pl-info">
          <text :class="item.sid === sid ? 'pl-name pl-cur' : 'pl-name'">{{ item.song }}</text>
          <text class="pl-singer">{{ item.sing }}</text>
        </view>
        <text v-if="item.sid === sid" class="pl-playing-dot"></text>
        <!-- 删除按钮 -->
        <view class="pl-del-btn" @click.stop="removeItem(item.sid)">
          <text class="pl-del-icon">×</text>
        </view>
      </view>
      <view style="height: 12px;" />
    </scroll-view>
  </view>
</view>

结构拆解:

  • 顶层 pl-overlay:是一个全屏 view,配合绝对定位和半透明背景色,形成蒙层效果。
  • 子节点 pl-close-area:占据上半部分区域,点击即可关闭弹窗,模拟“点击遮罩关闭”的常见交互。
  • 底部 pl-panel:固定高度的底部面板,相当于自定义的 Bottom Sheet,内部使用 scroll-view 渲染歌曲列表。
  • 列表项中通过 item.sid === sid 判断当前播放歌曲,展示高亮颜色和小图标。
  • 删除按钮使用 @click.stop 阻止事件冒泡,避免触发整行的“切歌”点击事件。

可以看到,这里并没有使用任何 popmenu 或 popup 组件,而是一步步搭出来的遮罩 + 面板结构,因此逻辑比较透明、可完全自定义样式。


5. 弹窗样式:自定义 Bottom Sheet 效果

对应的样式写在 player.uvue<style> 部分,关键样式如下:

.pl-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.52);
  flex-direction: column;
}

/* 点击上方空白关闭 */
.pl-close-area {
  flex: 1;
}

/* 底部面板 */
.pl-panel {
  height: 320px;
  background-color: #1a1a2e;
  border-top-left-radius: 16px;
  border-top-right-radius: 16px;
  flex-direction: column;
}

.pl-header {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding-left: 20px;
  padding-right: 20px;
  padding-top: 16px;
  padding-bottom: 12px;
  border-bottom-width: 1px;
  border-bottom-color: rgba(255, 255, 255, 0.06);
}

.pl-title {
  font-size: 15px;
  font-weight: bold;
  color: #ffffff;
}

.pl-scroll {
  flex: 1;
}

.pl-item {
  flex-direction: row;
  align-items: center;
  padding-left: 20px;
  padding-right: 16px;
  padding-top: 12px;
  padding-bottom: 12px;
  border-bottom-width: 1px;
  border-bottom-color: rgba(255, 255, 255, 0.04);
}

.pl-index {
  width: 28px;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.35);
  text-align: center;
  flex-shrink: 0;
}

.pl-info {
  flex: 1;
  padding-left: 12px;
  flex-direction: column;
}

.pl-name {
  font-size: 14px;
  color: #e0e0e0;
}

.pl-singer {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.4);
  margin-top: 3px;
}

.pl-cur {
  color: #e67e22;
}

.pl-playing-dot {
  font-size: 12px;
  color: #e67e22;
  padding-left: 8px;
  flex-shrink: 0;
}

/* 删除按钮 */
.pl-del-btn {
  width: 28px;
  height: 28px;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  margin-left: 6px;
}

.pl-del-icon {
  font-size: 20px;
  color: rgba(255, 255, 255, 0.3);
}

实现效果:

  • pl-overlay 让整个弹窗浮在播放器之上,半透明黑色背景形成暗场效果。
  • pl-close-area 利用 flex: 1 把底部面板“推”到下方,同时作为点击关闭的区域。
  • pl-panel 通过圆角、背景色和固定高度形成“抽屉/底部弹窗”的视觉效果。
  • 列表项使用柔和的分割线和高亮颜色,让当前播放歌曲一目了然。

如果需要进一步优化体验,也可以在这里加入过渡动画(例如高度变化或位移动画),但当前实现已经能满足大多数移动端场景。


6. 整体流程回顾

结合前面的代码,我们把“点击列表按钮 → 弹出播放列表 → 切歌/删除 → 关闭弹窗”的完整流程串起来:

  1. 在底部控制区点击「列表」按钮,触发 togglePlaylist
    • 如果之前是关闭状态,先从 playlistStore 中读取最新播放队列到 playlistItems
    • 然后把 showPlaylistfalse 设为 true,模板中 v-if 生效,弹窗渲染出来。
  2. 弹窗出现后:
    • 点击顶部的半透明区域或右上角的 按钮,调用 closePlaylist 关闭弹窗。
    • 点击某一行歌曲,调用 onPlaylistItemTap
      • 如果是当前歌曲,则只关闭弹窗;
      • 如果是其他歌曲,调用 switchSong 更新当前歌曲并继续播放,同时自动关闭弹窗。
    • 点击行尾的删除按钮,调用 removeItem 删除对应歌曲,并刷新 playlistItems,界面立即更新。
  3. 播放过程中,当当前歌曲播放完毕:
    • 音频上下文 onEnded 事件中通过 getPlaylist + getIndexBySid 找到下一首,调用 switchSong 自动播放下一首。

通过上述设计,播放列表弹窗与全局播放队列、播放器核心逻辑紧密结合,在不依赖任何额外组件的前提下,实现了一个体验完整、可维护性强的播放列表弹窗。


7. 小结与扩展建议

这套实现有几个值得借鉴的点:

  • 不用依赖复杂弹窗组件:直接基于 view + scroll-view + v-if,配合绝对定位和遮罩,就能实现稳定的底部弹窗。
  • 模块级单例管理播放队列playlistStore.uts 让跨页面共享播放列表变得简单,逻辑集中、易于维护。
  • UTS 强类型配合 Composition API:状态和方法定义清晰,代码在不同平台上都有较好的一致性。

在此基础上,你可以进一步扩展:

  • 为播放列表弹窗增加打开/关闭动画,提升动态体验;
  • 在列表项增加清空全部、批量删除等高级操作;
  • 为播放列表添加持久化存储(如本地缓存),实现跨会话记忆播放队列。

这些扩展都可以在现有架构上平滑演进,无需大改整体结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

特立独行的猫a

您的鼓励是我的创作动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值