cl-draggable.uvue 17 KB

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