拖拽排序,换个思路轻松搞定

1,722 阅读6分钟

前言

上篇文章我们基于插件化架构完成了我们的拖拽库,这篇文章我们基于它来完成一些有意思的效果,先看下要完成的2个效果

  1. 列表排序效果图 sort.gif

  2. 2个列表互相穿梭排序效果图

sort-two.gif

sort-two-iframe.gif

下文我们将要写一个插件来完成效果

单个列表排序

功能拆分

单个列表排序,我们先对功能进行拆分

  1. 记录鼠标按下时的索引,并且高亮该索引对应的元素
  2. 记录鼠标移动时经过的索引
  3. 在鼠标移动时将2个记录的索引交换
  4. 应用动画
  5. 拖拽结束后取消高亮元素

为什么要记录索引,而不是直接记录元素

因为我们在实际的业务开发过程中,都是用框架的,而框架都是基于数据的,数据改变后,视图自动更新。

Vue3 框架为例,比如我们想写一个列表的布局

  1. 写好样式布局
  2. 定义列表数据
  3. 通过v-for遍历数据

列表排序的本质不就是2条数据交换么?只要能找到2条数据的索引,我们只需要用以下代码就能实现交换

function swap(data,startIndex,targetIndex){
    const temp = data[startIndex];
    data[startIndex] = data[targetIndex];
    data[targetIndex] = temp;
}

动画要怎么做?

数据是可以很简单的交换,但是动画呢,这种交换的动画应该怎么搞?

还是以Vue3 框架为例,框架内提供了TransitionTransitionGroup 2个动画过渡组件,这2个动画过渡组件的实现是基于FLIP动画思想的,想了解的可以参考这篇 FLIP 博客

我就要用原生写,我就是这么NB,我不用框架,请问阁下应该如何应对?

那还是可以基于FLIP动画思想,只不过你要自己封装一个类似TransitionTransitionGroup的函数或组件,我之前用原生的写过一个随机的动画,类似这样的效果,大概js50行代码左右

FLIP.gif

布局代码

样式布局这部分代码没有营养价值,这里我就粘贴了

<script setup lang="tsx">
import { ref } from 'vue'

interface Item {
  id: string
  name: string
  color: string
}
const baseBackgroundColor = 'skyblue'
const swapBackgroundColor = 'pink'

const listRef = ref<Item[]>([
  { id: '1', name: '1', color: baseBackgroundColor },
  { id: '2', name: '2', color: baseBackgroundColor },
  { id: '3', name: '3', color: baseBackgroundColor },
  { id: '4', name: '4', color: baseBackgroundColor },
  { id: '5', name: '5', color: baseBackgroundColor },
  { id: '6', name: '6', color: baseBackgroundColor },
  { id: '7', name: '7', color: baseBackgroundColor },
])
</script>

<template>
  <TransitionGroup tag="div" class="container" name="list">
    <div v-for="item in listRef" :id="item.id" :key="item.id" class="item" :style="{ background: item.color }">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>

<style scoped>
.container{
  display:inline-flex;
  flex-direction:column;
  gap:10px;
  margin:100px 0 0 30px;
  padding:20px;
  border:1px solid #ccc;
}
.item{
  display:flex;
  align-items:center;
  justify-content:center;
  width:200px;
  height:40px;
  user-select:none;
  border-radius:5px;
}
</style>

上面的代码运行后,页面就是这样的

image.png

这里值得注意的一点:我们给每个元素上添加了一个id属性,是为了后面能根据这个id获取到数据

实现 sort 插件

插件需要做什么事情?

在之前实现插件化拖拽架构的文章中我们暴露了一些拖拽过程中的钩子,这里再次列举一下,方便读者理解

  1. onStart:拖拽开始的钩子
  2. onMove:拖拽中的钩子
  3. onEnd:拖拽结束的钩子
  4. onDragging:在拖拽中的钩子

我们需要将插件实现的通用一点,那也就是说我们在内部不能实现交换索引,而是将交换的过程暴露给上层,我们在插件中只需要实现监听即可,大概分为这几步

  1. 监听用户按下的event
  2. 监听用户移动时的event
  3. 让用户实现交换

插件实现

代码比较简单,这里我给贴上后在解释

import { ref, unref } from 'vue'
import type { DragDropPluginCtx, DrapDropEventsCallback, EnhancedMouseEvent } from '@drag-drop/core'

interface SortPluginOptions extends Partial<Omit<DrapDropEventsCallback, 'onDragging'>> {
  swap: (mouseDownEvent: EnhancedMouseEvent, mouseMoveEvent: EnhancedMouseEvent) => void
}
export function sortPlugin(options: SortPluginOptions) {
  return function ({ context }: DragDropPluginCtx) {
    const {
      swap,
      onStart,
      onMove,
      onEnd,
    } = options
    const mouseDownEventRef = ref<EnhancedMouseEvent>()
    const mouseMoveEventRef = ref<EnhancedMouseEvent>()

    context.onStart((event) => {
      mouseDownEventRef.value = event
      onStart?.(event)
    })

    context.onMove((event) => {
      mouseMoveEventRef.value = event
      swap(unref(mouseDownEventRef)!, event)
      onMove?.(event)
    })

    context.onEnd((event) => {
      mouseDownEventRef.value = undefined
      mouseMoveEventRef.value = undefined
      onEnd?.(event)
    })
  }
}

这里我解释一下,因为可能没有读我之前的文章不太好理解

  1. 插件需要是一个函数,这个函数会被内部包装成vue 的 setup函数,所以可以使用 响应式 Api
  2. sortPlugin 是一个高阶函数,为了方便用户传参
  3. context 参数 是插件的上下文,封装好了一些拖拽钩子,可以直接监听
  4. swap 参数需要外界传递进来,让用户自己实现交换,我们提供了鼠标按下时的event鼠标移动时的event参数
  5. EnhancedMouseEvent 这个类型是增强版的MouseEvent,因为在设计时考虑到跨Iframe情况,暂且想象成MouseEvent类型即可

可以看到,上面的代码就已经实现了监听排序的过程,接下来看这个插件如何在外层去使用

外层使用插件

我们需要在之前实现的布局代码中添加上我们的插件代码

<script setup lang="tsx">
import { ref } from 'vue'
import { useDragDrop } from '@drag-drop/core'
import { sortPlugin } from '@drag-drop/plugin-sort'

...
const context = useDragDrop({
  canDraggable:event => !!event.target?.classList.contains('item'),
})

context.use(sortPlugin({
  swap:// 这里需要用户去实现,
}))
</script>

<template>
  <TransitionGroup tag="div" class="container" name="list">
    <div v-for="item in listRef" :id="item.id" :key="item.id" class="item" :style="{ background: item.color }">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>

<style scoped>
...
</style>

上述代码已经将插件引入并且使用了,用户需要实现swap函数即可

实现swap函数

我们只需要在swap函数中交换鼠标按下时的索引鼠标移动时的索引即可

function swap(startEvent: EnhancedMouseEvent, moveEvent: EnhancedMouseEvent) {
  const startIndex = getIndexByEvent(startEvent)
  const targetIndex = getIndexByEvent(moveEvent)

  if ((~startIndex) && (~targetIndex) && startIndex !== targetIndex) {
    const list = unref(listRef)
    const temp = list[startIndex]
    list[startIndex] = list[targetIndex]
    list[targetIndex] = temp
  }
}

function getIndexByEvent(event: EnhancedMouseEvent) {
  const element = event.target!
  const id = element.getAttribute('id')
  return unref(id2IndexByListGetter).get(id as any) ?? -1
}

const id2IndexByListGetter = computed(() => {
  const list = unref(listRef)
  return list.reduce((p, c, i) => {
    p.set(c.id, i)
    return p
  }, new Map<string, number>())
})

解释一下

  1. getIndexByEvent 函数:根据event参数获取到元素在数据中的索引,因为在之前我们将每个元素上都添加了一个id属性
  2. ~startIndex 等同于 startIndex !== -1
  3. startIndex !== targetIndex 是为了屏蔽自己和自己交换

好了,上述代码运行后是这样的

sort-basic.gif

添加动画

添加动画的代码比较简单,只需要给TransitionGroup添加一个name 属性,然后在样式中设定动画过渡即可

<template>
  <TransitionGroup tag="div" class="container" name="list">
    <div v-for="item in listRef" :id="item.id" :key="item.id" class="item" :style="{ background: item.color }">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>

<style scoped>
.list-move{
  transition: transform 0.1s linear;
}
...
</style>

本来以为写到这里就应该可以完成整个拖拽排序了,但是实际运行后确是这样的

sort-basic-doudong.gif

有点抖动的感觉,而且动画也不流畅,明明没有动画前毫无问题,咋有动画了就出问题了?

这是因为在动画的过程中我们还在频繁的触发swap函数,又在不停的交换位置,所以我们需要在动画的执行过程中暂停 swap 函数的触发,动画结束后恢复 swap 函数的执行,所以我们在稍微添加2行代码

<script setup lang="tsx">
...
const context = useDragDrop({
  canDraggable,
})

const { pause, resume } = context.use(sortPlugin({
  swap, // 这里需要用户去实现,
}))

function swap(startEvent: EnhancedMouseEvent, moveEvent: EnhancedMouseEvent) {
  const startIndex = getIndexByEvent(startEvent)
  const targetIndex = getIndexByEvent(moveEvent)

  if ((~startIndex) && (~targetIndex) && startIndex !== targetIndex) {
    const list = unref(listRef)
    const temp = list[startIndex]
    list[startIndex] = list[targetIndex]
    list[targetIndex] = temp
    pause() // 暂停 swap 函数的触发
  }
}

useEventListener('transitionend', resume) // 动画结束恢复 swap 函数
...
</script>

我们的插件会被内部统一包装,并提供pauseresume函数,这个时候就派上用场了,具体这两个函数的实现大家可以自行去翻阅源码,看最终效果

sort.gif

这里省略了鼠标按下时添加高亮效果鼠标抬起时取消高亮效果的代码,为什么省略,因为比较简单(主要因为懒🧐)

结语

  1. 完整代码在我的仓库, 仓库地址
  2. 实现拖拽插件化架构的文章,掘金文章地址
  3. 列表互相穿梭拖拽排序文章,掘金文章地址

如有错误之处,请指正,谢谢大家~