cl-popup.uvue 11 KB

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