Files
WAI_Project_UNIX/uni_modules/cool-ui/components/cl-popup/cl-popup.uvue

648 lines
12 KiB
Plaintext

<template>
<!-- #ifdef H5 -->
<teleport to="uni-app" :disabled="!enablePortal">
<!-- #endif -->
<!-- #ifdef MP -->
<root-portal :enable="enablePortal">
<!-- #endif -->
<view
class="cl-popup-wrapper"
:class="[`cl-popup-wrapper--${direction}`]"
:style="{
zIndex,
pointerEvents
}"
v-show="visible"
v-if="keepAlive ? true : visible"
@touchmove.stop.prevent
>
<view
class="cl-popup-mask"
:class="[
{
'is-open': status == 1,
'is-close': status == 2
},
pt.mask?.className
]"
@tap="maskClose"
v-if="showMask"
></view>
<view
class="cl-popup"
:class="[
{
'is-open': status == 1,
'is-close': status == 2,
'is-custom-navbar': router.isCustomNavbarPage(),
'stop-transition': swipe.isTouch
},
pt.className
]"
:style="popupStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view
class="cl-popup__inner"
:class="[
{
'is-dark': isDark
},
pt.inner?.className
]"
:style="{
paddingBottom
}"
>
<view
class="cl-popup__draw"
:class="[
{
'!bg-surface-400': swipe.isMove
},
pt.draw?.className
]"
v-if="isSwipeClose"
></view>
<view
class="cl-popup__header"
:class="[pt.header?.className]"
v-if="showHeader"
>
<slot name="header">
<cl-text
ellipsis
:pt="{
className: `text-lg font-bold ${pt.header?.text?.className}`
}"
>{{ title }}</cl-text
>
</slot>
<cl-icon
name="close-circle-fill"
:size="40"
:pt="{
className: parseClass([
'absolute right-[24rpx] text-surface-400',
[isDark, 'text-surface-50']
])
}"
@tap="close"
@touchmove.stop
v-if="isOpen && showClose"
></cl-icon>
</view>
<view
class="cl-popup__container"
:class="[pt.container?.className]"
@touchmove.stop
>
<slot></slot>
</view>
</view>
</view>
</view>
<!-- #ifdef MP -->
</root-portal>
<!-- #endif -->
<!-- #ifdef H5 -->
</teleport>
<!-- #endif -->
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, type PropType } from "vue";
import { getSafeAreaHeight, isAppIOS, parseClass, parsePt, parseRpx } from "@/cool";
import type { ClPopupDirection, PassThroughProps } from "../../types";
import { isDark, router } from "@/cool";
import { config } from "../../config";
defineOptions({
name: "cl-popup"
});
defineSlots<{
header: () => any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 是否可见
modelValue: {
type: Boolean,
default: false
},
// 标题
title: {
type: String,
default: null
},
// 弹出方向
direction: {
type: String as PropType<ClPopupDirection>,
default: "bottom"
},
// 弹出框宽度
size: {
type: [String, Number],
default: ""
},
// 是否显示头部
showHeader: {
type: Boolean,
default: true
},
// 显示关闭按钮
showClose: {
type: Boolean,
default: true
},
// 是否显示遮罩层
showMask: {
type: Boolean,
default: true
},
// 是否点击遮罩层关闭弹窗
maskClosable: {
type: Boolean,
default: true
},
// 是否开启拖拽关闭
swipeClose: {
type: Boolean,
default: true
},
// 拖拽关闭的阈值
swipeCloseThreshold: {
type: Number,
default: 150
},
// 触摸事件响应方式
pointerEvents: {
type: String as PropType<"auto" | "none">,
default: "auto"
},
// 是否开启缓存
keepAlive: {
type: Boolean,
default: false
},
// 是否启用 portal
enablePortal: {
type: Boolean,
default: true
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
// 透传样式类型定义
type HeaderPassThrough = {
className?: string;
text?: PassThroughProps;
};
type PassThrough = {
className?: string;
inner?: PassThroughProps;
header?: HeaderPassThrough;
container?: PassThroughProps;
mask?: PassThroughProps;
draw?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 控制弹出层显示/隐藏
const visible = ref(false);
// 0: 初始状态 1: 打开中 2: 关闭中
const status = ref(0);
// 标记弹出层是否处于打开状态(包含动画过程)
const isOpen = ref(false);
// 标记弹出层是否已完全打开(动画结束)
const isOpened = ref(false);
// 弹出层z-index值
const zIndex = ref(config.zIndex);
// 计算弹出层高度
const height = computed(() => {
switch (props.direction) {
case "top":
case "bottom":
return parseRpx(props.size); // 顶部和底部弹出时使用传入的size
case "left":
case "right":
return "100%"; // 左右弹出时占满全高
default:
return "";
}
});
// 计算弹出层宽度
const width = computed(() => {
switch (props.direction) {
case "top":
case "bottom":
return "100%"; // 顶部和底部弹出时占满全宽
case "left":
case "right":
case "center":
return parseRpx(props.size); // 其他方向使用传入的size
default:
return "";
}
});
// 底部安全距离
const paddingBottom = computed(() => {
let h = 0;
if (props.direction == "bottom") {
h += getSafeAreaHeight("bottom");
}
return h + "px";
});
// 是否显示拖动条
const isSwipeClose = computed(() => {
return props.direction == "bottom" && props.swipeClose;
});
// 动画定时器
let timer: number = 0;
// 打开弹出层
function open() {
// 递增z-index,保证多个弹出层次序
zIndex.value = config.zIndex++;
if (!visible.value) {
// 显示弹出层
visible.value = true;
// 触发事件
emit("update:modelValue", true);
emit("open");
// 等待DOM更新后开始动画
setTimeout(
() => {
// 设置打开状态
status.value = 1;
// 动画结束后触发opened事件
// @ts-ignore
timer = setTimeout(() => {
isOpened.value = true;
emit("opened");
}, 350);
},
isAppIOS() ? 100 : 50
);
}
}
// 关闭弹出层
function close() {
if (status.value == 1) {
// 重置打开状态
isOpened.value = false;
// 设置关闭状态
status.value = 2;
// 触发事件
emit("close");
// 清除未完成的定时器
if (timer != 0) {
clearTimeout(timer);
}
// 动画结束后隐藏弹出层
// @ts-ignore
timer = setTimeout(() => {
// 隐藏弹出层
visible.value = false;
// 重置状态
status.value = 0;
// 触发事件
emit("update:modelValue", false);
emit("closed");
}, 350);
}
}
// 点击遮罩层关闭
function maskClose() {
if (props.maskClosable) {
close();
}
emit("maskClose");
}
// 滑动状态类型定义
type Swipe = {
isMove: boolean; // 是否移动
isTouch: boolean; // 是否处于触摸状态
startY: number; // 开始触摸的Y坐标
offsetY: number; // Y轴偏移量
};
// 初始化滑动状态数据
const swipe = reactive<Swipe>({
isMove: false, // 是否移动
isTouch: false, // 默认非触摸状态
startY: 0, // 初始Y坐标为0
offsetY: 0 // 初始偏移量为0
});
/**
* 触摸开始事件处理
* @param e 触摸事件对象
* 当弹出层获得焦点且允许滑动关闭时,记录触摸起始位置
*/
function onTouchStart(e: UniTouchEvent) {
if (props.direction != "bottom") {
return;
}
if (isOpened.value && isSwipeClose.value) {
swipe.isTouch = true; // 标记开始触摸
swipe.startY = e.touches[0].clientY; // 记录起始Y坐标
}
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
* 计算手指移动距离,更新弹出层位置
*/
function onTouchMove(e: UniTouchEvent) {
if (swipe.isTouch) {
// 标记为移动状态
swipe.isMove = true;
// 计算Y轴偏移量
const offsetY = (e.touches[0] as UniTouch).pageY - swipe.startY;
// 只允许向下滑动(offsetY > 0)
if (offsetY > 0) {
swipe.offsetY = offsetY;
}
}
}
/**
* 触摸结束事件处理
* 根据滑动距离判断是否关闭弹出层
*/
function onTouchEnd() {
if (swipe.isTouch) {
// 结束触摸状态
swipe.isTouch = false;
// 结束移动状态
swipe.isMove = false;
// 如果滑动距离超过阈值,则关闭弹出层
if (swipe.offsetY > props.swipeCloseThreshold) {
close();
}
// 重置偏移量
swipe.offsetY = 0;
}
}
/**
* 计算弹出层样式
* 根据滑动状态动态设置transform属性实现位移动画
*/
const popupStyle = computed(() => {
const style = {};
// 基础样式
style["height"] = height.value;
style["width"] = width.value;
// 处于触摸状态时添加位移效果
if (swipe.isTouch) {
style["transform"] = `translateY(${swipe.offsetY}px)`;
}
return style;
});
// 监听modelValue变化
watch(
computed(() => props.modelValue),
(val: boolean) => {
if (val) {
open();
} else {
close();
}
},
{
immediate: true
}
);
// 监听状态变化
watch(status, (val: number) => {
isOpen.value = val == 1;
});
defineExpose({
isOpened,
isOpen,
open,
close
});
</script>
<style lang="scss" scoped>
.cl-popup-wrapper {
@apply h-full w-full;
@apply fixed top-0 bottom-0 left-0 right-0;
pointer-events: none;
.cl-popup-mask {
@apply absolute top-0 bottom-0 left-0 right-0;
@apply h-full w-full;
@apply bg-black opacity-0;
transition-property: opacity;
&.is-open {
@apply opacity-40;
}
&.is-open,
&.is-close {
transition-duration: 0.3s;
}
}
.cl-popup {
@apply absolute duration-300;
transition-property: transform;
&__inner {
@apply bg-white h-full w-full flex flex-col;
&.is-dark {
@apply bg-surface-700;
}
}
&__draw {
@apply bg-surface-200 rounded-md;
@apply absolute top-2 left-1/2;
height: 10rpx;
width: 70rpx;
transform: translateX(-50%);
transition-property: background-color;
transition-duration: 0.2s;
}
&__header {
@apply flex flex-row items-center flex-wrap;
height: 90rpx;
padding: 0 80rpx 0 26rpx;
}
&__container {
flex: 1;
}
&.stop-transition {
@apply transition-none;
}
}
&--left {
.cl-popup {
@apply left-0 top-0;
transform: translateX(-100%);
&.is-open {
transform: translateX(0);
}
}
}
&--right {
.cl-popup {
@apply right-0 top-0;
transform: translateX(100%);
&.is-open {
transform: translateX(0);
}
}
}
&--top {
.cl-popup {
@apply left-0 top-0;
transform: translateY(-100%);
.cl-popup__inner {
@apply rounded-b-2xl;
}
&.is-open {
transform: translateY(0);
}
}
}
&--left,
&--right,
&--top {
& > .cl-popup {
// #ifdef H5
top: 44px;
// #endif
&.is-custom-navbar {
top: 0;
}
}
}
&--left,
&--right {
& > .cl-popup {
// #ifdef H5
height: calc(100% - 44px) !important;
// #endif
}
}
&--bottom {
& > .cl-popup {
@apply left-0 bottom-0;
transform: translateY(100%);
.cl-popup__inner {
@apply rounded-t-2xl;
}
&.is-open {
transform: translateY(0);
}
&.is-close {
transform: translateY(100%);
}
}
}
&--center {
@apply flex flex-col items-center justify-center;
& > .cl-popup {
transform: scale(1.3);
opacity: 0;
transition-property: transform, opacity;
.cl-popup__inner {
@apply rounded-2xl;
}
&.is-open {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
}
}
</style>