cl-popup.uvue 11 KB

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