u-draggable.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. <template>
  2. <view class="u-draggable" ref="uDraggable">
  3. <movable-area class="u-draggable__area" :style="[areaStyles]">
  4. <movable-view
  5. v-for="(item, index) in cloneList"
  6. direction="all"
  7. class="u-draggable__view"
  8. :key="index"
  9. :style="[{
  10. width: $u.addUnit(width),
  11. height: $u.addUnit(height)
  12. }]"
  13. :class="[{
  14. 'u-draggable__active': index == activeIndex,
  15. 'u-draggable__disabled': disabled || item.disabled,
  16. }]"
  17. :x="item.x"
  18. :y="item.y"
  19. :friction="friction"
  20. :damping="damping"
  21. :disabled="item.disabled || (longpress && !longPressStarted) || !isDrag"
  22. @change="handleTouchMove($event, index)"
  23. @longpress="handleLongPress($event, index)"
  24. @touchstart="handleTouchStart($event, index)"
  25. @touchend="handleTouchEnd"
  26. @touchcancel="handleTouchEnd"
  27. >
  28. <slot
  29. name="item"
  30. :index="index"
  31. :startIndex="item.startIndex"
  32. :item="item.item"
  33. :active="!item.disabled && index == activeIndex"
  34. :disabled="item.disabled"
  35. />
  36. <view class="u-draggable__close" v-if="closeable && !item.disabled" @click.stop="closeItem(index)">
  37. <slot name="close">
  38. <view class="u-draggable__close-inner">
  39. <u-icon name="close" size="12" color="#fff"></u-icon>
  40. </view>
  41. </slot>
  42. </view>
  43. </movable-view>
  44. </movable-area>
  45. </view>
  46. </template>
  47. <script>
  48. // #ifdef APP-NVUE
  49. const dom = weex.requireModule('dom');
  50. // #endif
  51. import props from './props.js';
  52. import mixin from '../../libs/mixin/mixin';
  53. import mpMixin from '../../libs/mixin/mpMixin';
  54. /**
  55. * u-draggable 拖拽排序
  56. * @description 拖拽排序组件,支持多列拖拽排序,可关闭,手柄拖拽,长按拖拽。
  57. * @tutorial https://uview.d3u.cn/components/draggable.html
  58. * @property {Array} list 数据列表 (默认 [] )
  59. * @property {Number} column 列数 (默认 2 )
  60. * @property {Number} aspectRatio 宽高比,填写这项时gridHeight失效 (默认 null )
  61. * @property {Number} itemHeight 项目高度 (默认 60 )
  62. * @property {Number} damping 阻尼系数 (默认 50 )
  63. * @property {Number} friction 摩擦系数 (默认 2 )
  64. * @property {Boolean} handle 是否使用手柄拖拽 (默认 false )
  65. * @property {Boolean} disabled 是否禁用 (默认 false )
  66. * @property {Boolean} longpress 是否长按拖拽 (默认 false )
  67. * @property {Boolean} closeable 是否显示关闭按钮 (默认 false )
  68. *
  69. * @event {Function} change 拖拽排序组件被点击时触发
  70. * @event {Function} close 拖拽排序组件被关闭时触发
  71. * @example <u-draggable :list="list" @change="change"></u-draggable>
  72. */
  73. export default {
  74. name: 'u-draggable',
  75. mixins: [mpMixin, mixin, props],
  76. data() {
  77. return {
  78. isDrag: false,
  79. isInit: false,
  80. maxIndex: -1,
  81. activeIndex: -1,
  82. isDisabled: false,
  83. positions: [],
  84. cloneList: [],
  85. containerWidth: 0,
  86. positionMap: [],
  87. longPressStarted: false,
  88. dragState: {
  89. item: null, // 当前拖拽的元素
  90. index: 0, // 当前索引位置
  91. oldIndex: -1, // 开始拖拽时的索引位置
  92. sortableIndex: -1 // 在可排序数组中的索引
  93. }
  94. };
  95. },
  96. computed: {
  97. rows() {
  98. return Math.ceil((this.isInit ? this.cloneList.length : this.list.length) / this.column);
  99. },
  100. height() {
  101. if (this.aspectRatio) {
  102. return this.width / this.aspectRatio;
  103. }
  104. return this.$u.getPx(this.itemHeight);
  105. },
  106. width() {
  107. return this.containerWidth / this.column;
  108. },
  109. areaStyles() {
  110. return {
  111. // #ifdef APP-NVUE
  112. width: this.$u.addUnit(this.containerWidth),
  113. // #endif
  114. height: this.$u.addUnit((this.rows) * this.height)
  115. };
  116. }
  117. },
  118. mounted() {
  119. this.init();
  120. },
  121. watch: {
  122. list: {
  123. handler(newVal) {
  124. this.init();
  125. },
  126. deep: true
  127. }
  128. },
  129. // #ifdef VUE3
  130. emits: ['change', 'close'],
  131. // #endif
  132. methods: {
  133. async init() {
  134. // 清除旧数据
  135. this.clear();
  136. await this.$nextTick();
  137. const res = await this.getContainerRect();
  138. if (res && res.width) {
  139. this.containerWidth = res.width;
  140. this.positions = [];
  141. this.positionMap = [];
  142. let sortableIndex = 0;
  143. this.cloneList = this.list.map((item, listIndex) => {
  144. this.maxIndex++;
  145. // 计算网格位置
  146. const row = Math.floor(listIndex / this.column);
  147. const col = listIndex % this.column;
  148. const x = col * this.width;
  149. const y = row * this.height;
  150. // 创建位置信息
  151. this.positions[listIndex] = {
  152. row,
  153. x,
  154. y,
  155. x1: x + this.width,
  156. y1: y + this.height,
  157. disabled: item.disabled,
  158. oldIndex: listIndex
  159. };
  160. // 处理禁用和可排序项的映射
  161. if (item.disabled) {
  162. this.positionMap.push({ index: listIndex, disabled: true, sortableIndex: -1 });
  163. } else {
  164. this.positionMap.push({ index: listIndex, disabled: false, sortableIndex: sortableIndex });
  165. sortableIndex++;
  166. }
  167. // 创建克隆项
  168. return {
  169. index: listIndex,
  170. item,
  171. x: x,
  172. y: y,
  173. startIndex: listIndex,
  174. disabled: item.disabled,
  175. oldIndex: listIndex
  176. };
  177. });
  178. this.isInit = true;
  179. }
  180. },
  181. getContainerRect() {
  182. return new Promise(resolve => {
  183. // #ifndef APP-NVUE
  184. this.$uGetRect('.u-draggable').then(res => {
  185. resolve(res);
  186. });
  187. // #endif
  188. // #ifdef APP-NVUE
  189. const ref = this.$refs['uDraggable'];
  190. if (ref) {
  191. dom.getComponentRect(ref, (res) => {
  192. resolve({
  193. height: res.size.height,
  194. width: res.size.width
  195. });
  196. });
  197. } else {
  198. resolve({ height: 0, width: 0 });
  199. }
  200. // #endif
  201. });
  202. },
  203. handleTouchStart(event, index) {
  204. const { handle } = event.target.dataset || {}
  205. if(this.handle && !handle) {
  206. return;
  207. }
  208. // 如果启用了长按模式且未长按,不允许拖拽
  209. if (this.longpress && !this.longPressStarted) {
  210. return;
  211. }
  212. // 获取目标元素并进行验证
  213. const targetItem = this.cloneList[index];
  214. if (!targetItem || targetItem.disabled || this.disabled) return;
  215. // 检查positionMap是否存在对应的数据
  216. const positionData = this.positionMap[targetItem.oldIndex];
  217. if (!positionData) return;
  218. // 设置拖拽状态
  219. this.isDrag = true;
  220. this.activeIndex = index;
  221. this.dragState.item = targetItem;
  222. this.dragState.index = targetItem.index;
  223. this.dragState.oldIndex = targetItem.index;
  224. this.dragState.sortableIndex = positionData.sortableIndex;
  225. },
  226. handleTouchMove(event, index) {
  227. // 如果不在拖拽状态,直接返回
  228. if (!this.isDrag) return;
  229. // 如果不是当前激活的元素,直接返回
  230. if (index !== this.activeIndex) return;
  231. // 获取当前位置
  232. const {x, y} = event.detail;
  233. // 计算中心点坐标
  234. const centerX = x + this.width / 2;
  235. const centerY = y + this.height / 2;
  236. this.detectCollision(centerX, centerY, index);
  237. },
  238. handleLongPress(event, index) {
  239. // 标记长按已开始
  240. this.longPressStarted = true;
  241. // 触发拖拽开始
  242. this.handleTouchStart(event, index);
  243. // 震动反馈(如果支持)
  244. if (uni.vibrateShort) {
  245. uni.vibrateShort();
  246. }
  247. },
  248. handleTouchEnd(event) {
  249. this.longPressStarted = false;
  250. // 如果没有激活的元素或全局禁用,直接返回
  251. if (this.activeIndex === -1 || this.disabled) return;
  252. // 结束拖拽状态
  253. this.isDrag = false;
  254. // 判断是否需要触发变更事件(只有当位置发生变化时)
  255. const hasPositionChanged = this.dragState.index !== this.dragState.oldIndex && this.dragState.oldIndex > -1;
  256. // 获取最后拖拽的元素和目标位置
  257. const lastDraggedItem = this.cloneList[this.activeIndex];
  258. const targetPosition = this.positions[this.dragState.index];
  259. // 验证元素和位置
  260. if (!lastDraggedItem || this.positionMap[this.dragState.index].disabled) {
  261. this.activeIndex = -1;
  262. return;
  263. }
  264. // 添加微小偏移以确保动画效果
  265. lastDraggedItem.x = targetPosition.x + 0.001;
  266. lastDraggedItem.y = targetPosition.y + 0.001;
  267. // 延迟设置最终位置并触发事件
  268. uni.$u.sleep(30).then(() => {
  269. // 设置最终位置
  270. lastDraggedItem.x = targetPosition.x;
  271. lastDraggedItem.y = targetPosition.y;
  272. // 如果位置发生变化,触发事件并重置状态
  273. if (hasPositionChanged) {
  274. this.dragState.oldIndex = -1;
  275. this.activeIndex = -1;
  276. this.triggerEmits();
  277. } else {
  278. // 即使位置没有变化,也需要重置激活状态
  279. this.activeIndex = -1;
  280. }
  281. });
  282. },
  283. detectCollision(centerX, centerY, activeIndex) {
  284. // 如果全局禁用,直接返回
  285. if (this.disabled) return;
  286. // 快速边界检查
  287. if (centerX < 0 || centerY < 0 ||
  288. centerX > this.containerWidth ||
  289. centerY > this.rows * this.height) {
  290. return;
  291. }
  292. // 获取当前行和列
  293. const currentRow = Math.floor(centerY / this.height);
  294. const currentCol = Math.floor(centerX / this.width);
  295. // 计算可能的目标索引
  296. const possibleIndex = currentRow * this.column + currentCol;
  297. // 验证索引是否有效且不是禁用位置
  298. if (possibleIndex >= 0 &&
  299. possibleIndex < this.positions.length &&
  300. possibleIndex !== this.dragState.index &&
  301. !this.positionMap[possibleIndex].disabled) {
  302. const targetPos = this.positions[possibleIndex];
  303. // 检查是否在目标区域内
  304. if (
  305. centerX >= targetPos.x &&
  306. centerX <= targetPos.x1 &&
  307. centerY >= targetPos.y &&
  308. centerY <= targetPos.y1
  309. ) {
  310. // 执行交换(只在可排序项之间)
  311. this.swapSortableItems(activeIndex, possibleIndex);
  312. }
  313. }
  314. },
  315. swapSortableItems(fromIndex, toIndex) {
  316. // 快速边界检查
  317. if (toIndex < 0 || toIndex >= this.positions.length) return;
  318. // 如果全局禁用或目标位置是禁用位置,直接返回
  319. if (this.disabled || this.positionMap[toIndex].disabled) return;
  320. // 获取拖拽元素
  321. const draggedItem = this.cloneList[fromIndex];
  322. if (!draggedItem || draggedItem.disabled) return;
  323. // 查找目标位置的元素
  324. const targetItem = this.cloneList.find(item => item.index === toIndex);
  325. if (!targetItem || targetItem.disabled) return;
  326. // 获取目标元素在cloneList中的索引
  327. const targetCloneIndex = this.cloneList.findIndex(item => item.index === toIndex);
  328. if (targetCloneIndex === -1) return;
  329. // 交换位置信息
  330. const draggedPosition = this.positions[this.dragState.index];
  331. const targetPosition = this.positions[toIndex];
  332. // 更新目标元素位置
  333. targetItem.x = draggedPosition.x;
  334. targetItem.y = draggedPosition.y;
  335. targetItem.index = this.dragState.index;
  336. // 更新拖拽元素索引
  337. draggedItem.index = toIndex;
  338. // 如果是拖拽状态,更新dragState的索引
  339. if (this.isDrag) {
  340. this.dragState.index = toIndex;
  341. }
  342. },
  343. triggerEmits() {
  344. // 按位置索引排序所有剩余项目
  345. const sortedItems = [...this.cloneList].sort((a, b) => a.index - b.index);
  346. // 创建最终结果数组
  347. const result = sortedItems.map(item => item.item);
  348. // 触发变更事件
  349. this.$emit('change', result);
  350. },
  351. closeItem(index) {
  352. // 如果全局禁用或项目禁用,直接返回
  353. if (this.disabled || (this.cloneList[index] && this.cloneList[index].disabled)) return;
  354. // 重置拖拽状态
  355. this.activeIndex = -1;
  356. this.isDrag = false;
  357. // 获取要删除的项目并验证
  358. const itemToRemove = this.cloneList[index];
  359. if (!itemToRemove) return;
  360. // 获取被删除项目的位置索引
  361. const removedPosition = itemToRemove.index;
  362. // 从列表中移除项目
  363. this.cloneList.splice(index, 1);
  364. // 重新计算所有项目的位置
  365. this.cloneList.forEach((item, idx) => {
  366. // 如果项目的位置索引大于被删除项目的位置,需要前移
  367. if (item.index > removedPosition) {
  368. item.index--;
  369. // 重新计算网格位置
  370. const row = Math.floor(item.index / this.column);
  371. const col = item.index % this.column;
  372. const x = col * this.width;
  373. const y = row * this.height;
  374. // 更新位置
  375. item.x = x;
  376. item.y = y;
  377. // 更新positions数组
  378. this.positions[item.index] = {
  379. row,
  380. x,
  381. y,
  382. x1: x + this.width,
  383. y1: y + this.height,
  384. disabled: item.disabled,
  385. oldIndex: item.index
  386. };
  387. }
  388. });
  389. // 重建位置映射表
  390. this.rebuildPositionMap();
  391. // 删除多余的位置信息
  392. if (this.positions.length > this.cloneList.length) {
  393. this.positions.splice(this.cloneList.length);
  394. }
  395. this.$emit('close', index);
  396. },
  397. rebuildPositionMap() {
  398. this.positionMap = [];
  399. let sortableIndex = 0;
  400. this.cloneList.forEach((item) => {
  401. if (item.disabled) {
  402. this.positionMap.push({
  403. index: item.index,
  404. disabled: true,
  405. sortableIndex: -1
  406. });
  407. } else {
  408. this.positionMap.push({
  409. index: item.index,
  410. disabled: false,
  411. sortableIndex: sortableIndex
  412. });
  413. sortableIndex++;
  414. }
  415. });
  416. },
  417. clear() {
  418. this.isInit = false;
  419. this.isDrag = false;
  420. this.maxIndex = -1;
  421. this.activeIndex = -1;
  422. this.cloneList = [];
  423. this.positions = [];
  424. this.positionMap = [];
  425. }
  426. },
  427. // #ifdef VUE2
  428. beforeDestroy() {
  429. this.clear();
  430. }
  431. // #endif
  432. // #ifdef VUE3
  433. beforeUnmount() {
  434. this.clear();
  435. }
  436. // #endif
  437. };
  438. </script>
  439. <style lang="scss" scoped>
  440. .u-draggable {
  441. overflow: hidden;
  442. /* #ifdef APP-NVUE */
  443. flex: 1;
  444. /* #endif */
  445. /* #ifndef APP-NVUE */
  446. width: 100%;
  447. /* #endif */
  448. &__area {
  449. /* #ifdef APP-NVUE */
  450. flex: 1;
  451. /* #endif */
  452. /* #ifndef APP-NVUE */
  453. width: 100%;
  454. /* #endif */
  455. }
  456. &__handle {
  457. position: absolute;
  458. z-index: 9999;
  459. display: flex;
  460. align-items: center;
  461. justify-content: center;
  462. right: 0;
  463. top: 0;
  464. &-inner {
  465. display: flex;
  466. align-items: center;
  467. justify-content: center;
  468. width: 60px;
  469. height: 60px;
  470. background-color: #000;
  471. }
  472. }
  473. &__close {
  474. position: absolute;
  475. z-index: 9999;
  476. display: flex;
  477. align-items: center;
  478. justify-content: center;
  479. right: 0;
  480. top: 0;
  481. &-inner {
  482. display: flex;
  483. align-items: center;
  484. justify-content: center;
  485. width: 20px;
  486. height: 20px;
  487. background-color: #000;
  488. border-radius: 50%;
  489. opacity: 0.8;
  490. }
  491. }
  492. &__view {
  493. z-index: 2;
  494. transition: opacity 300ms ease;
  495. box-sizing: border-box;
  496. }
  497. &__active {
  498. z-index: 9999;
  499. transition: transform 100ms ease;
  500. }
  501. &__disabled {
  502. opacity: 0.5;
  503. cursor: not-allowed;
  504. /* #ifndef APP-NVUE */
  505. pointer-events: none;
  506. /* #endif */
  507. }
  508. }
  509. </style>