uniapp 仿美团外卖详情页滑动面板组件[可自定义内容、自定义高度]

发布于:2025-07-24 ⋅ 阅读:(14) ⋅ 点赞:(0)

示例代码:

<!-- 组件 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>

效果展示:


网站公告

今日签到

点亮在社区的每一天
去签到