FrontTools/OverlayPage/OverlayPage.vue

327 lines
8.0 KiB
Vue
Raw Permalink Normal View History

<script setup lang="ts">
import { ref, watch, inject, onUnmounted } from 'vue'
// ══════ 全局状态管理 ══════
let _zSeed = 1000
let _activeCount = 0
// 记录所有活动的 overlay 的 z-index用于判断最顶层
const _activeZIndices = new Set<number>()
const props = withDefaults(defineProps<{
visible: boolean
title?: string
noHeader?: boolean
/** 点击标题时的回调,传入后标题可点击 */
titleClick?: () => void
/** 为 true 时:默认槽在首次打开后才挂载,关闭后保留 DOM减少未打开 overlay 的重内容渲染 */
lazy?: boolean
}>(), {
title: '',
noHeader: false,
lazy: false
})
const emit = defineEmits<{
/** 打开时触发(当 visible 变为 true 时) */
open: []
/** 关闭时触发(用户主动关闭时,如点击关闭按钮、返回键等) */
close: []
/** 关闭完成时触发(当 visible 变为 false 时,用于生命周期监听) */
closed: []
}>()
// ══════ 层级 Z-Index新打开的永远在上层 ══════
const zIndex = ref(_zSeed)
// ══════ 状态栏高度 ══════
const statusBarHeight = ref(0)
// ══════ 返回键栈管理 ══════
const tabRootBackPress = inject<{
register: (h: { check: () => boolean; close: () => void }) => () => void
}>('tabRootBackPress', undefined as any)
let unreg: (() => void) | null = null
// ══════ 内部状态 ══════
const isTracked = ref(false)
/** lazy 模式下,首次打开后为 true用于保持 slot 挂载避免重复创建 */
const hasOpenedOnce = ref(false)
/** 关闭动画定时器,用于延迟触发 closed 事件 */
let closeTimer: ReturnType<typeof setTimeout> | null = null
// ══════ 工具函数 ══════
/**
* 获取状态栏高度
*/
const getStatusBarHeight = (): number => {
try {
return uni.getSystemInfoSync().statusBarHeight ?? 0
} catch {
return 0
}
}
/**
* 注册到动态页面计数
*/
const registerActive = () => {
if (!isTracked.value) {
isTracked.value = true
_activeCount++
_activeZIndices.add(zIndex.value)
}
}
/**
* 从动态页面计数中注销
*/
const unregisterActive = () => {
if (isTracked.value) {
isTracked.value = false
_activeZIndices.delete(zIndex.value)
if (--_activeCount <= 0) {
_activeCount = 0
_zSeed = 1000
_activeZIndices.clear()
}
}
}
/**
* 获取当前最大的 z-index
*/
const getMaxZIndex = (): number => {
if (_activeZIndices.size === 0) return 0
return Math.max(...Array.from(_activeZIndices))
}
/**
* 注册返回键处理
* 只有当当前 overlay 可见且是最顶层z-index 最高时才响应返回键
*/
const registerBackPress = () => {
if (tabRootBackPress) {
unreg = tabRootBackPress.register({
check: () => {
// 只有当前 overlay 可见且是最顶层时才返回 true
if (!props.visible) return false;
// 检查是否是最顶层:当前 z-index 应该等于所有活动 overlay 中的最大 z-index
const maxZ = getMaxZIndex()
return zIndex.value === maxZ && maxZ > 0
},
close: handleClose
})
}
}
/**
* 注销返回键处理
*/
const unregisterBackPress = () => {
if (unreg) {
unreg()
unreg = null
}
}
/**
* 处理打开逻辑
*/
const handleOpen = () => {
// 分配新层级
zIndex.value = ++_zSeed
// 注册层级
registerActive()
// 获取状态栏高度
statusBarHeight.value = getStatusBarHeight()
// 注册返回键
registerBackPress()
// 触发生命周期事件
emit('open')
}
/**
* 处理关闭逻辑
*/
const handleClose = () => {
emit('close')
}
/**
* 处理关闭后的清理
* 延迟触发 closed 事件等待退出动画完成
*/
const handleCloseCleanup = () => {
// 注销返回键
unregisterBackPress()
// 注销活动计数
unregisterActive()
// 清除之前的定时器(如果存在)
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
// 延迟触发 closed 事件,等待动画完成
closeTimer = setTimeout(() => {
closeTimer = null
emit('closed')
}, 600)
}
// ══════ 监听 visible 变化,管理生命周期 ══════
watch(() => props.visible, (newVal, oldVal) => {
if (newVal === oldVal) return
if (newVal) {
// 如果正在关闭动画中,取消关闭定时器
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (props.lazy) hasOpenedOnce.value = true
handleOpen()
} else {
handleCloseCleanup()
}
})
// ══════ 组件卸载时的清理 ══════
onUnmounted(() => {
unregisterBackPress()
unregisterActive()
// 清除关闭动画定时器
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
})
// ══════ 暴露给模板的方法 ══════
const close = handleClose
</script>
<template>
<view class="ov-page" :class="{ 'ov-page--visible': visible }" :style="{ zIndex }">
<!-- 遮罩: 屏蔽下层触摸 -->
<view
v-show="visible"
class="ov-page__backdrop"
:class="{ 'ov-page__backdrop--visible': visible }"
@touchmove.stop.prevent
@touchstart.stop
@touchend.stop
/>
<!-- 滑入面板 -->
<view class="ov-page__panel" :class="{ 'ov-page__panel--in': visible }">
<!-- 顶栏 (noHeader 关闭 / #header 完全替换 / #header-right 自定义右侧) -->
<template v-if="!noHeader">
<slot name="header" :close="close" :statusBarHeight="statusBarHeight">
<view class="ov-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="ov-header__inner">
<view class="ov-header__back" @click="close">
<wd-icon name="thin-arrow-left" size="32rpx" color="var(--dv-text)" />
</view>
<view
v-if="titleClick"
class="ov-header__title ov-header__title--clickable"
@click="titleClick"
>{{ title }}</view>
<text v-else class="ov-header__title">{{ title }}</text>
<view class="ov-header__right">
<slot name="header-right" />
</view>
</view>
</view>
</slot>
</template>
<!-- 主内容lazy 时首次打开后才挂载减少未打开 overlay 的重内容 -->
<template v-if="!lazy || visible || hasOpenedOnce">
<slot />
</template>
</view>
<!-- 额外内容插槽用于放置嵌套 overlay避免 transform 影响 fixed 定位 -->
<slot name="extra" />
</view>
</template>
<style lang="scss" scoped>
.ov-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.ov-page--visible { pointer-events: auto; }
.ov-page__backdrop {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0;
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
}
.ov-page__backdrop--visible {
opacity: 1;
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
}
.ov-page__panel {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
background: var(--dv-bg);
transform: translateX(100%);
opacity: 0;
filter: blur(20rpx);
box-shadow: -8rpx 0 24rpx rgba(0, 0, 0, 0.15);
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .6s cubic-bezier(.22, .61, .36, 1), filter .6s cubic-bezier(.22, .61, .36, 1);
will-change: transform, opacity, filter;
}
.ov-page__panel--in {
transform: translateX(0);
opacity: 1;
filter: blur(0);
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .25s cubic-bezier(.22, .61, .36, 1), filter .25s cubic-bezier(.22, .61, .36, 1);
}
.ov-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--dv-bg);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.ov-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.ov-header__back {
width: 72rpx;
display: flex;
align-items: center;
padding: 8rpx;
}
.ov-header__title {
flex: 1;
text-align: center;
font-size: var(--dv-font-title);
font-weight: 600;
color: var(--dv-text);
}
.ov-header__title--clickable {
cursor: pointer;
}
.ov-header__right {
width: 88rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>