在音乐播放页面中,点击底部控制区左侧的「列表」按钮,会从底部弹出一个播放列表面板,并带有半透明遮罩层。很多同学会问:这是使用了
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. 整体流程回顾
结合前面的代码,我们把“点击列表按钮 → 弹出播放列表 → 切歌/删除 → 关闭弹窗”的完整流程串起来:
- 在底部控制区点击「列表」按钮,触发
togglePlaylist:- 如果之前是关闭状态,先从
playlistStore中读取最新播放队列到playlistItems。 - 然后把
showPlaylist从false设为true,模板中v-if生效,弹窗渲染出来。
- 如果之前是关闭状态,先从
- 弹窗出现后:
- 点击顶部的半透明区域或右上角的
✕按钮,调用closePlaylist关闭弹窗。 - 点击某一行歌曲,调用
onPlaylistItemTap:- 如果是当前歌曲,则只关闭弹窗;
- 如果是其他歌曲,调用
switchSong更新当前歌曲并继续播放,同时自动关闭弹窗。
- 点击行尾的删除按钮,调用
removeItem删除对应歌曲,并刷新playlistItems,界面立即更新。
- 点击顶部的半透明区域或右上角的
- 播放过程中,当当前歌曲播放完毕:
- 音频上下文
onEnded事件中通过getPlaylist + getIndexBySid找到下一首,调用switchSong自动播放下一首。
- 音频上下文
通过上述设计,播放列表弹窗与全局播放队列、播放器核心逻辑紧密结合,在不依赖任何额外组件的前提下,实现了一个体验完整、可维护性强的播放列表弹窗。
7. 小结与扩展建议
这套实现有几个值得借鉴的点:
- 不用依赖复杂弹窗组件:直接基于
view + scroll-view + v-if,配合绝对定位和遮罩,就能实现稳定的底部弹窗。 - 模块级单例管理播放队列:
playlistStore.uts让跨页面共享播放列表变得简单,逻辑集中、易于维护。 - UTS 强类型配合 Composition API:状态和方法定义清晰,代码在不同平台上都有较好的一致性。
在此基础上,你可以进一步扩展:
- 为播放列表弹窗增加打开/关闭动画,提升动态体验;
- 在列表项增加清空全部、批量删除等高级操作;
- 为播放列表添加持久化存储(如本地缓存),实现跨会话记忆播放队列。
这些扩展都可以在现有架构上平滑演进,无需大改整体结构。
1087

被折叠的 条评论
为什么被折叠?



