cl-popup.uvue 11 KB

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