<template>
<view class="dropdown-wrapper">
<!-- 插槽:触发器 -->
<slot name="reference" :open="open" :close="close" :toggle="toggle">
<!-- 默认触发器 -->
<view class="dropdown-trigger-default" data-dropdown-trigger @tap="open"> 点击选择 ▼ </view>
</slot>
<!-- 浮动下拉菜单 -->
<view
v-if="visible"
ref="dropdownRef"
class="zy-popup-dropdown-menu"
:style="$rpxStyle({ top: `${top}px`, right: `${right}px`, transform: positionStyle || position })"
@touchmove.stop
>
<!-- 使用 scroll-view 实现可靠滚动 -->
<scroll-view
class="dropdown-content"
:style="$rpxStyle({ maxHeight: props.maxHeight + 'px' })"
scroll-y
@touchstart="onContentTouchStart"
@touchmove="onContentTouchMove"
>
<view
v-for="item in props.data"
:key="item[props.valueKey]"
class="dropdown-item"
:class="{ 'is-selected': props.modelValue === item[props.valueKey] }"
@tap="handleSelect(item)"
>
<text class="item-label">{{ item[props.labelKey] }}</text>
<uni-icons
v-show="props.modelValue === item[props.valueKey]"
class="icon-check"
:type="checkIcon"
color="#0f56d5"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref, nextTick, onUnmounted } from 'vue'
// ----------------------
// Props 定义
// ----------------------
const props = defineProps({
// 数据源 [{ label: 'xxx', value: '1' }]
data: {
type: Array,
required: true
},
// 当前选中值(v-model)
modelValue: {
type: [String, Number, Boolean, null],
default: null
},
// 显示字段名
labelKey: {
type: String,
default: 'name'
},
// 值字段名
valueKey: {
type: String,
default: 'value'
},
// 触发器元素的 ID
trigger: {
type: String,
default: '',
required: true
},
// 选中图标
checkIcon: {
type: String,
default: 'checkmarkempty'
},
// 页面滚动是否关闭下拉框
scrollClose: {
type: Boolean,
default: true
},
// 最大高度(px)
maxHeight: {
type: Number,
default: 486
},
// 自定义定位 transform
positionStyle: {
type: String,
default: ''
},
parentInstance: {
type: Object
}
})
// ----------------------
// Emits
// ----------------------
const emit = defineEmits(['update:modelValue', 'change', 'open', 'close'])
// ----------------------
// 内部状态
// ----------------------
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const right = ref(12)
const position = ref('')
const dropdownRef = ref(null)
let observer = null
let startY, startScrollTop
// ----------------------
// 触摸事件处理(用于防止滚动穿透)
// ----------------------
const onContentTouchStart = e => {
const el = e.currentTarget
startY = e.touches?.[0]?.clientY || 0
startScrollTop = el?.scrollTop || 0
}
const onContentTouchMove = e => {
const el = e.currentTarget
if (!el) return
const endY = e.touches?.[0]?.clientY
if (!endY) return
const direction = startY - endY > 0 ? 'down' : 'up'
// 到达顶部且向上滑动 → 允许页面滚动(不做阻止)
if (startScrollTop <= 0 && direction === 'up') {
// 不阻止,交给外层处理
return
}
// 到达底部且向下滑动
if (el.scrollHeight - el.scrollTop <= el.clientHeight + 1 && direction === 'down') {
// 不阻止,允许外层滚动
return
}
// 否则:正在内容内部滚动,阻止默认行为避免页面抖动
e.preventDefault()
}
// ----------------------
// 获取触发器位置
// ----------------------
async function getTriggerRect() {
// 确保 DOM 更新完成
await nextTick()
return new Promise(resolve => {
const query = uni.createSelectorQuery()
// 注意:H5 和小程序平台写法略有不同,建议加上 .in(this)
// 如果是在组件内,需要传入当前实例上下文
query
.select('#' + props.trigger)
.boundingClientRect(data => {
if (data) {
console.log('获取到元素位置:', data)
resolve(data)
} else {
console.warn('未找到元素 #' + props.trigger)
resolve(null)
}
})
.exec()
})
}
async function getTriggerRectT() {
await nextTick()
return new Promise(resolve => {
const query = uni.createSelectorQuery()
query
.in(props.parentInstance) // ← 绑定到父组件作用域
.select('#' + props.trigger)
.boundingClientRect(data => {
if (data) {
console.log('成功获取父组件元素位置:', data)
resolve(data)
} else {
console.warn('未找到 #myTrigger')
resolve(null)
}
})
.exec()
})
}
// ----------------------
// 打开下拉框
// ----------------------
// 定义一个异步函数 open,用于处理打开操作,接收事件对象 e 作为参数
const open = async e => {
console.log('🚀 ~ open ~ e:', e)
if (visible.value) {
close()
return
}
let rect = null
if (props.parentInstance) {
rect = await getTriggerRectT()
} else {
rect = await getTriggerRect()
}
console.log('🚀 ~ open ~ rect:', rect)
if (!rect) return
doOpen(rect)
}
const doOpen = rect => {
console.log('🚀 ~ rect:', rect)
const res = uni.getSystemInfoSync()
const windowHeight = Number(res.windowHeight)
console.log('🚀 ~ windowHeight 视口高度:', windowHeight)
const windowWidth = Number(res.windowWidth)
console.log('🚀 ~ windowWidth 视口宽度:', windowWidth)
let maxHeight = null
if (props.maxHeight < (props.data.length + 1) * 46) {
maxHeight = props.maxHeight
} else {
maxHeight = (props.data.length + 1) * 46
}
console.log('🚀 ~ maxHeight 弹窗最大高度:', maxHeight)
const menuWidth = 215
const rightGap = 12
const spaceBelow = windowHeight - rect.bottom
console.log('🚀 ~ spaceBelow: 安全距离', spaceBelow)
const spaceAbove = rect.top
console.log('🚀 ~ spaceAbove: 安全距离', spaceAbove)
const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight
console.log('🚀 ~ isBothDirectionsInsufficient:', isBothDirectionsInsufficient)
let finalTop, finalLeft, finalTransform
if (isBothDirectionsInsufficient) {
// 上下空间都不够 → 居中显示
finalTop = windowHeight / 2
finalTransform = 'translate(0, -50%)'
finalLeft = windowWidth - menuWidth - rightGap
if (finalLeft < 0) finalLeft = 0
} else {
// 正常方向展开
const needUpward = spaceBelow < maxHeight
console.log('🚀 ~ needUpward:', needUpward)
if (needUpward) {
// 弹窗在上方时,确保不覆盖触发元素
finalTop = rect.top - maxHeight
// 可以添加一个小的间距,避免紧贴太近
finalTop = Math.max(0, finalTop - 5)
} else {
// 弹窗在下方时,紧贴触发元素底部
finalTop = rect.bottom
}
finalTransform = needUpward ? '' : ''
finalLeft = windowWidth - menuWidth - rightGap
if (finalLeft < 0) finalLeft = 0
}
// 移除不必要的偏移
top.value = finalTop
console.log('🚀 ~ top.value:', top.value)
left.value = finalLeft
console.log('🚀 ~ left.value:', left.value)
position.value = finalTransform
console.log('🚀 ~ position.value:', position.value)
visible.value = true
emit('open', props.modelValue, props.trigger)
nextTick(() => {
bindOutsideClickListener()
bindScrollListener()
})
}
// ----------------------
// 关闭 & 切换 & 选择
// ----------------------
const close = () => {
if (!visible.value) return
visible.value = false
emit('close', props.trigger)
removeListeners()
}
const toggle = () => {
visible.value ? close() : open()
}
const handleSelect = item => {
const value = item[props.valueKey]
const label = item[props.labelKey]
const selected = { [props.valueKey]: value, [props.labelKey]: label }
if (props.modelValue !== value) {
emit('update:modelValue', value, props.trigger)
emit('change', selected, props.trigger)
}
close()
}
// ----------------------
// 外部点击关闭
// ----------------------
const bindOutsideClickListener = () => {
const handler = e => {
const path = e.path || []
const isInside = path.some(
node =>
node === dropdownRef.value?.$el ||
node?.classList?.contains('zy-popup-dropdown-menu') ||
node?.dataset?.dropdownTrigger
)
if (!isInside) close()
}
uni.$on('onPageTap', handler)
observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) }
}
// ----------------------
// 页面滚动关闭
// ----------------------
const bindScrollListener = () => {
const scrollHandler = () => {
if (!visible.value) return
if (props.scrollClose) {
close()
} else {
reposition()
}
}
uni.$on('onPageScroll', scrollHandler)
observer = {
...observer,
cleanupScroll: () => uni.$off('onPageScroll', scrollHandler)
}
}
// ----------------------
// 重新定位(滚动时调整位置)
// ----------------------
const reposition = async () => {
let rect = null
if (props.parentInstance) {
rect = await getTriggerRectT()
} else {
rect = await getTriggerRect()
}
if (!rect) return
const res = uni.getSystemInfoSync()
const windowHeight = Number(res.windowHeight)
const windowWidth = Number(res.windowWidth)
let maxHeight = null
if (props.maxHeight < (props.data.length + 1) * 46) {
maxHeight = props.maxHeight
} else {
maxHeight = (props.data.length + 1) * 46
}
const menuWidth = 215
const rightGap = 12
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight
let finalTop, finalLeft, finalTransform
if (isBothDirectionsInsufficient) {
finalTop = windowHeight / 2
finalTransform = 'translate(0, -50%)'
finalLeft = windowWidth - menuWidth - rightGap
if (finalLeft < 0) finalLeft = 0
} else {
const needUpward = spaceBelow < maxHeight
finalTop = needUpward ? rect.top - maxHeight : rect.bottom
finalTransform = needUpward ? '' : ''
finalLeft = windowWidth - menuWidth - rightGap
if (finalLeft < 0) finalLeft = 0
}
top.value = finalTop - 8
console.log('🚀 ~ reposition ~ top.value:', top.value)
left.value = finalLeft
console.log('🚀 ~ reposition ~ left.value:', left.value)
position.value = finalTransform
console.log('🚀 ~ reposition ~ position.value:', position.value)
}
// 防抖函数
const debounce = (fn, time = 50) => {
let timer = null
return (...args) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), time)
}
}
const repositionDebounced = debounce(reposition, 50)
// ----------------------
// 移除监听
// ----------------------
const removeListeners = () => {
observer?.cleanupTap?.()
observer?.cleanupScroll?.()
observer = null
}
// ----------------------
// 暴露方法
// ----------------------
defineExpose({ open, close, toggle })
// ----------------------
// 卸载清理
// ----------------------
onUnmounted(() => {
removeListeners()
})
</script>
<style scoped>
/* 整体容器 */
.dropdown-wrapper {
display: inline-block;
}
/* 默认触发器样式 */
.dropdown-trigger-default {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
color: #333;
}
/* 下拉菜单主体 */
.zy-popup-dropdown-menu {
position: fixed;
width: 215px;
background-color: #ffffff;
border-radius: 8px;
z-index: 9999;
display: flex;
flex-direction: column;
box-shadow:
0 4px 5px -3px rgba(0, 0, 0, 0.08),
0 8px 12px 1px rgba(0, 0, 0, 0.04),
0 3px 15px 3px rgba(0, 0, 0, 0.05);
}
/* 内容区可滚动(现在由 scroll-view 渲染) */
.dropdown-content {
flex: 1;
max-height: 486px;
/* scroll-view 会接管滚动行为,无需 overflow */
}
/* 每一项 */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 15px;
color: #333333;
border-bottom: 1px solid #f5f5f5;
}
.dropdown-item:last-child {
border-bottom: none;
}
/* 选中项样式 */
.dropdown-item.is-selected {
color: #0f56d5;
font-weight: 500;
}
/* 文本自动换行 */
.item-label {
flex: 1;
word-break: break-word;
line-height: 1.4;
text-align: left;
}
/* 对号图标 */
.icon-check {
font-family: 'erda' !important;
font-size: 16px;
margin-left: 8px;
color: #0f56d5;
}
</style>
当前组件代码打开的弹窗位置没有紧贴住触发元素的上边框或者下边框,元素距离视口上边越近打开的弹窗就会距离触发元素越近,反之则越远