多屏环境下window.open()参数left不生效,窗口无法水平居中显示问题解决

在双屏或多屏设置中,使用window.open()的left参数尝试使新窗口居中可能不会生效。文章提到了一种替代方案,即利用window.screen.availLeft来获取可用屏幕左边界,从而实现跨屏幕的弹窗居中。通过计算(window.screen.availLeft||0)+(screen.width-1200)/2,可以确保窗口在任何屏幕上都能正确居中打开。

多屏环境下window.open()参数left不生效

let windowLeft = (window.screenX || window.screenLeft || 0) + (screen.width - widowWidth) / 2;

tips: screenX和screenY属性返回窗口相对于屏幕的X和Y坐标

经过测试:发现双屏时,以上方法在副屏打开弹窗仍然无法居中

最终方案:
let left = (window.screen.availLeft || 0) + (screen.width - 1200) /2

        /**
         * 打开子窗口
         * @param path
         */
        windowOpenHelper(path, dynamicQuery) {
            const url = this.$router.resolve({
                path,
                query: {
                    projectId: this.$route.query.projectId,
                    enterpriseId: this.$route.query.enterpriseId,
                    ...dynamicQuery
                },
            }).href
            const width = 1750
            const height = 900
            const offsetLeft = (window.outerWidth - width) / 2
            const offsetTop = (window.outerHeight - height) / 2
            //浏览器地址栏、书签栏高度
            const topHeight = (window.outerHeight - window.innerHeight) / 2
            const features = `left=${(window.screen.availLeft || 0) + offsetLeft},top=${
                offsetTop + topHeight},width=${width},height=${height - 60}`
            return window.open(url, '_blank', features)
        },
const doOpen = rect => { console.log('🚀 ~ rect:', rect) const res = uni.getWindowInfo?.() || uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) const windowWidth = Number(res.windowWidth) const bottomSafeInset = res.safeArea?.bottom || 0 // 安全区底部(如虚拟按键区) const fixedBottomBarHeight = 75 // 固定底部栏高度(单位 px) const minRequiredSpaceBelow = maxHeight // 至少需要这么空间才能向下展开 console.log('🚀 ~ windowHeight:', windowHeight) console.log('🚀 ~ bottomSafeInset:', bottomSafeInset) console.log('🚀 ~ fixedBottomBarHeight:', fixedBottomBarHeight) // 计算最大高度 const itemHeight = 42 const maxHeight = Math.min(props.maxHeight, (props.data.length + 1) * itemHeight) console.log('🚀 ~ maxHeight 弹窗最大高度:', maxHeight) const menuWidth = 215 const rightGap = 12 // ✅ 改进:向下可用空间 = 视口底 - 元素底 - 安全区底 - 固定栏高度 const availableSpaceBelow = windowHeight - rect.bottom - bottomSafeInset - fixedBottomBarHeight const spaceAbove = rect.top // 向上空间就是元素顶部到视口顶的距离(一般不需要减 top 安全区?但也可以) console.log('🚀 ~ availableSpaceBelow(向下可用空间):', availableSpaceBelow) console.log('🚀 ~ spaceAbove(向上可用空间):', spaceAbove) const isBothDirectionsInsufficient = spaceAbove < maxHeight && availableSpaceBelow < maxHeight let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // 上下都不够 → 居中显示 finalTop = windowHeight / 2 finalTransform = 'translateY(-50%)' finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) finalLeft = 0 } else { // 是否需要向上展开?如果向下的可用空间不足以容纳弹窗,则向上 const needUpward = availableSpaceBelow < maxHeight console.log('🚀 ~ needUpward:', needUpward) if (needUpward) { // 向上展开:弹窗底部贴住触发元素顶部 finalTop = rect.top finalTransform = 'translateY(-100%)' } else { // 向下展开:弹窗顶部紧贴触发元素底部 finalTop = rect.bottom finalTransform = '' } // 水平右对齐 finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) finalLeft = 0 } // 更新位置 top.value = finalTop left.value = finalLeft position.value = finalTransform visible.value = true emit('open', props.modelValue, props.trigger) nextTick(() => { bindOutsideClickListener() bindScrollListener() }) }向下的可用距离都是负数了
最新发布
11-14
<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="{ top: `${top}px`, right: `${right}px`, transform: positionStyle?positionStyle:position, maxHeight: `${props.maxHeight}px` }" @touchmove.stop > <view class="dropdown-content"> <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> </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: { // 选中时候的icon type: String, default: 'checkmarkempty' }, scrollClose: { // 页面滚动是否关闭已打开组件 type: Boolean, default: true }, maxHeight: { // 展开组件的最大高度(仅传入数字即可,需要进行计算,单位是px) type: Number, default: 486 }, positionStyle: { // 展开组件的position样式 type: String, default: '' } }) // ---------------------- // 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('translateY(8px)') const dropdownRef = ref(null) let observer = null // ---------------------- // 获取触发器位置(核心方法) function getTriggerRect() { return new Promise(resolve => { // console.log('🚀 ~ getTriggerRect ~ props.trigger:', props.trigger) uni .createSelectorQuery() .select('#' + props.trigger) .boundingClientRect(rect => { if (rect) { // console.log('✅ 定位成功:', rect) resolve(rect) } else { // console.error('❌ 查询失败,请确认 ID 是否正确或已渲染') resolve(null) } }) .exec() }) } // ---------------------- // 打开下拉框 // ---------------------- const open = async () => { if (visible.value) { close() return } const rect = await getTriggerRect() if (!rect) return doOpen(rect) // 抽离打开逻辑 } const doOpen = rect => { // 获取设备系统信息(同步) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) // 可视窗口高度(单位 px) const windowWidth = Number(res.windowWidth) // 可视窗口宽度(单位 px) const maxHeight = props.maxHeight // 弹窗最大高度(CSS 中设定的最大高度) const menuWidth = 215 // 弹窗固定宽度,必须与 CSS 一致(避免计算偏差) const rightGap = 12 // 弹窗右侧留白距离(安全边距) console.log('【doOpen】触发打开菜单', { res, rect, windowHeight, windowWidth }) // 计算触发元素上下方可用空间 const spaceBelow = windowHeight - rect.bottom // 下方剩余空间 const spaceAbove = rect.top // 上方剩余空间 // 判断上下空间是否都不足以放下弹窗(maxHeight) const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【空间判断】', { spaceAbove, // 上方还有少空间 spaceBelow, // 下方还有少空间 maxHeight, // 需要的高度 isBothDirectionsInsufficient: isBothDirectionsInsufficient // 是否上下都不够 }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下空间都不够 → 居中显示 // ======================== // 🔹 Y 轴定位:将弹窗垂直居中幕中央 finalTop = windowHeight / 2 // 🔹 使用 transform 实现真正的居中(X 和 Y 方向都偏移自身尺寸的 50%) finalTransform = 'translate(0, -50%)' // 🔹 X 轴定位:右对齐,距离右侧边界 12px finalLeft = windowWidth - menuWidth - rightGap // ⚠️ 安全检查:防止弹窗左侧溢出幕(比如小设备) if (finalLeft < 0) { console.warn('【警告】弹窗左溢,修正为 0') finalLeft = 0 } console.log('【居中模式】启用幕中心定位', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:至少有一侧空间足够 → 正常弹出(向上或向下) // ======================== // 根据下方空间是否足够决定方向 const needUpward = spaceBelow < maxHeight // 下方不够就往上弹 if (needUpward) { // 向上展开:弹窗底部对齐触发元素顶部,再留 8px 间隙 finalTop = rect.top - maxHeight - 8 finalTransform = 'translateY(8px)' // 微调:让动画有“出现”感(可选) } else { // 向下展开:弹窗顶部对齐触发元素底部,再留 8px 间隙 finalTop = rect.bottom + 8 finalTransform = 'translateY(-8px)' // 微调偏移 } // 🔹 水平方向统一右对齐(距离右边 12px) finalLeft = '12px' console.log('【常规模式】根据方向定位', { direction: needUpward ? '向上弹出' : '向下弹出', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式数据,驱动 UI 更新 top.value = finalTop left.value = finalLeft // right.value = finalLeft position.value = finalTransform console.log('✅ 【最终定位结果】已设置:', { top: top.value, left: left.value, // right: right.value, transform: position.value }) // 显示弹窗 visible.value = true // 触发 open 事件,携带当前 modelValue emit('open', props.modelValue, props.trigger) // 在下一次 DOM 更新后绑定外部点击和滚动监听 nextTick(() => { console.log('🔧 执行 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] // 动态构造返回对象,key 来自 props const selected = { [props.valueKey]: value, [props.labelKey]: label } // 检查 modelValue 是否变化 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?.dataset?.dropdownTrigger) if (!isInside) close() } uni.$on('onPageTap', handler) observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) } } // ---------------------- // 页面滚动关闭 // ---------------------- const bindScrollListener = () => { const scrollHandler = res => { if (!visible.value) return // 滚动时重新定位 // repositionDebounced() console.log('🚀 ~ bindScrollListener ~ props.scrollClose:', props.scrollClose) if (props.scrollClose) return close() reposition() } uni.$on('onPageScroll', scrollHandler) observer = { ...observer, cleanupScroll: () => uni.$off('onPageScroll', scrollHandler) } } // 新增:重新计算并设置位置 // const reposition = async () => { // const rect = await getTriggerRect() // if (!rect) return // const res = uni.getSystemInfoSync() // const windowHeight = res.windowHeight // const maxHeight = props.maxHeight // const needUpward = windowHeight - rect.bottom < maxHeight // top.value = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 // position.value = needUpward ? 'translateY(-8px)' : 'translateY(8px)' // } const reposition = async () => { // 获取触发元素的边界信息 const rect = await getTriggerRect() if (!rect) { console.warn('【reposition】无法获取触发元素位置,跳过重定位') return } console.log('【reposition】开始重新定位', { rect }) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) const windowWidth = Number(res.windowWidth) const maxHeight = props.maxHeight // 弹窗最大高度 const menuWidth = 215 // 必须与 CSS 一致 const rightGap = 12 // 右侧边距 // 计算上下可用空间 const spaceBelow = windowHeight - rect.bottom const spaceAbove = rect.top const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【reposition - 空间判断】', { spaceAbove, spaceBelow, maxHeight, isBothDirectionsInsufficient }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下都不够 → 居中 + 右对齐 // ======================== finalTop = windowHeight / 2 finalTransform = 'translate(0, -50%)' // Y轴居中,X不偏移(left 已经精确设置) finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】左溢修正', finalLeft) finalLeft = 0 } console.log('【reposition - 居中模式】', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:正常方向展开 // ======================== const needUpward = spaceBelow < maxHeight finalTop = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 finalTransform = needUpward ? 'translateY(-8px)' : 'translateY(8px)' finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】右对齐导致左溢,修正为 0') finalLeft = 0 } console.log('【reposition - 常规模式】', { direction: needUpward ? '向上' : '向下', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式变量 top.value = finalTop left.value = finalLeft position.value = finalTransform console.log('✅ 【reposition 成功】更新定位:', { top: top.value, left: left.value, transform: position.value }) } const debounce = (fn, time = 10) => { let timer = null return function (...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); } /* 内容区可滚动 */ .dropdown-content { flex: 1; height: 0; /* 防止撑开 */ max-height: 486px; overflow-y: auto; -webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */ } /* 每一项 */ .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; /* 可替换为 iconfont 字体 */ font-size: 16px; margin-left: 8px; color: #0f56d5; } </style> 修改当前组件,让滚动区域使用,确保复用当前组件样式,返回我完整的代码
11-01
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值