cl-draggable.uvue 17 KB

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