music / src /components /common /BottomSheet.vue
ahutchen's picture
fix
40f23a9
<template>
<Teleport to="body">
<Transition name="bottom-sheet" appear>
<div v-if="visible" class="bottom-sheet-overlay" @click="handleOverlayClick">
<div
class="bottom-sheet"
:class="{ fullscreen: isFullscreen }"
@click.stop
ref="sheetRef"
>
<!-- 拖拽指示器 -->
<div class="drag-indicator" v-if="draggable" @touchstart="handleTouchStart" @mousedown="handleMouseDown">
<div class="drag-handle"></div>
</div>
<!-- 头部 -->
<div class="bottom-sheet-header" v-if="title || closable">
<h3 class="sheet-title" v-if="title">{{ title }}</h3>
<button
v-if="closable"
class="sheet-close-btn"
@click="handleClose"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 内容 -->
<div class="bottom-sheet-body" ref="bodyRef">
<slot></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
// 标题
title: {
type: String,
default: ''
},
// 是否可关闭
closable: {
type: Boolean,
default: true
},
// 点击遮罩是否关闭
maskClosable: {
type: Boolean,
default: true
},
// 是否可拖拽
draggable: {
type: Boolean,
default: true
},
// 最大高度(百分比)
maxHeight: {
type: Number,
default: 90
}
})
const emit = defineEmits(['close', 'open'])
const visible = ref(false)
const isFullscreen = ref(false)
const sheetRef = ref(null)
const bodyRef = ref(null)
// 拖拽相关
const isDragging = ref(false)
const startY = ref(0)
const startHeight = ref(0)
const currentTranslateY = ref(0)
// 处理遮罩点击
const handleOverlayClick = () => {
if (props.maskClosable) {
handleClose()
}
}
// 处理关闭
const handleClose = () => {
close()
}
// 处理触摸开始
const handleTouchStart = (event) => {
if (!props.draggable) return
isDragging.value = true
startY.value = event.touches[0].clientY
startHeight.value = sheetRef.value.offsetHeight
currentTranslateY.value = 0
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd)
}
// 处理鼠标按下
const handleMouseDown = (event) => {
if (!props.draggable) return
isDragging.value = true
startY.value = event.clientY
startHeight.value = sheetRef.value.offsetHeight
currentTranslateY.value = 0
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
event.preventDefault()
}
// 处理触摸移动
const handleTouchMove = (event) => {
if (!isDragging.value) return
const deltaY = event.touches[0].clientY - startY.value
// 只允许向下拖拽
if (deltaY > 0) {
currentTranslateY.value = deltaY
sheetRef.value.style.transform = `translateY(${deltaY}px)`
}
event.preventDefault()
}
// 处理鼠标移动
const handleMouseMove = (event) => {
if (!isDragging.value) return
const deltaY = event.clientY - startY.value
if (deltaY > 0) {
currentTranslateY.value = deltaY
sheetRef.value.style.transform = `translateY(${deltaY}px)`
}
}
// 处理触摸结束
const handleTouchEnd = () => {
if (!isDragging.value) return
isDragging.value = false
// 如果拖拽距离超过阈值,关闭弹窗
if (currentTranslateY.value > startHeight.value * 0.3) {
close()
} else {
// 回弹到原位置
sheetRef.value.style.transform = ''
sheetRef.value.style.transition = 'transform 0.3s ease-out'
setTimeout(() => {
if (sheetRef.value) {
sheetRef.value.style.transition = ''
}
}, 300)
}
currentTranslateY.value = 0
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
// 处理鼠标释放
const handleMouseUp = () => {
if (!isDragging.value) return
isDragging.value = false
if (currentTranslateY.value > startHeight.value * 0.3) {
close()
} else {
sheetRef.value.style.transform = ''
sheetRef.value.style.transition = 'transform 0.3s ease-out'
setTimeout(() => {
if (sheetRef.value) {
sheetRef.value.style.transition = ''
}
}, 300)
}
currentTranslateY.value = 0
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// 处理键盘事件
const handleKeyDown = (event) => {
if (event.key === 'Escape' && props.closable) {
handleClose()
}
}
// 显示底部弹窗
const open = () => {
visible.value = true
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleKeyDown)
nextTick(() => {
// 检查内容是否需要全屏显示
if (bodyRef.value) {
const contentHeight = bodyRef.value.scrollHeight
const maxAllowedHeight = window.innerHeight * (props.maxHeight / 100)
if (contentHeight > maxAllowedHeight * 0.8) {
isFullscreen.value = true
}
}
})
emit('open')
}
// 关闭底部弹窗
const close = () => {
visible.value = false
document.body.style.overflow = ''
document.removeEventListener('keydown', handleKeyDown)
emit('close')
}
// 生命周期
onMounted(() => {
open()
})
onUnmounted(() => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
// 暴露方法
defineExpose({
open,
close
})
</script>
<style scoped>
.bottom-sheet-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bottom-sheet {
width: 100%;
max-width: 500px;
max-height: v-bind(maxHeight + '%');
background: var(--bg-secondary);
border-radius: 16px 16px 0 0;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
margin-bottom: 0;
user-select: none;
}
.bottom-sheet.fullscreen {
height: 90%;
border-radius: 16px;
margin: 20px;
}
.drag-indicator {
padding: 8px 0 4px;
display: flex;
justify-content: center;
cursor: grab;
}
.drag-indicator:active {
cursor: grabbing;
}
.drag-handle {
width: 32px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.bottom-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.sheet-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.sheet-close-btn {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-fast);
}
.sheet-close-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.bottom-sheet-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* 动画 */
.bottom-sheet-enter-active,
.bottom-sheet-leave-active {
transition: all 0.3s ease-out;
}
.bottom-sheet-enter-from,
.bottom-sheet-leave-to {
opacity: 0;
}
.bottom-sheet-enter-from .bottom-sheet,
.bottom-sheet-leave-to .bottom-sheet {
transform: translateY(100%);
}
/* 响应式 */
@media (max-width: 500px) {
.bottom-sheet {
max-width: none;
margin: 0;
}
.bottom-sheet.fullscreen {
margin: 0;
height: 95%;
border-radius: 16px 16px 0 0;
}
}
@media (max-width: 375px) {
.bottom-sheet-header {
padding: 14px 16px;
}
.sheet-title {
font-size: 16px;
}
.sheet-close-btn {
width: 28px;
height: 28px;
}
}
/* 滚动条样式 */
.bottom-sheet-body::-webkit-scrollbar {
width: 4px;
}
.bottom-sheet-body::-webkit-scrollbar-track {
background: transparent;
}
.bottom-sheet-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.bottom-sheet-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>