示例代码:
<!-- 组件 BottomSlidePanel.vue 代码 -->
<template>
<view class="bottom-slide-panel" v-if="visible">
<!-- 背景遮罩 -->
<view class="overlay" :class="{ 'overlay-visible': showOverlay }" @tap="handleOverlayTap"></view>
<!-- 滑动面板 -->
<view class="panel" :class="{
'panel-show': isShow,
'panel-expanded': isExpanded
}" :style="{
// height: panelHeight + 'px',
transform: getPanelTransform()
}" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
<!-- 拖拽指示器 -->
<view class="drag-handle" @tap="toggle">
<view class="drag-bar"></view>
</view>
<!-- 面板内容 -->
<view class="panel-content">
<slot></slot>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'BottomSlidePanel',
props: {
// 是否显示面板
visible: {
type: Boolean,
default: false
},
// 面板高度(px)
height: {
type: Number,
default: 400
},
// 最小显示高度(收起时显示的高度)
minHeight: {
type: Number,
default: 60
},
// 是否显示遮罩
showMask: {
type: Boolean,
default: true
},
// 点击遮罩是否关闭
maskClosable: {
type: Boolean,
default: true
},
// 初始是否展开
defaultExpanded: {
type: Boolean,
default: true
}
},
data() {
return {
isShow: false,
isExpanded: false,
showOverlay: false,
panelHeight: 0,
startY: 0,
currentY: 0,
isDragging: false,
startTime: 0
}
},
watch: {
visible: {
handler(newVal) {
if (newVal) {
this.show()
} else {
this.hide()
}
},
immediate: true
}
},
mounted() {
this.panelHeight = this.height
this.isExpanded = this.defaultExpanded
},
methods: {
show() {
this.isShow = true
this.$nextTick(() => {
setTimeout(() => {
if (this.defaultExpanded) {
this.expand()
} else {
this.collapse()
}
}, 50)
})
},
hide() {
this.isShow = false
this.showOverlay = false
this.isExpanded = false
},
expand() {
this.isExpanded = true
this.showOverlay = this.showMask
this.$emit('expand')
this.$emit('change', { expanded: true })
},
collapse() {
this.isExpanded = false
this.showOverlay = false
this.$emit('collapse')
this.$emit('change', { expanded: false })
},
toggle() {
if (this.isExpanded) {
this.collapse()
} else {
this.expand()
}
},
handleOverlayTap() {
if (this.maskClosable) {
this.hide()
this.$emit('update:visible', false)
}
},
onTouchStart(e) {
this.isDragging = true
this.startY = e.touches[0].clientY
this.currentY = this.startY
this.startTime = Date.now()
},
onTouchMove(e) {
if (!this.isDragging) return
e.preventDefault()
this.currentY = e.touches[0].clientY
const deltaY = this.currentY - this.startY
// 根据滑动方向和当前状态判断是否允许滑动
if (this.isExpanded && deltaY > 0) {
// 展开状态下向下滑动,允许收起
return
} else if (!this.isExpanded && deltaY < 0) {
// 收起状态下向上滑动,允许展开
return
}
},
onTouchEnd() {
if (!this.isDragging) return
this.isDragging = false
const deltaY = this.currentY - this.startY
const deltaTime = Date.now() - this.startTime
const velocity = Math.abs(deltaY) / deltaTime
// 快速滑动或滑动距离超过阈值时切换状态
const threshold = 50
const velocityThreshold = 0.3
if (velocity > velocityThreshold || Math.abs(deltaY) > threshold) {
if (deltaY > 0 && this.isExpanded) {
// 向下滑动且当前展开,收起面板
this.collapse()
} else if (deltaY < 0 && !this.isExpanded) {
// 向上滑动且当前收起,展开面板
this.expand()
}
}
},
getPanelTransform() {
if (!this.isShow) {
return 'translateY(100%)'
} else if (this.isExpanded) {
return 'translateY(0)'
} else {
return `translateY(calc(100% - ${this.minHeight}px))`
}
}
}
}
</script>
<style lang="scss" scoped>
.bottom-slide-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
pointer-events: none;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
// background-color: rgba(0, 0, 0, 0);
transition: background-color 0.3s ease;
pointer-events: none;
&.overlay-visible {
// background-color: rgba(0, 0, 0, 0.4);
pointer-events: auto;
}
}
.panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
border-radius: 32rpx 32rpx 0 0;
box-shadow: 0 -4rpx 32rpx rgba(0, 0, 0, 0.1);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
pointer-events: auto;
display: flex;
flex-direction: column;
}
.drag-handle {
display: flex;
justify-content: center;
align-items: center;
height: 60rpx;
padding: 16rpx 0;
cursor: pointer;
flex-shrink: 0;
}
.drag-bar {
width: 80rpx;
height: 6rpx;
background-color: #e5e5e5;
border-radius: 3rpx;
transition: background-color 0.2s ease;
}
.drag-handle:active .drag-bar {
background-color: #d0d0d0;
}
.panel-content {
flex: 1;
padding: 0 32rpx 32rpx;
-webkit-overflow-scrolling: touch;
}
</style>
使用示例:
<template>
<view class="demo-page">
<!-- 模拟地图背景 -->
<view class="map-container">
<image class="map-image" src="/static/map-bg.jpg" mode="aspectFill"></image>
<!-- 操作按钮 -->
<view class="control-buttons">
<button @tap="showPanel" class="btn">显示面板</button>
<button @tap="hidePanel" class="btn">隐藏面板</button>
<button @tap="togglePanel" class="btn">切换状态</button>
</view>
</view>
<!-- 底部滑动面板 -->
<BottomSlidePanel :visible="panelVisible" :height="400" :minHeight="80" :showMask="true" :maskClosable="true"
:defaultExpanded="true" @update:visible="panelVisible = $event" @expand="onPanelExpand"
@collapse="onPanelCollapse" @change="onPanelChange">
<!-- 自定义面板内容 -->
<view class="panel-header">
<text class="title">附近推荐</text>
</view>
<view class="content-section">
<view class="info-card">
<view class="card-icon">📍</view>
<view class="card-content">
<text class="card-title">星巴克咖啡</text>
<text class="card-desc">距离您 200m · 营业中</text>
</view>
</view>
<view class="action-buttons">
<button class="action-btn primary">导航</button>
<button class="action-btn secondary">收藏</button>
</view>
<view class="more-content">
<text class="section-title">更多推荐</text>
<view class="item-list">
<view class="list-item" v-for="item in 5" :key="item">
<text class="item-name">推荐地点 {{ item }}</text>
<text class="item-distance">{{ item * 100 }}m</text>
</view>
</view>
</view>
</view>
</BottomSlidePanel>
</view>
</template>
<script>
import BottomSlidePanel from '@/components/BottomSlidePanel.vue'
export default {
components: {
BottomSlidePanel
},
data() {
return {
panelVisible: false
}
},
methods: {
showPanel() {
this.panelVisible = true
},
hidePanel() {
this.panelVisible = false
},
togglePanel() {
this.panelVisible = !this.panelVisible
},
onPanelExpand() {
console.log('面板展开')
},
onPanelCollapse() {
console.log('面板收起')
},
onPanelChange(e) {
console.log('面板状态改变:', e.expanded)
}
}
}
</script>
<style lang="scss" scoped>
.demo-page {
height: 100vh;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
position: relative;
background-color: #f0f0f0;
}
.map-image {
width: 100%;
height: 100%;
}
.control-buttons {
position: absolute;
top: 100rpx;
left: 32rpx;
right: 32rpx;
display: flex;
gap: 20rpx;
}
.btn {
flex: 1;
height: 80rpx;
background-color: #007aff;
color: white;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
}
.panel-header {
padding: 0 0 32rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
.content-section {
padding-top: 32rpx;
}
.info-card {
display: flex;
align-items: center;
padding: 24rpx;
background-color: #fff7e6;
border-radius: 12rpx;
border: 1rpx solid #ffd591;
margin-bottom: 32rpx;
}
.card-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.card-content {
flex: 1;
}
.card-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.card-desc {
font-size: 26rpx;
color: #666;
}
.action-buttons {
display: flex;
gap: 24rpx;
margin-bottom: 48rpx;
}
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
font-size: 32rpx;
border: none;
&.primary {
background-color: #ff6b35;
color: white;
}
&.secondary {
background-color: #f5f5f5;
color: #333;
}
}
.more-content {
margin-top: 32rpx;
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
}
.item-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.item-name {
font-size: 30rpx;
color: #333;
}
.item-distance {
font-size: 26rpx;
color: #999;
}
</style>
</template>
效果展示: