列表的拖动排序动画原理

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

列表的拖动排序动画原理

列表的拖动排序动画原理

前端甜甜已关注

分享点赞在看

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

视频详情

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


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

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

bash 复制代码
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 即可,translateY2 = dy,这就是常规的拖拽。

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

视图层

对于 activeItem 项,要设置 transition 为 none

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

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


留几个疑问

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

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

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

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

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

全部代码

bash 复制代码
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(0, 5),
      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 的代码

bash 复制代码
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()
}
相关推荐
ZJU_统一阿萨姆1 小时前
【DSH】如何将 DeepSeek Harness 嵌入生产环境?万字解构 DeepSeek Agent Harness 的运行机制与安全边界
前端·javascript·安全
嘟嘟07171 小时前
Next.js 博客左侧笔记列表是怎么从 Redis 渲染出来的?从组件、children 到动态路由一次讲清
redis·react.js·前端工程化
sunly_2 小时前
TypeScript总结:16、面向对象速查
前端·javascript·typescript
张元清2 小时前
React useInterval Hook:没有过期闭包的 setInterval (2026)
javascript·react.js
MXN_小南学前端2 小时前
React超长文本域中实现“返回顶部”浮动按钮
前端·javascript·react.js
gs801403 小时前
解构 Cordis:面向“时空可组合性”的 TypeScript 元框架深度剖析
前端·javascript·typescript
Highcharts.js3 小时前
Highcharts大数据渲染模块Boost实战与参数最佳实践表
javascript·数据可视化·boost·highcharts·大数据渲染·加速配置·参数表
岁岁种桃花儿3 小时前
Vue核心语法第一篇:Vue是什么?
前端·javascript·vue.js
Highcharts.js4 小时前
数据可视化避坑指南 ——开发中常见图表错误与修复方案
javascript·信息可视化·数据可视化·highcharts·数据可视化避坑·修复方案
观无4 小时前
若依EasyExcel实现单元格合并
开发语言·前端·javascript