列表的拖动排序动画原理

0 阅读3分钟

最终实现的效果如下,结尾有全部代码

20260313-213351.gif

列表的拖动排序动画原理

列表的拖动排序动画原理

前端甜甜已关注

分享点赞在看

已同步到看一看写下你的评论

视频详情

拖拽排序是一个常见的交互模式,本文将深入浅出地讲解拖动时动画效果的实现原理,并通过 React 演示一个最小实现方案。


我们的列表有 5 个由内容决定高度的项目,核心思路是通过 transform 的 translateY 属性来控制每个项目的位置而不是直接操作 DOM 的顺序。

初始状态下,每个项目的 translateY 都是 0:

translateY
0
0
0
0
0

初始值 translateY 记录的是元素位移

translateY 最终将赋值给 视图层每一项 div 的 style transform

先来看数据层

按下鼠标时,需要做两件事:

  • 记录当前拖拽的项目(activeItem)

  • 使用 getBoundingClientRect() 获取所有列表项的位置信息(top、bottom、height等)

随着鼠标移动,我们需要判断当前拖拽的项目应该与哪个项目交换位置。这里采用中心点检测法,计算鼠标当前位置(clientY)与每个列表项矩形中心点的距离,距离最近的那个就是 overItem(目标项)。

开始拖动

假设鼠标按在第 2 项后开始拖动,以向下拖动为例

情况一

当拖动时碰撞到了第 3 项,然后就只需要将第 3 项向上移动即可,第三项的位移是 dy = 第 2 项的 rect.top - 第 3 项的 rect.top,

则 translateY = [0, 0, dy, 0, 0]。

rect 是鼠标按下时 getBoundingClientRect()的返回值。

情况二

当拖动时碰撞到了第 4 项,需要将第 3 项 和 第 4 项都向上移动,这两项的位移依然是 dy = 第 2 项 的 rect.top - 第 3 项的 rect.top,

则 translateY  = [0, 0, dy, dy, 0]。

第 2 项 也需要移动,鼠标按下的时候记录一下 按下的 clientY, 在移动过程中 dy = move_clientY - down_clientY 即可,translateY[2] = dy,这就是常规的拖拽。

向上移动同理,translateY 的值始终应该设置list[activeIndex] 和 list[activeIndex + 1] 的差,或 list[activeIndex] 和 list[activeIndex - 1] 的差

视图层

对于 activeItem 项,要设置 transition 为 none

const style: React.CSSProperties = {
  transition: isActive ? 'none' : 'transform 0.3s',
  transform: `translateY(${state.translateY[index]}px)`
}

至此,一个简单的拖动排序动画就完成了


留几个疑问

  1. 为什么不能在 鼠标移动的时候获取元素的位置呢?是单纯从性能考虑,还是有其他原因。

  2. 如果列表项比较多,所在的容器有滚动条的情况下,会不会有问题

  3. 如果鼠标拖动到容器边界的时候,让容器自动滚动,会不会有问题

  4. 如果列表使用了虚拟滚动,会不会有问题

关注我,后续我将依次展示每一个问题的具体原理。

全部代码

import { cn } from'@/utils/cn'
import { configure } from'mobx'
import { observer, useLocalObservable } from'mobx-react-lite'
import { PointerEvent, useRef } from'react'
import { startDrag } from'rmst-design'
const textPool = [
  'Design',
  'Implement the user authentication module with OAuth support',
  'Setup',
  'Write API endpoints for data management including pagination, filtering and sorting capabilities across all resources',
  'Deploy to production',
  'Write comprehensive test suites covering both unit and integration scenarios to ensure system reliability',
  'Review',
  'Configure the CI/CD pipeline with automated testing, staging deployments, and production release workflows for multiple environments',
  'Fix bugs',
  'Refactor the database access layer to support multiple database backends and implement connection pooling for improved performance under heavy concurrent load',
  'Update deps',
  'Design and implement the real-time notification system supporting email, SMS, and push notifications with configurable user preference management and delivery tracking',
  'Optimize queries',
  'Build the admin dashboard with role-based access control, comprehensive activity logging, user management tools, and system health monitoring capabilities for operations team'
]
const initialItems = Array.from({ length: 5 }, (_, i) => ({
  id: `a-${i + 1}`,
  text: `${i + 1}${textPool[i % textPool.length]}`
}))
configure({ enforceActions: 'never' })
exportconstDragSortMy = observer(() => {
  const state = useLocalObservable(() => {
    return {
      items: initialItems.slice(05),
      activeIndex: null,
      overIndex: null,
      translateY: []
    }
  })
  const domsMapRef = useRef<Map<string, DOMRect>>(newMap())
  const domRefs = useRef<Map<string, HTMLDivElement>>(newMap())
  constsetDomRef = (id, el) => {
    domRefs.current.set(id, el)
  }
  consthandlePointerDown = async (downEvt: PointerEvent, id: string, index: number) => {
    calcPosition()
    const down_clientY = downEvt.clientY
    state.activeIndex = index
    startDrag(downEvt, {
      onDragMove: moveEvent => {
        const move_clientY = moveEvent.clientY
        const rects = Array.from(domsMapRef.current).map(([id, rect]) => ({ id, rect }))
        // 寻找到最近的一个元素
        const overIndex = rects.reduce(
          (closest, item, i) => {
            const center = item.rect.top + item.rect.height / 2
            const distance = Math.abs(moveEvent.clientY - center)
            return distance < closest.distance ? { index: i, distance } : closest
          },
          { index: -1, distance: Infinity }
        ).index
        state.overIndex = overIndex
        const { activeIndex } = state
        const translateY = []
        if (overIndex > activeIndex) {
          const offset = rects[activeIndex].rect.top - rects[activeIndex + 1].rect.top
          for (let i = activeIndex + 1; i <= overIndex; i++) {
            translateY[i] = offset
          }
        } elseif (overIndex < activeIndex) {
          const offset = rects[activeIndex].rect.bottom - rects[activeIndex - 1].rect.bottom
          for (let i = activeIndex - 1; i >= overIndex; i--) {
            translateY[i] = offset
          }
        } elseif (overIndex === activeIndex) {
        }
        translateY[activeIndex] = move_clientY - down_clientY
        state.translateY = [...translateY]
      },
      onDragEnd: () => {
        if (state.overIndex !== state.activeIndex) {
        }
        reset()
      },
      onPointerUp: () => {
        reset()
      }
    })
    constreset = () => {
      state.activeIndex = null
      state.overIndex = null
      state.translateY = []
    }
  }
  constcalcPosition = () => {
    for (const [id, el] of domRefs.current) {
      const rect = el.getBoundingClientRect().toJSON()
      domsMapRef.current.set(id, rect)
    }
  }
  return (
    <divclassName="rmstsd-dsm-c relative mt-10">
      <divclassName="px-4 space-y-2">
        {state.items.map((item, index) => {
          const isActive = state.activeIndex === index
          const style: React.CSSProperties = {
            transition: isActive ? 'none' : 'transform 0.3s',
            transform: `translateY(${state.translateY[index] ?? 0}px)`
          }
          return (
            <div
              key={item.id}
              ref={el => setDomRef(item.id, el)}
              style={style}
              onPointerDown={evt => handlePointerDown(evt, item.id, index)}
              className={cn(
                'rounded-xl bg-slate-500 px-4 py-3 text-sm shadow-sm text-white select-none',
                isActive ? 'ring-sky-500 shadow-lg bg-slate-800 relative z-10' : ''
              )}
            >
              <divclassName="mt-2">{item.id}</div>
              <divclassName="mt-2">{item.text}</div>
            </div>
          )
        })}
      </div>
    </div>
  )
})

startDrag 的代码

interface DragEndData {
  isCanceled: boolean
  upEvt: PointerEvent
}
interface DragOptions {
  onDragStart?: (downEvt: React.PointerEvent | PointerEvent) => void
  onDragMove?: (moveEvt: PointerEvent) => void
  onDragEnd?: ({ isCanceled, upEvt }: DragEndData) => void
  onPointerUp?: (upEvt: PointerEvent) => void // 与 html 类似, 发生了 drag 后, 就不会触发 onPointerUp 事件
  distanceThreshold?: number
}
let disableClick = false

document.addEventListener(
  'click',
  evt => {
    if (disableClick) {
      evt.stopPropagation()
    }
  },
  { capture: true }
)

export const startDrag = (downEvt: React.PointerEvent | PointerEvent, options: DragOptions) => {
  const { onDragStart, onDragMove, onDragEnd, onPointerUp, distanceThreshold = 10 } = options
  const abCt = new AbortController()
  const target = downEvt.target as HTMLElement
  let isMoved = false
  document.addEventListener(
    'pointermove',
    moveEvt => {
      const dis = Math.hypot(moveEvt.clientX - downEvt.clientX, moveEvt.clientY - downEvt.clientY)
      if (!isMoved) {
        if (dis < distanceThreshold) {
          return
        }
        disableClick = true
        clearWebSelection()
        target.setPointerCapture(downEvt.pointerId)
        onDragStart?.(downEvt)
        isMoved = true
      }
      onDragMove?.(moveEvt)
    },
    { signal: abCt.signal }
  )
  let _isCanceled = false
  const cancel = (dee: DragEndData, isPointerEvent: boolean) => {
    if (isPointerEvent) {
      setTimeout(() => {
        disableClick = false
      })
    }
    if (_isCanceled) {
      return
    }
    _isCanceled = true
    abCt.abort()
    if (isMoved) {
      onDragEnd?.(dee)
    } else {
      onPointerUp?.(dee?.upEvt)
    }
  }
  document.addEventListener('pointerup', evt => cancel({ isCanceled: false, upEvt: evt }, true), { signal: abCt.signal })
  document.addEventListener('pointercancel', evt => cancel({ isCanceled: true, upEvt: evt }, true), { signal: abCt.signal })
  document.addEventListener(
    'keydown',
    evt => {
      if (evt.key === 'Escape') {
        cancel({ isCanceled: true, upEvt: null }, false)
      }
    },
    { signal: abCt.signal }
  )
}
export function clearWebSelection() {
  const sel = window.getSelection()
  if (sel.rangeCount > 0) sel.removeAllRanges()
}