cl-popup.uvue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. <template>
  2. <view
  3. class="cl-popup-wrapper"
  4. :class="[`cl-popup-wrapper--${direction}`]"
  5. :style="{
  6. zIndex,
  7. pointerEvents
  8. }"
  9. v-show="visible"
  10. v-if="keepAlive ? true : visible"
  11. @touchmove.stop.prevent
  12. >
  13. <view
  14. class="cl-popup-mask"
  15. :class="[
  16. {
  17. 'is-open': status == 1,
  18. 'is-close': status == 2
  19. },
  20. pt.mask?.className
  21. ]"
  22. @tap="maskClose"
  23. v-if="showMask"
  24. ></view>
  25. <view
  26. class="cl-popup"
  27. :class="[
  28. {
  29. 'is-open': status == 1,
  30. 'is-close': status == 2,
  31. 'is-custom-navbar': router.isCustomNavbarPage(),
  32. 'stop-transition': swipe.isTouch
  33. },
  34. pt.className
  35. ]"
  36. :style="popupStyle"
  37. @touchstart="onTouchStart"
  38. @touchmove="onTouchMove"
  39. @touchend="onTouchEnd"
  40. >
  41. <view
  42. class="cl-popup__inner"
  43. :class="[
  44. {
  45. 'is-dark': isDark
  46. },
  47. pt.inner?.className
  48. ]"
  49. :style="{
  50. paddingBottom
  51. }"
  52. >
  53. <view
  54. class="cl-popup__draw"
  55. :class="[
  56. {
  57. '!bg-surface-400': swipe.isMove
  58. },
  59. pt.draw?.className
  60. ]"
  61. v-if="isSwipeClose"
  62. ></view>
  63. <view class="cl-popup__header" :class="[pt.header?.className]" v-if="showHeader">
  64. <slot name="header">
  65. <cl-text
  66. :pt="{
  67. className: `text-lg font-bold ${pt.header?.text?.className}`
  68. }"
  69. >{{ title }}</cl-text
  70. >
  71. </slot>
  72. <cl-icon
  73. name="close-circle-fill"
  74. :size="40"
  75. :pt="{
  76. className:
  77. 'absolute right-[24rpx] !text-surface-400 dark:!text-surface-50'
  78. }"
  79. @tap="close"
  80. v-if="isOpen && showClose"
  81. ></cl-icon>
  82. </view>
  83. <view class="cl-popup__container" :class="[pt.container?.className]">
  84. <slot></slot>
  85. </view>
  86. </view>
  87. </view>
  88. </view>
  89. </template>
  90. <script lang="ts" setup>
  91. import { computed, reactive, ref, watch, type PropType } from "vue";
  92. import { parsePt, parseRpx, usePage } from "@/cool";
  93. import type { ClPopupDirection, PassThroughProps } from "../../types";
  94. import { isDark, router } from "@/cool";
  95. import { config } from "../../config";
  96. defineOptions({
  97. name: "cl-popup"
  98. });
  99. defineSlots<{
  100. header: () => any;
  101. }>();
  102. // 组件属性定义
  103. const props = defineProps({
  104. // 透传样式配置
  105. pt: {
  106. type: Object,
  107. default: () => ({})
  108. },
  109. // 是否可见
  110. modelValue: {
  111. type: Boolean,
  112. default: false
  113. },
  114. // 标题
  115. title: {
  116. type: String,
  117. default: null
  118. },
  119. // 弹出方向
  120. direction: {
  121. type: String as PropType<ClPopupDirection>,
  122. default: "bottom"
  123. },
  124. // 弹出框宽度
  125. size: {
  126. type: [String, Number],
  127. default: ""
  128. },
  129. // 是否显示头部
  130. showHeader: {
  131. type: Boolean,
  132. default: true
  133. },
  134. // 显示关闭按钮
  135. showClose: {
  136. type: Boolean,
  137. default: true
  138. },
  139. // 是否显示遮罩层
  140. showMask: {
  141. type: Boolean,
  142. default: true
  143. },
  144. // 是否点击遮罩层关闭弹窗
  145. maskClosable: {
  146. type: Boolean,
  147. default: true
  148. },
  149. // 是否开启拖拽关闭
  150. swipeClose: {
  151. type: Boolean,
  152. default: true
  153. },
  154. // 拖拽关闭的阈值
  155. swipeCloseThreshold: {
  156. type: Number,
  157. default: 150
  158. },
  159. // 触摸事件响应方式
  160. pointerEvents: {
  161. type: String as PropType<"auto" | "none">,
  162. default: "auto"
  163. },
  164. // 是否开启缓存
  165. keepAlive: {
  166. type: Boolean,
  167. default: false
  168. }
  169. });
  170. // 定义组件事件
  171. const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
  172. // 页面实例
  173. const page = usePage();
  174. type HeaderPassThrough = {
  175. className?: string;
  176. text?: PassThroughProps;
  177. };
  178. // 透传样式类型定义
  179. type PassThrough = {
  180. className?: string;
  181. inner?: PassThroughProps;
  182. header?: HeaderPassThrough;
  183. container?: PassThroughProps;
  184. mask?: PassThroughProps;
  185. draw?: PassThroughProps;
  186. };
  187. // 解析透传样式配置
  188. const pt = computed(() => parsePt<PassThrough>(props.pt));
  189. // 控制弹出层显示/隐藏
  190. const visible = ref(false);
  191. // 0: 初始状态 1: 打开中 2: 关闭中
  192. const status = ref(0);
  193. // 标记弹出层是否处于打开状态(包含动画过程)
  194. const isOpen = ref(false);
  195. // 标记弹出层是否已完全打开(动画结束)
  196. const isOpened = ref(false);
  197. // 弹出层z-index值
  198. const zIndex = ref(config.zIndex);
  199. // 计算弹出层高度
  200. const height = computed(() => {
  201. switch (props.direction) {
  202. case "top":
  203. case "bottom":
  204. return parseRpx(props.size); // 顶部和底部弹出时使用传入的size
  205. case "left":
  206. case "right":
  207. return "100%"; // 左右弹出时占满全高
  208. default:
  209. return "";
  210. }
  211. });
  212. // 计算弹出层宽度
  213. const width = computed(() => {
  214. switch (props.direction) {
  215. case "top":
  216. case "bottom":
  217. return "100%"; // 顶部和底部弹出时占满全宽
  218. case "left":
  219. case "right":
  220. case "center":
  221. return parseRpx(props.size); // 其他方向使用传入的size
  222. default:
  223. return "";
  224. }
  225. });
  226. // 底部安全距离
  227. const paddingBottom = computed(() => {
  228. let h = 0;
  229. if (props.direction == "bottom") {
  230. h += page.hasCustomTabBar() ? page.getTabBarHeight() : page.getSafeAreaHeight("bottom");
  231. }
  232. return h + "px";
  233. });
  234. // 是否显示拖动条
  235. const isSwipeClose = computed(() => {
  236. return props.direction == "bottom" && props.swipeClose;
  237. });
  238. // 动画定时器
  239. let timer: number = 0;
  240. // 打开弹出层
  241. function open() {
  242. // 递增z-index,保证多个弹出层次序
  243. zIndex.value = config.zIndex++;
  244. if (!visible.value) {
  245. // 显示弹出层
  246. visible.value = true;
  247. // 触发事件
  248. emit("update:modelValue", true);
  249. emit("open");
  250. // 等待DOM更新后开始动画
  251. setTimeout(() => {
  252. // 设置打开状态
  253. status.value = 1;
  254. // 动画结束后触发opened事件
  255. // @ts-ignore
  256. timer = setTimeout(() => {
  257. isOpened.value = true;
  258. emit("opened");
  259. }, 350);
  260. }, 50);
  261. }
  262. }
  263. // 关闭弹出层
  264. function close() {
  265. if (status.value == 1) {
  266. // 重置打开状态
  267. isOpened.value = false;
  268. // 设置关闭状态
  269. status.value = 2;
  270. // 触发事件
  271. emit("close");
  272. // 清除未完成的定时器
  273. if (timer != 0) {
  274. clearTimeout(timer);
  275. }
  276. // 动画结束后隐藏弹出层
  277. // @ts-ignore
  278. timer = setTimeout(() => {
  279. // 隐藏弹出层
  280. visible.value = false;
  281. // 重置状态
  282. status.value = 0;
  283. // 触发事件
  284. emit("update:modelValue", false);
  285. emit("closed");
  286. }, 350);
  287. }
  288. }
  289. // 点击遮罩层关闭
  290. function maskClose() {
  291. if (props.maskClosable) {
  292. close();
  293. }
  294. emit("maskClose");
  295. }
  296. // 滑动状态类型定义
  297. type Swipe = {
  298. isMove: boolean; // 是否移动
  299. isTouch: boolean; // 是否处于触摸状态
  300. startY: number; // 开始触摸的Y坐标
  301. offsetY: number; // Y轴偏移量
  302. };
  303. // 初始化滑动状态数据
  304. const swipe = reactive<Swipe>({
  305. isMove: false, // 是否移动
  306. isTouch: false, // 默认非触摸状态
  307. startY: 0, // 初始Y坐标为0
  308. offsetY: 0 // 初始偏移量为0
  309. });
  310. /**
  311. * 触摸开始事件处理
  312. * @param e 触摸事件对象
  313. * 当弹出层获得焦点且允许滑动关闭时,记录触摸起始位置
  314. */
  315. function onTouchStart(e: UniTouchEvent) {
  316. if (isOpened.value && isSwipeClose.value) {
  317. swipe.isTouch = true; // 标记开始触摸
  318. swipe.startY = e.touches[0].clientY; // 记录起始Y坐标
  319. }
  320. }
  321. /**
  322. * 触摸移动事件处理
  323. * @param e 触摸事件对象
  324. * 计算手指移动距离,更新弹出层位置
  325. */
  326. function onTouchMove(e: UniTouchEvent) {
  327. if (swipe.isTouch) {
  328. // 标记为移动状态
  329. swipe.isMove = true;
  330. // 计算Y轴偏移量
  331. const offsetY = (e.touches[0] as UniTouch).pageY - swipe.startY;
  332. // 只允许向下滑动(offsetY > 0)
  333. if (offsetY > 0) {
  334. swipe.offsetY = offsetY;
  335. }
  336. }
  337. }
  338. /**
  339. * 触摸结束事件处理
  340. * 根据滑动距离判断是否关闭弹出层
  341. */
  342. function onTouchEnd() {
  343. if (swipe.isTouch) {
  344. // 结束触摸状态
  345. swipe.isTouch = false;
  346. // 结束移动状态
  347. swipe.isMove = false;
  348. // 如果滑动距离超过阈值,则关闭弹出层
  349. if (swipe.offsetY > props.swipeCloseThreshold) {
  350. close();
  351. }
  352. // 重置偏移量
  353. swipe.offsetY = 0;
  354. }
  355. }
  356. /**
  357. * 计算弹出层样式
  358. * 根据滑动状态动态设置transform属性实现位移动画
  359. */
  360. const popupStyle = computed(() => {
  361. const style = new Map<string, string>();
  362. // 基础样式
  363. style.set("height", height.value);
  364. style.set("width", width.value);
  365. // 处于触摸状态时添加位移效果
  366. if (swipe.isTouch) {
  367. style.set("transform", `translateY(${swipe.offsetY}px)`);
  368. }
  369. return style;
  370. });
  371. // 监听modelValue变化
  372. watch(
  373. computed(() => props.modelValue),
  374. (val: boolean) => {
  375. if (val) {
  376. open();
  377. } else {
  378. close();
  379. }
  380. },
  381. {
  382. immediate: true
  383. }
  384. );
  385. // 监听状态变化
  386. watch(status, (val: number) => {
  387. isOpen.value = val == 1;
  388. });
  389. defineExpose({
  390. isOpened,
  391. isOpen,
  392. open,
  393. close
  394. });
  395. </script>
  396. <style lang="scss" scoped>
  397. .cl-popup-wrapper {
  398. @apply h-full w-full;
  399. @apply fixed top-0 bottom-0 left-0 right-0;
  400. pointer-events: none;
  401. .cl-popup-mask {
  402. @apply absolute top-0 bottom-0 left-0 right-0;
  403. @apply h-full w-full;
  404. @apply bg-black opacity-0;
  405. transition-property: opacity;
  406. &.is-open {
  407. @apply opacity-40;
  408. }
  409. &.is-open,
  410. &.is-close {
  411. transition-duration: 0.3s;
  412. }
  413. }
  414. .cl-popup {
  415. @apply absolute;
  416. &__inner {
  417. @apply bg-white h-full w-full flex flex-col;
  418. &.is-dark {
  419. @apply bg-surface-700;
  420. }
  421. }
  422. &__draw {
  423. @apply bg-surface-200 rounded-md;
  424. @apply absolute top-2 left-1/2;
  425. height: 10rpx;
  426. width: 70rpx;
  427. transform: translateX(-50%);
  428. transition-property: background-color;
  429. transition-duration: 0.2s;
  430. }
  431. &__header {
  432. @apply flex flex-row items-center;
  433. height: 90rpx;
  434. padding: 0 26rpx;
  435. }
  436. &__container {
  437. flex: 1;
  438. }
  439. &.is-open {
  440. transform: translate(0, 0) scale(1);
  441. opacity: 1;
  442. }
  443. &.is-open,
  444. &.is-close {
  445. transition-property: transform, opacity;
  446. transition-duration: 0.3s;
  447. }
  448. &.stop-transition {
  449. transition-property: none;
  450. }
  451. }
  452. &--left {
  453. .cl-popup {
  454. @apply left-0 top-0;
  455. transform: translateX(-100%);
  456. }
  457. }
  458. &--right {
  459. .cl-popup {
  460. @apply right-0 top-0;
  461. transform: translateX(100%);
  462. }
  463. }
  464. &--top {
  465. .cl-popup {
  466. @apply left-0 top-0;
  467. &__inner {
  468. @apply rounded-b-2xl;
  469. }
  470. transform: translateY(-100%);
  471. }
  472. }
  473. &--left,
  474. &--right,
  475. &--top {
  476. .cl-popup {
  477. // #ifdef H5
  478. top: 44px;
  479. // #endif
  480. &.is-custom-navbar {
  481. top: 0;
  482. }
  483. }
  484. }
  485. &--left,
  486. &--right {
  487. .cl-popup {
  488. // #ifdef H5
  489. height: calc(100% - 44px) !important;
  490. // #endif
  491. }
  492. }
  493. &--bottom {
  494. .cl-popup {
  495. @apply left-0 bottom-0;
  496. transform: translateY(100%);
  497. &__inner {
  498. @apply rounded-t-2xl;
  499. }
  500. }
  501. }
  502. &--center {
  503. @apply flex flex-col items-center justify-center;
  504. .cl-popup {
  505. transform: scale(1.3);
  506. opacity: 0;
  507. &__inner {
  508. @apply rounded-2xl;
  509. }
  510. }
  511. }
  512. }
  513. </style>