cl-draggable.uvue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. <template>
  2. <view
  3. class="cl-draggable"
  4. :class="[
  5. {
  6. 'cl-draggable--columns': props.columns > 1
  7. },
  8. pt.className
  9. ]"
  10. >
  11. <!-- @vue-ignore -->
  12. <view
  13. v-for="(item, index) in list"
  14. :key="getItemKey(item, index)"
  15. class="cl-draggable__item"
  16. :class="[
  17. {
  18. 'cl-draggable__item--disabled': disabled,
  19. 'cl-draggable__item--dragging': dragging && dragIndex == index,
  20. 'cl-draggable__item--animating': dragging && dragIndex != index
  21. }
  22. ]"
  23. :style="getItemStyle(index)"
  24. @touchstart="
  25. (event: UniTouchEvent) => {
  26. onTouchStart(event, index, 'touch');
  27. }
  28. "
  29. @longpress="
  30. (event: UniTouchEvent) => {
  31. onTouchStart(event, index, 'longpress');
  32. }
  33. "
  34. @touchmove="onTouchMove"
  35. @touchend="onTouchEnd"
  36. >
  37. <slot
  38. name="item"
  39. :item="item"
  40. :index="index"
  41. :dragging="dragging"
  42. :dragIndex="dragIndex"
  43. :insertIndex="insertIndex"
  44. >
  45. </slot>
  46. </view>
  47. </view>
  48. </template>
  49. <script lang="ts" setup>
  50. import { computed, ref, getCurrentInstance, type PropType, watch } from "vue";
  51. import { isNull, parsePt, uuid } from "@/.cool";
  52. import type { PassThroughProps } from "../../types";
  53. defineOptions({
  54. name: "cl-draggable"
  55. });
  56. defineSlots<{
  57. item(props: {
  58. item: UTSJSONObject;
  59. index: number;
  60. dragging: boolean;
  61. dragIndex: number;
  62. insertIndex: number;
  63. }): any;
  64. }>();
  65. // 项目位置信息类型定义
  66. type ItemPosition = {
  67. top: number;
  68. left: number;
  69. width: number;
  70. height: number;
  71. };
  72. // 位移偏移量类型定义
  73. type TranslateOffset = {
  74. x: number;
  75. y: number;
  76. };
  77. const props = defineProps({
  78. /** PassThrough 样式配置 */
  79. pt: {
  80. type: Object,
  81. default: () => ({})
  82. },
  83. /** 数据数组,支持双向绑定 */
  84. modelValue: {
  85. type: Array as PropType<UTSJSONObject[]>,
  86. default: () => []
  87. },
  88. /** 是否禁用拖拽功能 */
  89. disabled: {
  90. type: Boolean,
  91. default: false
  92. },
  93. /** 列数:1为单列纵向布局,>1为多列网格布局 */
  94. columns: {
  95. type: Number,
  96. default: 1
  97. },
  98. // 是否需要长按触发
  99. longPress: {
  100. type: Boolean,
  101. default: true
  102. }
  103. });
  104. const emit = defineEmits(["update:modelValue", "change", "start", "end"]);
  105. const { proxy } = getCurrentInstance()!;
  106. // 透传样式类型定义
  107. type PassThrough = {
  108. className?: string;
  109. ghost?: PassThroughProps;
  110. };
  111. /** PassThrough 样式解析 */
  112. const pt = computed(() => parsePt<PassThrough>(props.pt));
  113. /** 数据列表 */
  114. const list = ref<UTSJSONObject[]>([]);
  115. /** 是否正在拖拽 */
  116. const dragging = ref(false);
  117. /** 当前拖拽元素的原始索引 */
  118. const dragIndex = ref(-1);
  119. /** 预期插入的目标索引 */
  120. const insertIndex = ref(-1);
  121. /** 触摸开始时的Y坐标 */
  122. const startY = ref(0);
  123. /** 触摸开始时的X坐标 */
  124. const startX = ref(0);
  125. /** Y轴偏移量 */
  126. const offsetY = ref(0);
  127. /** X轴偏移量 */
  128. const offsetX = ref(0);
  129. /** 当前拖拽的数据项 */
  130. const dragItem = ref<UTSJSONObject>({});
  131. /** 所有项目的位置信息缓存 */
  132. const itemPositions = ref<ItemPosition[]>([]);
  133. /** 是否处于放下动画状态 */
  134. const dropping = ref(false);
  135. /** 动态计算的项目高度 */
  136. const itemHeight = ref(0);
  137. /** 动态计算的项目宽度 */
  138. const itemWidth = ref(0);
  139. /** 是否已开始排序模拟(防止误触) */
  140. const sortingStarted = ref(false);
  141. /**
  142. * 重置所有拖拽相关的状态
  143. * 在拖拽结束后调用,确保组件回到初始状态
  144. */
  145. function reset() {
  146. dragging.value = false; // 拖拽状态
  147. dropping.value = false; // 放下动画状态
  148. dragIndex.value = -1; // 拖拽元素索引
  149. insertIndex.value = -1; // 插入位置索引
  150. offsetX.value = 0; // X轴偏移
  151. offsetY.value = 0; // Y轴偏移
  152. dragItem.value = {}; // 拖拽的数据项
  153. itemPositions.value = []; // 位置信息缓存
  154. itemHeight.value = 0; // 动态计算的高度
  155. itemWidth.value = 0; // 动态计算的宽度
  156. sortingStarted.value = false; // 排序模拟状态
  157. }
  158. /**
  159. * 计算网格布局中元素的位移偏移
  160. * @param index 当前元素索引
  161. * @param dragIdx 拖拽元素索引
  162. * @param insertIdx 插入位置索引
  163. * @returns 包含 x 和 y 坐标偏移的对象
  164. */
  165. function calculateGridOffset(index: number, dragIdx: number, insertIdx: number): TranslateOffset {
  166. const cols = props.columns;
  167. // 计算当前元素在网格中的行列位置
  168. const currentRow = Math.floor(index / cols);
  169. const currentCol = index % cols;
  170. // 计算元素在拖拽后的新位置索引
  171. let newIndex = index;
  172. if (dragIdx < insertIdx) {
  173. // 向后拖拽:dragIdx+1 到 insertIdx 之间的元素需要向前移动一位
  174. if (index > dragIdx && index <= insertIdx) {
  175. newIndex = index - 1;
  176. }
  177. } else if (dragIdx > insertIdx) {
  178. // 向前拖拽:insertIdx 到 dragIdx-1 之间的元素需要向后移动一位
  179. if (index >= insertIdx && index < dragIdx) {
  180. newIndex = index + 1;
  181. }
  182. }
  183. // 计算新位置的行列坐标
  184. const newRow = Math.floor(newIndex / cols);
  185. const newCol = newIndex % cols;
  186. // 使用动态计算的网格尺寸
  187. const cellWidth = itemWidth.value;
  188. const cellHeight = itemHeight.value;
  189. // 计算实际的像素位移
  190. const offsetX = (newCol - currentCol) * cellWidth;
  191. const offsetY = (newRow - currentRow) * cellHeight;
  192. return { x: offsetX, y: offsetY };
  193. }
  194. /**
  195. * 计算网格布局的插入位置
  196. * @param dragCenterX 拖拽元素中心点X坐标
  197. * @param dragCenterY 拖拽元素中心点Y坐标
  198. * @returns 最佳插入位置索引
  199. */
  200. function calculateGridInsertIndex(dragCenterX: number, dragCenterY: number): number {
  201. if (itemPositions.value.length == 0) {
  202. return dragIndex.value;
  203. }
  204. let closestIndex = dragIndex.value;
  205. let minDistance = Infinity;
  206. // 使用欧几里得距离找到最近的网格位置(包括原位置)
  207. for (let i = 0; i < itemPositions.value.length; i++) {
  208. const position = itemPositions.value[i];
  209. // 计算到元素中心点的距离
  210. const centerX = position.left + position.width / 2;
  211. const centerY = position.top + position.height / 2;
  212. // 使用欧几里得距离公式
  213. const distance = Math.sqrt(
  214. Math.pow(dragCenterX - centerX, 2) + Math.pow(dragCenterY - centerY, 2)
  215. );
  216. // 更新最近的位置
  217. if (distance < minDistance) {
  218. minDistance = distance;
  219. closestIndex = i;
  220. }
  221. }
  222. return closestIndex;
  223. }
  224. /**
  225. * 计算单列布局的插入位置
  226. * @param clientY Y坐标
  227. * @returns 最佳插入位置索引
  228. */
  229. function calculateSingleColumnInsertIndex(clientY: number): number {
  230. let closestIndex = dragIndex.value;
  231. let minDistance = Infinity;
  232. // 遍历所有元素,找到距离最近的元素中心
  233. for (let i = 0; i < itemPositions.value.length; i++) {
  234. const position = itemPositions.value[i];
  235. // 计算到元素中心点的距离
  236. const itemCenter = position.top + position.height / 2;
  237. const distance = Math.abs(clientY - itemCenter);
  238. if (distance < minDistance) {
  239. minDistance = distance;
  240. closestIndex = i;
  241. }
  242. }
  243. return closestIndex;
  244. }
  245. /**
  246. * 计算拖拽元素的最佳插入位置
  247. * @param clientPosition 在主轴上的坐标(仅用于单列布局的Y轴坐标)
  248. * @returns 最佳插入位置的索引
  249. */
  250. function calculateInsertIndex(clientPosition: number): number {
  251. // 如果没有位置信息,保持原位置
  252. if (itemPositions.value.length == 0) {
  253. return dragIndex.value;
  254. }
  255. // 根据布局类型选择计算方式
  256. if (props.columns > 1) {
  257. // 多列网格布局:计算拖拽元素的中心点坐标,使用2D坐标计算最近位置
  258. const dragPos = itemPositions.value[dragIndex.value];
  259. const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
  260. const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
  261. return calculateGridInsertIndex(dragCenterX, dragCenterY);
  262. } else {
  263. // 单列布局:基于Y轴距离计算最近的元素中心
  264. return calculateSingleColumnInsertIndex(clientPosition);
  265. }
  266. }
  267. /**
  268. * 计算单列布局的位移偏移
  269. * @param index 元素索引
  270. * @param dragIdx 拖拽元素索引
  271. * @param insertIdx 插入位置索引
  272. * @returns 位移偏移对象
  273. */
  274. function calculateSingleColumnOffset(
  275. index: number,
  276. dragIdx: number,
  277. insertIdx: number
  278. ): TranslateOffset {
  279. if (dragIdx < insertIdx) {
  280. // 向下拖拽:dragIdx+1 到 insertIdx 之间的元素向上移动
  281. if (index > dragIdx && index <= insertIdx) {
  282. return { x: 0, y: -itemHeight.value };
  283. }
  284. } else if (dragIdx > insertIdx) {
  285. // 向上拖拽:insertIdx 到 dragIdx-1 之间的元素向下移动
  286. if (index >= insertIdx && index < dragIdx) {
  287. return { x: 0, y: itemHeight.value };
  288. }
  289. }
  290. return { x: 0, y: 0 };
  291. }
  292. /**
  293. * 计算非拖拽元素的位移偏移量
  294. * @param index 元素索引
  295. * @returns 包含 x 和 y 坐标偏移的对象
  296. */
  297. function getItemTranslateOffset(index: number): TranslateOffset {
  298. // 只在满足所有条件时才计算位移:拖拽中、非放下状态、已开始排序
  299. if (!dragging.value || dropping.value || !sortingStarted.value) {
  300. return { x: 0, y: 0 };
  301. }
  302. const dragIdx = dragIndex.value;
  303. const insertIdx = insertIndex.value;
  304. // 跳过正在拖拽的元素(拖拽元素由位置控制)
  305. if (index == dragIdx) {
  306. return { x: 0, y: 0 };
  307. }
  308. // 没有位置变化时不需要位移(拖回原位置)
  309. if (dragIdx == insertIdx) {
  310. return { x: 0, y: 0 };
  311. }
  312. // 根据布局类型计算位移
  313. if (props.columns > 1) {
  314. // 多列网格布局:使用2D位移计算
  315. return calculateGridOffset(index, dragIdx, insertIdx);
  316. } else {
  317. // 单列布局:使用简单的纵向位移
  318. return calculateSingleColumnOffset(index, dragIdx, insertIdx);
  319. }
  320. }
  321. /**
  322. * 计算项目的完整样式对象
  323. * @param index 项目索引
  324. * @returns 样式对象
  325. */
  326. function getItemStyle(index: number) {
  327. const style = {};
  328. const isCurrent = dragIndex.value == index;
  329. // 多列布局时设置等宽分布
  330. if (props.columns > 1) {
  331. const widthPercent = 100 / props.columns;
  332. style["flex-basis"] = `${widthPercent}%`;
  333. style["width"] = `${widthPercent}%`;
  334. style["box-sizing"] = "border-box";
  335. }
  336. // 放下动画期间,只保留基础样式
  337. if (dropping.value) {
  338. return style;
  339. }
  340. // 拖拽状态下的样式处理
  341. if (dragging.value) {
  342. if (isCurrent) {
  343. // 拖拽元素:跟随移动
  344. style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
  345. style["z-index"] = "100";
  346. } else {
  347. // 其他元素:显示排序预览位移
  348. const translateOffset = getItemTranslateOffset(index);
  349. style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
  350. }
  351. }
  352. return style;
  353. }
  354. /**
  355. * 获取所有项目的位置信息
  356. */
  357. async function getItemPosition(): Promise<void> {
  358. return new Promise((resolve) => {
  359. uni.createSelectorQuery()
  360. .in(proxy)
  361. .select(".cl-draggable")
  362. .boundingClientRect()
  363. .exec((res) => {
  364. const box = res[0] as NodeInfo;
  365. itemWidth.value = (box.width ?? 0) / props.columns;
  366. uni.createSelectorQuery()
  367. .in(proxy)
  368. .selectAll(".cl-draggable__item")
  369. .boundingClientRect()
  370. .exec((res) => {
  371. const rects = res[0] as NodeInfo[];
  372. const positions: ItemPosition[] = [];
  373. for (let i = 0; i < rects.length; i++) {
  374. const rect = rects[i];
  375. if (i == 0) {
  376. itemHeight.value = rect.height ?? 0;
  377. }
  378. positions.push({
  379. top: rect.top ?? 0,
  380. left: rect.left ?? 0,
  381. width: itemWidth.value,
  382. height: itemHeight.value
  383. });
  384. }
  385. itemPositions.value = positions;
  386. resolve();
  387. });
  388. });
  389. });
  390. }
  391. /**
  392. * 获取项目是否禁用
  393. * @param index 项目索引
  394. * @returns 是否禁用
  395. */
  396. function getItemDisabled(index: number): boolean {
  397. return !isNull(list.value[index]["disabled"]) && (list.value[index]["disabled"] as boolean);
  398. }
  399. /**
  400. * 检查拖拽元素的中心点是否移动到其他元素区域
  401. */
  402. function checkMovedToOtherElement(): boolean {
  403. // 如果没有位置信息,默认未移出
  404. if (itemPositions.value.length == 0) return false;
  405. const dragIdx = dragIndex.value;
  406. const dragPosition = itemPositions.value[dragIdx];
  407. // 计算拖拽元素当前的中心点位置(考虑拖拽偏移)
  408. const dragCenterX = dragPosition.left + dragPosition.width / 2 + offsetX.value;
  409. const dragCenterY = dragPosition.top + dragPosition.height / 2 + offsetY.value;
  410. // 根据布局类型采用不同的判断策略
  411. if (props.columns > 1) {
  412. // 多列网格布局:检查中心点是否与其他元素区域重叠
  413. for (let i = 0; i < itemPositions.value.length; i++) {
  414. if (i == dragIdx) continue;
  415. const otherPosition = itemPositions.value[i];
  416. const isOverlapping =
  417. dragCenterX >= otherPosition.left &&
  418. dragCenterX <= otherPosition.left + otherPosition.width &&
  419. dragCenterY >= otherPosition.top &&
  420. dragCenterY <= otherPosition.top + otherPosition.height;
  421. if (isOverlapping) {
  422. return true;
  423. }
  424. }
  425. } else {
  426. // 检查是否向上移动超过上一个元素的中线
  427. if (dragIdx > 0) {
  428. const prevPosition = itemPositions.value[dragIdx - 1];
  429. const prevCenterY = prevPosition.top + prevPosition.height / 2;
  430. if (dragCenterY <= prevCenterY) {
  431. return true;
  432. }
  433. }
  434. // 检查是否向下移动超过下一个元素的中线
  435. if (dragIdx < itemPositions.value.length - 1) {
  436. const nextPosition = itemPositions.value[dragIdx + 1];
  437. const nextCenterY = nextPosition.top + nextPosition.height / 2;
  438. if (dragCenterY >= nextCenterY) {
  439. return true;
  440. }
  441. }
  442. }
  443. return false;
  444. }
  445. /**
  446. * 触摸开始事件处理
  447. * @param event 触摸事件对象
  448. * @param index 触摸的项目索引
  449. */
  450. async function onTouchStart(event: UniTouchEvent, index: number, type: string) {
  451. // 如果是长按触发,但未开启长按功能,则直接返回
  452. if (type == "longpress" && !props.longPress) return;
  453. // 如果是普通触摸触发,但已开启长按功能,则直接返回
  454. if (type == "touch" && props.longPress) return;
  455. // 检查是否禁用或索引无效
  456. if (props.disabled) return;
  457. if (getItemDisabled(index)) return;
  458. if (index < 0 || index >= list.value.length) return;
  459. // 获取触摸点
  460. const touch = event.touches[0];
  461. // 初始化拖拽状态
  462. dragging.value = true;
  463. // 初始化拖拽索引
  464. dragIndex.value = index;
  465. insertIndex.value = index; // 初始插入位置为原位置
  466. startX.value = touch.clientX;
  467. startY.value = touch.clientY;
  468. offsetX.value = 0;
  469. offsetY.value = 0;
  470. // 初始化拖拽数据项
  471. dragItem.value = list.value[index];
  472. // 先获取所有项目的位置信息,为后续计算做准备
  473. await getItemPosition();
  474. // 触发开始事件
  475. emit("start", index);
  476. // 震动
  477. uni.$emit("cool.vibrate");
  478. // 阻止事件冒泡
  479. event.stopPropagation();
  480. // 阻止默认行为
  481. event.preventDefault();
  482. }
  483. /**
  484. * 触摸移动事件处理
  485. * @param event 触摸事件对象
  486. */
  487. function onTouchMove(event: TouchEvent): void {
  488. if (!dragging.value) return;
  489. const touch = event.touches[0];
  490. // 更新拖拽偏移量
  491. offsetX.value = touch.clientX - startX.value;
  492. offsetY.value = touch.clientY - startY.value;
  493. // 智能启动排序模拟:只有移出原元素区域才开始
  494. if (!sortingStarted.value) {
  495. if (checkMovedToOtherElement()) {
  496. sortingStarted.value = true;
  497. }
  498. }
  499. // 只有开始排序模拟后才计算插入位置
  500. if (sortingStarted.value) {
  501. // 计算拖拽元素当前的中心点坐标
  502. const dragPos = itemPositions.value[dragIndex.value];
  503. const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
  504. const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
  505. // 根据布局类型选择坐标轴:网格布局使用X坐标,单列布局使用Y坐标
  506. const dragCenter = props.columns > 1 ? dragCenterX : dragCenterY;
  507. // 计算最佳插入位置
  508. const newIndex = calculateInsertIndex(dragCenter);
  509. if (newIndex != insertIndex.value) {
  510. insertIndex.value = newIndex;
  511. }
  512. }
  513. // 阻止默认行为
  514. event.preventDefault();
  515. }
  516. /**
  517. * 触摸结束事件处理
  518. */
  519. function onTouchEnd(): void {
  520. if (!dragging.value) return;
  521. // 旧索引
  522. const oldIndex = dragIndex.value;
  523. // 新索引
  524. const newIndex = insertIndex.value;
  525. // 如果位置发生变化,立即更新数组
  526. if (oldIndex != newIndex && newIndex >= 0) {
  527. const newList = [...list.value];
  528. const item = newList.splice(oldIndex, 1)[0];
  529. newList.splice(newIndex, 0, item);
  530. list.value = newList;
  531. // 触发变化事件
  532. emit("update:modelValue", list.value);
  533. emit("change", list.value);
  534. }
  535. // 开始放下动画
  536. dropping.value = true;
  537. dragging.value = false;
  538. // 重置所有状态
  539. reset();
  540. // 等待放下动画完成后重置所有状态
  541. emit("end", newIndex >= 0 ? newIndex : oldIndex);
  542. }
  543. /**
  544. * 根据平台选择合适的key
  545. * @param item 数据项
  546. * @param index 索引
  547. * @returns 合适的key
  548. */
  549. function getItemKey(item: UTSJSONObject, index: number): string {
  550. // #ifdef MP
  551. // 小程序环境使用 index 作为 key,避免数据错乱
  552. return `${index}`;
  553. // #endif
  554. // #ifndef MP
  555. // 其他平台使用 uid,提供更好的性能
  556. return item["uid"] as string;
  557. // #endif
  558. }
  559. watch(
  560. computed(() => props.modelValue),
  561. (val: UTSJSONObject[]) => {
  562. list.value = val.map((e) => {
  563. return {
  564. uid: e["uid"] ?? uuid(),
  565. ...e
  566. };
  567. });
  568. },
  569. {
  570. immediate: true
  571. }
  572. );
  573. </script>
  574. <style lang="scss" scoped>
  575. .cl-draggable {
  576. @apply flex-col relative overflow-visible;
  577. &--columns {
  578. @apply flex-row flex-wrap;
  579. }
  580. &__item {
  581. @apply relative z-10;
  582. // #ifdef APP-IOS
  583. @apply transition-none opacity-100;
  584. // #endif
  585. &--dragging {
  586. @apply opacity-80 z-20;
  587. }
  588. &--disabled {
  589. @apply opacity-60;
  590. }
  591. &--animating {
  592. @apply duration-200;
  593. transition-property: transform;
  594. }
  595. }
  596. }
  597. </style>