- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
导读:本文以仓库内实施计划 plans/018-reorder-multidimensional.md 为核心骨架,完整还原 motion 动画库中 Reorder 组件从"单轴方向猜测"到"二维位置碰撞检测"的设计演进——包括 1D/2D 两种几何算法的伪代码、
Reorder.Group与Reorder.Item的改动点、逐步实施与验证流程、Cypress 端到端测试方案,并结合当前仓库源码(packages/framer-motion/src/components/Reorder/与packages/motion-utils/src/array.ts)印证落地形态。读完你将理解:为什么网格(grid/折行 flex)场景必须用几何碰撞而非索引算术,如何设计一个不依赖 velocity 方向、天然防震荡的排序判定核心,以及该功能从计划到验证的全套工程流程。
一、背景:网格排序是 Reorder 最被期待、也最难做对的功能
计划文档开篇即点明动机:网格(grid)重排序是 Reorder 组件被请求最多的功能,对应 issue #1400——该 issue 自 2021 年开启、累计 25 条评论。更重要的是,这不是一个"从零开始"的功能:此前曾有一版实现被合并进主干(PR #1685),随后又被移除;维护者的复活尝试(PR #1862)最终以"quite buggy and feels off"(相当多 bug、手感不对)为由关闭,并留下一条硬性要求——复活版本"必须真正手感好、工作正常"。
计划文档对前一次失败做了三点归因,这也是本次设计的直接输入:
- 按轴以 velocity 符号门控交换:当指针短暂静止或斜向移动时,明明悬停在目标槽位上却毫无反应(方向猜测在指针非轴向运动时失效)。
- 用
itemsPerAxis模运算推断网格结构:隐含"所有条目尺寸一致、行完整无缺"的假设,遇到折行 flex 布局、末行不齐或混合尺寸时直接崩溃。 - 用索引算术(
index ± itemsPerAxis)而非几何移动条目,且每次拖拽事件只能交换一个相邻槽位,无法一次跨越多个位置。
这三条失败原因被逐一回避:排序判定不再依赖速度方向,不再假设网格规则,不再做索引算术——全部替换为基于几何位置的碰撞检测。
二、核心设计转向:位置碰撞检测(positional collision detection)
计划文档给出的替代方案,是 dnd-kit 等拖拽库采用的位置碰撞检测思路,其关键洞察来自仓库已有的数据资产:
registerItem收到的本来就是完整布局Box,而当前实现只取layout[axis]把交叉轴(cross axis)信息丢弃了。
也就是说,每个条目的注册Box(布局矩形)已经在手,只是没被利用。新的目标槽位判定因此极其直接:"被拖拽条目的投影中心落在哪个条目的 Box 内,就往哪里排"。由此得到三个连锁收益:
- velocity 彻底退出排序决策——方向的语义由几何(中心相对 Box 的位置)自然涌现,不再需要猜测;
- 顺带修复 2026-06-11 Reorder 审计中发现的 finding #4(1D 路径的 velocity 怪癖:旧代码在
velocity === 0时提前返回,导致指针静止时悬停目标槽位无反应); - 单次拖拽事件即可实现多位置跳跃(multi-position jumps),不再局限于相邻槽位。
关键行为不变式(整个设计的基石)
计划文档强调一个必须成立的不变式:拖拽期间,一旦排序提交、被拖条目的槽位发生变化,拖拽系统会把条目的 transform 重新归基(rebase),使注册布局 + 当前偏移 ≈ 当前视觉位置。当前 1D 算法能工作的证据是:1D 交换判定用layout.max + offset与邻居中心比较,而比较所依赖的 layout 是每次排序渲染后由onLayoutMeasure触发registerItem重新注册的新布局。如果偏移不被归基,每次 1D 交换都会因"旧偏移 + 已移动的布局"重复触发,排序会失控——而实际上它没有失控。2D 设计完全依赖同一不变式,计划文档明确要求在实施 Step 6 中实证验证,否则触发 STOP 条件。
三、API 设计:axis扩展为三值,默认行为不变
Reorder.Group accepts axis?: "x" | "y" | "both", default "y" (unchanged)axis="both"时,条目获得drag(双轴可拖),排序目标在 2D 空间内几何查找;- 默认
axis="y"保持不变,因此 SSR 输出的touch-action: pan-x(drag="y"的产物)不受影响——server.ssr.test.tsx 中精确断言了包含touch-action:pan-x的标记字符串,计划要求这些测试零改动通过; - 超出拓宽
axis类型之外的公共 API 表面不在范围内。
落地差异说明:计划文档写作时轴类型写为
"both";当前仓库 types.ts 中实际落地为export type ReorderAxis = "x" | "y" | "xy",语义等价("xy" 即双轴)。文中算法说明沿用计划的"both"表述,与源码对应时可理解"both"≡ 当前实现中的"xy"。
四、checkReorder新契约:返回移动指令而非新数组
计划要求重写 utils/check-reorder.ts,契约变化如下:
export interface ItemData<T> { value: T layout: Box // 完整 Box —— 原来是 Axis } export interface ReorderMove { from: number to: number } export function checkReorder<T>( order: ItemData<T>[], value: T, offset: Point, // 2D 偏移 axis: "x" | "y" | "both" ): ReorderMove | null // 返回 from/to 索引,而非新数组- velocity 不再是参数,方向由几何涌现;
- 返回值从"新数组"改为"移动指令
{from, to}",把"判定"与"应用"解耦,方便Group把 measured 序索引映射回完整values数组; Point需从motion-utils导入(计划要求核验其导出,若未导出则在types.ts内本地定义interface Point { x: number; y: number })。当前源码中Point已由motion-utils导出并直接导入使用,见 check-reorder.ts。
五、1D 路径算法:投影区间扫描,去掉 velocity 门控
当axis为"x"或"y"时,order仍按layout[axis].min排序(与现状一致)。新算法计算被拖条目的投影区间,然后寻找"中心已被跨越的最远条目":
const projectedMin = item.layout[axis].min + offset[axis] const projectedMax = item.layout[axis].max + offset[axis] let target = index // 向前扫描:所有中心 < projectedMax 的后续条目均已被跨越 for (let j = index + 1; j < order.length; j++) { if (centerOf(order[j].layout[axis]) < projectedMax) target = j else break } if (target === index) { // 向后扫描:所有中心 > projectedMin 的前置条目均已被跨越 for (let j = index - 1; j >= 0; j--) { if (centerOf(order[j].layout[axis]) > projectedMin) target = j else break } } return target === index ? null : { from: index, to: target }其中centerOf(a: Axis) = mixNumber(a.min, a.max, 0.5)(mixNumber来自motion-dom,取区间中点)。
该设计有两个计划文档明确指出的性质:
- 相邻场景下与现有一致:判定阈值恰好复刻当前行为——"前导边跨越相邻条目中心"触发交换,但不再需要 velocity 门控;
- 多位置跳跃天然支持:只要偏移足够大、跨越了两个及以上条目的中心,
target会落到最远被跨越者; - 不会前后双触发:排序后的非重叠条目保证向前/向后扫描不可能同时成立。
六、2D 路径算法:投影中心包含判定(containment)
当axis === "both"时,order保持注册顺序(=values顺序),不再按min排序。计算被拖条目的投影中心,找"Box 包含该点"的条目:
const projectedCenter = { x: centerOf(item.layout.x) + offset.x, y: centerOf(item.layout.y) + offset.y, } const target = order.findIndex( (entry, i) => i !== index && projectedCenter.x >= entry.layout.x.min && projectedCenter.x <= entry.layout.x.max && projectedCenter.y >= entry.layout.y.min && projectedCenter.y <= entry.layout.y.max ) return target === -1 ? null : { from: index, to: target }三个关键行为:
- 投影中心落入缝隙 → 无操作:gap 里没有任何 Box,
findIndex返回 -1,这是"正确"的——用户把条目拖进两个槽位之间的空白处,排序不该发生; - 混合尺寸天然支持:包含判定是逐条目的几何比较,与条目尺寸无关——一个 100×100 的条目拖进 200×100 条目的 Box 就触发移动,不依赖任何网格算术;
- 震荡被结构性防止:一次移动提交后,条目在排序渲染时通过
onLayoutMeasure重新注册新布局 Box(注册的是布局位置,而非动画中的视觉位置),于是被拖条目的投影中心会落在自己的新槽位内,而该槽位因i !== index(重新排序/注册后)被排除,指针不越过其他条目 Box 就不会再触发移动——直到指针真正进入另一个条目的 Box。
与当前源码的对应:仓库 check-reorder.ts 的
"xy"分支采用了行识别 + 最近距离的混合实现——先用getLines把条目按 y 轴区间聚成行(Line),若投影中心跨到不同行则moveToLine按 x 中心插入目标行;同行内用distanceToBox(点到矩形的最短欧氏距离平方)选择最近目标。这与计划的"纯包含判定"在"缝隙无操作、混合尺寸支持、防震荡"的语义目标上一致,但具体判定策略演进出更精细的行级处理。计划文档作为设计蓝图,与源码实现存在此层面的差异,属于可预期的实施演进。
七、Group.tsx改动:全 Box 注册、move 语义、虚拟化保护
计划文档给出 Group.tsx 的四处关键改动:
1. 注册完整 Box,且"both"时不排序
registerItem: (value, layout) => { const idx = order.findIndex((entry) => value === entry.value) if (idx !== -1) { order[idx].layout = layout // 原来是 layout[axis] } else { order.push({ value, layout }) } if (axis !== "both") order.sort(compareMin) // both 时保持注册顺序 },compareMin变为组件内闭包(a, b) => a.layout[axis].min - b.layout[axis].min(原先是模块级函数,需闭包捕获axis;计划提示用组件内局部箭头函数以控制产物体积)。"both"不排序的理由:条目按values顺序渲染,注册顺序就是 DOM 顺序,排序反而会破坏 2D 语义。
2.updateOrder应用 move 而非 swap
updateOrder: (item, offset) => { if (isReordering.current) return const move = checkReorder(order, item, offset, axis) if (!move) return isReordering.current = true const fromIndex = values.indexOf(order[move.from].value) const toIndex = values.indexOf(order[move.to].value) if (fromIndex !== -1 && toIndex !== -1) { onReorder(moveItem(values, fromIndex, toIndex)) } },把 measured 序索引映射回完整values数组的索引再调用moveItem,未测量条目(如虚拟化列表中屏幕外的项)因此得以保留。
3.moveItem语义辨析
moveItem位于 packages/motion-utils/src/array.ts:克隆数组、从fromIndex移除并插入toIndex。相邻索引时它等价于一次交换;距离较远时则把中间所有条目顺移——这正是网格回流(grid reflow)的正确语义。因此现有虚拟化单元测试("Preserves unmeasured items…",断言[1, 3, 2, 4, 5])必须在新签名下继续通过,仅需把调用从updateOrder(2, 30, 1)更新为updateOrder(2, { x: 0, y: 30 })。
4. 类型与上下文
ReorderContextProps中axis: "x" | "y" | "both"、updateOrder: (item: T, offset: Point) => void。
另外,Group还会在容器上设置overflow-anchor: none(当前源码 Group.tsx 中可见),防止浏览器滚动锚定在条目重排时调整滚动位置、干扰拖拽坐标计算——这是与设计配套的既有实现细节。
八、Item.tsx改动:双轴拖拽与双轴自动滚动
Item.tsx 的计划改动:
drag={axis === "both" ? true : axis} ... onDrag={(event, gesturePoint) => { const { velocity, point: pointerPoint } = gesturePoint updateOrder(value, { x: point.x.get(), y: point.y.get() }) if (axis === "both" || axis === "x") { autoScrollIfNeeded(groupRef.current, pointerPoint.x, "x", velocity.x) } if (axis === "both" || axis === "y") { autoScrollIfNeeded(groupRef.current, pointerPoint.y, "y", velocity.y) } onDrag && onDrag(event, gesturePoint) }}drag={true}经拖拽特性自动产出touch-action: none,无需手动处理样式;- 双轴模式会对 x、y 各发一次
autoScrollIfNeeded。该函数位于 utils/auto-scroll.ts:以 50px 为阈值窗口、25 为最大滚速,按"距边缘越近滚得越快"的平方强度计算滚动量,并通过WeakMap记录每容器初始滚动上限与激活边缘,防止无限滚动; Group的axis属性 JSDoc 中"To make draggable on both axes, set<Reorder.Item drag />"一行需要更新为提及axis="both"——计划文档特别指出,这行文档正是 #1400 困惑的原始来源。
九、实施步骤与逐级验证(Step 1–8)
计划文档为执行者规定了严格的逐步验证流程,每步都有机器可查的验证命令:
| 步骤 | 内容 | 验证 |
|---|---|---|
| Step 1 | 基线确认 | Reorder 单元测试全绿;yarn buildexit 0 |
| Step 2 | 先写新checkReorder的失败测试(utils/tests/check-reorder.test.ts) | 因新 API 缺失而编译/运行失败(预期) |
| Step 3 | 实现types.ts+check-reorder.ts | Step 2 全部测试转绿 |
| Step 4 | 更新Group.tsx/Item.tsx;更新虚拟化测试签名;在tests/index.test.tsx 扩展 2D 上下文级测试 | testPathPattern="Reorder\|check-reorder"全过;SSR 测试不变通过 |
| Step 5 | 更新 JSDoc | yarn lint、yarn buildexit 0 |
| Step 6 | 创建 dev/react/src/tests/reorder-grid.tsx(?test=reorder-grid)并手动验证关键不变式 | 页面渲染 9 个条目;无震荡、不瞬移 |
| Step 7 | 创建 cypress/integration/reorder-grid.ts,按 drag-to-reorder.ts 的指针事件模式编写 | React 18 与 19 均通过 |
| Step 8 | 全量验证 | 单元、client、SSR、lint、build 全部通过 |
常用命令速查
计划文档给出的命令表(均在仓库根执行):
make bootstrap # 安装依赖(仅需要时,前台执行) yarn build # 构建(禁止在包目录内执行) npx jest --config packages/framer-motion/jest.config.json --testPathPattern="Reorder|check-reorder" # 单元测试 cd packages/framer-motion && yarn test-client # 完整 client 测试 cd packages/framer-motion && yarn test-server # SSR 测试(Reorder 必须原样通过) yarn lint # 静态检查Cypress 双版本流程(必须前台执行,后台会静默挂起)
计划文档要求排序功能在React 18 与 React 19 两个版本上分别跑通:
# React 18 PORT=$((10000 + RANDOM % 50000)) cd dev/react && TEST_PORT=$PORT yarn vite --port $PORT & DEV_PID=$! npx wait-on http://localhost:$PORT cd ../../packages/framer-motion && npx cypress run --headed --config baseUrl=http://localhost:$PORT --spec "cypress/integration/drag-to-reorder.ts,cypress/integration/reorder-grid.ts" kill $DEV_PID # React 19(独立服务器、独立端口,使用 cypress.react-19.json 配置) PORT=$((10000 + RANDOM % 50000)) cd ../../dev/react-19 && TEST_PORT=$PORT yarn vite --port $PORT & DEV_PID=$! npx wait-on http://localhost:$PORT cd ../../packages/framer-motion && npx cypress run --config-file=cypress.react-19.json --config baseUrl=http://localhost:$PORT --headed --spec "cypress/integration/drag-to-reorder.ts,cypress/integration/reorder-grid.ts" kill $DEV_PID注意保留drag-to-reorder.ts作为1D 回归闸门——它验证新 1D 扫描逻辑没有破坏既有单轴手感。
手工验证场景(Step 6)
?test=reorder-grid页面:Reorder.Group as="div" axis="both",display: flex; flex-wrap: wrap; width: 340px,九个100×100px、margin: 5px、id="item-N"的条目,useState([0..8]),默认布局动画。手工检查三个现象:条目中心进入邻居槽位才触发排序、静止时无震荡(不快速来回交换)、不瞬移。若无法交互式运行浏览器,需在报告中说明,并依赖 Step 7 的 Cypress 中途拖拽断言。
Cypress 三个测试点
- 对角重排:
pointerdown在#item-0,约 5 步pointermove(每步wait(50))到 item 4 槽位中心(按 110px 单元格间距计算坐标),wait(100)后在拖拽中途断言 DOM 源码序中 item 0 已占据索引 4 的位置;pointerup后再次断言稳定序; - 缝隙拖放是 no-op:
pointerdown在#item-8,把中心移到两槽位之间的 margin 缝隙(偏移约 55px),断言顺序不变; - 无震荡:测试 1 移动后指针静止 500ms,用
.then()在两个相隔 300ms 的时间戳捕获顺序并断言不变——不能用.should(),因为它会重试直到通过,从而掩盖震荡。
当前仓库中 reorder-grid.tsx 与 reorder-grid.ts 均已落地(页面改为
display: grid双列布局 +data-testid断言顺序,测试通过?test=reorder-grid访问并断言中途current-order变为b,c,d,a),说明该计划的功能已进入实现与端到端验证阶段。
十、范围边界与 STOP 条件
In scope(仅允许改动的文件):Reorder/types.ts、Reorder/utils/check-reorder.ts、Reorder/Group.tsx、Reorder/Item.tsx,新建utils/__tests__/check-reorder.test.ts、扩展__tests__/index.test.tsx、新建dev/react/src/tests/reorder-grid.tsx与cypress/integration/reorder-grid.ts。
Out of scope(看着相关但严禁触碰):拖拽手势系统(src/gestures/drag/)、投影系统(src/projection/)——若设计需要改到它们,那是 STOP 条件而非邀请;utils/auto-scroll.ts内部(只调用不修改);自动轴检测(从布局换行推断"both")被明确推迟;SSR 标记期望。
STOP 条件(出现即停止并上报,不得自行发挥):
- 关键不变式失败:Step 6/7 中条目震荡或 2D 移动后瞬移——修复大概率在拖拽/投影系统(超出范围);
- 现有
drag-to-reorder.ts在任一 React 版本失败且根因在新 1D 扫描逻辑——不得调阈值硬凑,1D 手感契约是"前导边跨越邻居中心",偏离需维护者批准; - SSR 标记测试需要改动(意味着默认
drag/touch-action变了,默认轴必须保持"y"); Point未从motion-utils导出且本地定义与包内既有导入冲突;- 虚拟化测试在 move 语义下无法在不削弱断言的前提下通过;
- 实现被迫触碰
src/gestures/或src/projection/。
十一、完成标准(全部可机器检查)
计划文档给出六条可勾选的完成标准:Reorder/check-reorder 单元测试 exit 0 且check-reorder.test.ts存在 ≥10 个用例;grep -n "velocity" check-reorder.ts无匹配(velocity 彻底退出判定核心);grep -n '"both"' Group.tsx有匹配;Cypressreorder-grid.ts与drag-to-reorder.ts在 React 18/19 双通过;SSR 测试零改动;lint/build exit 0 且git status干净(范围外无改动);plans/README.md状态行更新。
十二、维护笔记:后续方向与已知关注点
计划文档在末尾留下一组明确的后续方向,值得读者关注:
- 自动轴检测(deferred):维护者 PR #1862 的关闭评论中包含"从布局自动检测轴"的期望,但本计划刻意将其排除——因为它会改变默认行为(进而影响 SSR
touch-action输出)并放大手感风险。一旦axis="both"发布且手感良好,自动检测可作为小跟进:首次测量后,若注册 Box 横跨 >1 个行带且 >1 个列带,则按"both"行为处理。当前仓库其实已先行实现detectAxis(utils/detect-axis.ts):两两比较布局在 x/y 上的分离性,x与y均分离即返回"xy",仅 x 分离返回"x",否则"y",且Reorder.Group在未显式传axis时用useState动态检测——这已部分消化了该 deferred 项; - 发布前必须做手感评审(feel review):前一代实现死于"手感"而非"正确性"。评审清单:槽位边界无震荡(containment + 重注册设计应能阻止;边界情况是中心恰好落在 Box 边缘)、快速对角甩动行为、整体拖出 Group 的行为;
- 与自动滚动的交互:
"both"现在双轴自动滚动(两次autoScrollIfNeeded调用)。封顶于初始滚动上限的逻辑按滚动容器独立、未变,但x+y 同时自动滚动从未被实际验证过——QA 若发现异常,优先排查此处; - #2603 请求的更丰富
onReorder签名((newOrder, {value, from, to}))在updateOrder计算fromIndex/toIndex后几乎免费可得,但被刻意排除(API 增加需维护者批准),计划要求记入 PR 描述; - issue #1400 的关联方式:若维护者认可显式
axis="both"(无需自动检测)即满足需求,则 PR 中Fixes #1400;否则Refs #1400。
十三、工程流程参考
实施遵循 monorepo 规范:分支improve/018-reorder-multidimensional基于main(在 015 合并后,或按操作者指示 rebase);每个步骤单独提交,提交信息用简短祈使句(仓库示例:Add auto-scroll support to Reorder.Group、Fix Reorder.Group axis change during window resize);除非操作者指示,不 push、不开 PR。开工前需先执行漂移检查git diff --stat 42bfbe3ed..HEAD -- packages/framer-motion/src/components/Reorder/ packages/framer-motion/src/context/ReorderContext.ts——计划 015(条件 hook 修复)与 016(仅 JSDoc)对同文件的改动是预期漂移,其余任何改动都按 STOP 条件处理。
总结:这份计划的价值在于把"网格重排"这一拖拽排序里手感最脆弱的场景,收敛为一个可证明的几何判定核心——1D 用投影区间扫描、2D 用投影中心包含,方向由几何涌现、velocity 出局、震荡被结构性防止,再以"单元测试 → 手工不变式验证 → 双版本 Cypress 回归"三层测试体系兜底。对照当前仓库源码可见,计划的"both"/"xy"双轴能力、自动轴检测、reorder-grid演示页与端到端测试均已落地,几何碰撞方案已成为 Reorder 组件可运行的现实能力。
- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
相关推荐
Vue-Draggable-Plus 拖拽方向检测实现方案
Vue Draggable Plus 拖拽方向检测实现方案 背景介绍 在使用Vue Draggable Plus这个Vue拖拽库时,开发者有时需要获取元素被拖拽
前端UI组件告别混乱排版:LogicFlow节点拖拽的智能对齐与碰撞检测实现
告别混乱排版:LogicFlow节点拖拽的智能对齐与碰撞检测实现 在流程图编辑场景中,节点拖拽的精准度直接影响用户体验。当拖拽节点时出现位置偏差、对齐困难或元素
前端低代码流程编排Area51碰撞组优先级编辑器:拖拽排序界面
Area51碰撞组优先级编辑器:拖拽排序界面 功能概述 碰撞组优先级编辑器是Area51游戏引擎中的核心工具,用于管理物理碰撞检测的执行顺序。通过拖拽排序界面,
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考