| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645 |
- <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:
- 'absolute right-[24rpx] text-surface-400 dark: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, 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>
|