资讯动态

修复 position: sticky 容器内拖拽 snap-to-cursor 的垂直偏移:motion(framer-motion)issue-2236 方案解析

发布时间:2026/9/30 10:53:18 来源:尧图企业网站定制
前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载本文基于仓库 plans/issues/issue-2236.md 计划文档结合framer-motion拖拽引擎源码完整剖析「可拖拽元素位于position: sticky容器内、容器吸顶后 snap-to-cursor 出现垂直偏移」这一 bug 的根因、复现测试设计与修复方案。读完你既能复刻出该 bug 的 Cypress 回归测试也能理解拖拽引擎中「布局测量时机」与「滚动语义」之间的微妙关系并掌握如何用一行「重新测量」消除整类 stale-measurement 缺陷。问题现象吸顶之前正常吸顶之后失准Issue #2236 的场景描述非常精确一个可拖拽元素位于position: sticky容器内页面尚未滚动到吸顶点时通过dragControls.start(event, { snapToCursor: true })触发拖拽元素会精确吸附到指针位置一旦 sticky 容器进入「吸顶stuck」状态再触发拖拽元素在垂直方向上出现明显偏移且不再跟随指针移动。计划文档明确指出snapToCursor: true是「点击即吸附到指针」的唯一入口路径见 VisualElementDragControls.ts 中DragControlOptions接口定义因此修复范围被精准圈定在VisualElementDragControls这一个类内。该 issue 与 #1535 以及若干布局位移变体同属一个「滚动后陈旧测量」问题家族与本仓库的另一个计划 plans/issues/issue-2024.md嵌套滚动容器中 ref 约束偏移共享根因测量发生在挂载时滚动之后不再刷新。根因分析陈旧的页面盒测量遇上 sticky 的滚动语义snap 逻辑读取的测量数据何时被捕获在start()的onSessionStart回调中当前代码 VisualElementDragControls.ts拖拽会话一旦开始就立即调用this.snapToCursor(event)中间没有任何重新测量的机会const onSessionStart (event: PointerEvent) { if (snapToCursor) this.snapToCursor(event) this.stopAnimation() }而snapToCursor内部计算「元素中心对齐指针」所需的盒子数据其测量时机在监听器建立阶段addListeners()中只有当projection尚未拥有layout时才会调用一次projection.updateLayout()当前代码 VisualElementDragControls.ts此后手势开始前再也不会刷新if (projection !projection!.layout) { projection.root projection.root.updateScroll() projection.updateLayout() }计划文档给出的原版公式为axisValue.set(point[axis] - mixNumber(min, max, 0.5) current)其中point取自 event-info.ts 中的pageX/pageY页面坐标系layoutBox则假设是包含当前 transform 的当前页面盒。在计划撰写时的代码树中该布局数据来自projection.layout——即挂载时测量、此后未再刷新的快照。需要说明的是当前仓库树中的snapToCursor实现VisualElementDragControls.ts已改为通过measureViewportBox()视口坐标系实时读取盒子并借getTransformPagePoint()对指针点做变换归一化但「测量与手势开始的时序关系」「页面坐标系与视口坐标系的差异在吸顶元素上不再互相抵消」这两个核心矛盾完全一致本方案针对的就是这套测量时序与坐标系语义。为什么偏偏是吸顶的 sticky 元素出问题关键在于「页面盒 视口盒 根滚动偏移」这一换算关系见 create-projection-node.ts 的measurePageBox()measurePageBox() { const { visualElement } this.options if (!visualElement) return createBox() const box visualElement.measureViewportBox() const wasInScrollRoot this.scroll?.wasRoot || this.path.some(checkNodeWasScrollRoot) if (!wasInScrollRoot) { // Remove viewport scroll to give page-relative coordinates const { scroll } this.root if (scroll) { translateAxis(box.x, scroll.offset.x) translateAxis(box.y, scroll.offset.y) } } return box }据此推导出两种截然不同的滚动行为普通元素视口盒随滚动同步变化加回滚动偏移后得到的页面盒是「滚动不变量」——因此挂载时的测量永远有效吸顶stuck的 sticky 元素视口盒反而变成滚动不变量其真实页面盒会随着越过吸顶点后的每一个滚动像素而改变。于是挂载时缓存的projection.layout恰好陈旧了scrollY - stickPoint那么多像素——这与上报症状吸顶后才出现垂直偏移完全吻合。这也解释了为什么同类的 #2829页面加载即恢复滚动位置问题只影响 ref 约束场景普通元素对根滚动天然免疫而吸顶元素把「视口坐标」与「页面坐标」的差异从常数变成了随滚动变化的变量。现成的修复先例resolveRefConstraints 的根滚动缓存清理方案并非从零发明同一文件里已经存在一模一样的修复模式。resolveRefConstraints()VisualElementDragControls.ts在测量 ref 约束元素之前会先清空投影根节点的滚动缓存并强制重读以绕过按animationId缓存的滚动值对应提交cfccb0300由 issue-2024 / #2829 引入if (projection.root) { projection.root.scroll undefined projection.root.updateScroll() }该注释道出了本质「拖拽挂载时捕获的滚动在文档随后滚动之后可能已陈旧——例如浏览器在刷新时恢复滚动位置或祖先布局副作用在本元素挂载之后才运行」。snap-to-cursor 的修复方案正是把这一「先清缓存、再刷新滚动」的纪律迁移到snapToCursor上。修复方案吸附前重新测量当前页面盒Step 2在 snapToCursor 顶部刷新测量在snapToCursor的eachAxis循环之前插入重新测量逻辑计划文档给出的核心补丁const { projection } this.visualElement if (projection) { if (projection.root) { projection.root.scroll undefined projection.root.updateScroll() } projection.updateLayout() }其原理链如下updateLayout()会重新执行measure(false)见 create-projection-node.ts产出「包含当前 transform、反映当前滚动位置」的页面盒——这正是既有 current公式所假设的数据形态根滚动缓存的清空与resolveRefConstraints()第 420–423 行完全镜像确保measure(false)读到的是实时滚动值而不是按animationId缓存的旧值。一个值得注意的副作用updateLayout()在测量完成后会触发投影节点的measure事件create-projection-node.ts。该事件被addListeners()中的measureDragConstraints监听进而调用resolveRefConstraints()VisualElementDragControls.ts。对吸顶元素而言这同时顺带刷新了 ref 约束——是期望中的收益但计划文档提醒Step 3 的回归套件必须观察是否存在「双重解析」的副作用例如每帧重复解析、约束被反复重置。测试先行Cypress 失败测试的设计与坐标纪律计划的核心执行纪律是「先写失败测试再写修复」对应 issue-2024 计划中的 no-repro 规则。由于该 bug 强依赖 sticky 吸顶后的滚动状态测试页与断言设计都很有讲究。测试页drag-snap-to-cursor-sticky.tsx测试页dev/react/src/tests/drag-snap-to-cursor-sticky.tsx导出App需新建以既有的 drag-snap-to-cursor.tsx外部触发式dragControls.start(e, { snapToCursor: true })的典型示范页为蓝本页面主体内容高 3000px确保可滚动顶部 300px 的 spacer之后是 sticky 容器div style{{ position: sticky, top: 0, height: 200 }}内含一个 200×200 的指针触发区idtriggeronPointerDown{(e) dragControls.start(e, { snapToCursor: true })}motion.div>cy.get(#trigger).trigger(pointerdown, { clientX: 150, clientY: 100, pageX: 150, pageY: 700, force: true, })跟随一次小幅pointermove5, 5同样遵守双坐标系纪律与pointerup.wait(200)用.then()断言拖拽元素getBoundingClientRect()中心点约等于客户端坐标 (155, 105)容差 ±10。修复前预期 y 中心偏差约为「600 减去吸顶距离」需要记录实际差值。计划文档给出明确的验证预期该 spec 在计划基线提交42bfbe3ed上必须失败出现垂直偏移。如果构造不出失败先排查pageY是否真正传到了页面的onPointerDown处理器在页面里console.log验证尝试 2–3 种变体不同滚动量、不同吸顶偏移仍失败则按 no-repro 规则 STOP并判断是合成事件的坐标管线问题还是 Electron/Chromium 的 sticky 行为问题可尝试--browser chrome。回归套件与命令用途命令预期构建yarn build仓库根目录exit 0Cypress React 18/19CLAUDE.md 中的配方--spec cypress/integration/drag-snap-to-cursor-sticky.ts修复后通过Jest 拖拽单测npx jest --config packages/framer-motion/jest.config.json --testPathPatterngestures/drag全部通过回归验证还包括新 spec 在 React 19 上通过既有drag.ts、drag-ref-constraints-absolute-scrolled.ts#2829 的门禁测试与本修复共享代码路径必须保持绿色、drag-ref-constraints-element-resize.ts、drag-tabs.ts在 React 18 上通过。遇到偶发失败先重跑一次同一 spec 连续两次失败即视为真实回归STOP。测试计划要求在同一个 spec 中覆盖两个分支吸顶状态bug 本体与未滚动状态scroll0第二测试防止修复回归正常路径并在 React 18 与 19 双版本上运行。范围边界与停止条件计划文档对改动范围做了严格约束范围内packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts仅snapToCursor和/或onSessionStart新建dev/react/src/tests/drag-snap-to-cursor-sticky.tsx新建packages/framer-motion/cypress/integration/drag-snap-to-cursor-sticky.ts。范围外让投影系统通用地建模 sticky/fixed 定位约束、sticky 下的布局动画——这属于投影层级别的独立项目剩余缺口应在 PR 描述中注明PanSession、动量、约束解析逻辑。停止条件Step 1 无法产出失败测试按 no-repro 规则报告修复需要改动create-projection-node.ts来建模 sticky超出范围上报snapToCursor中的updateLayout()破坏了布局动画类 Cypress speclayout.ts系列——此时备选方案是改用本地测量getBoundingClientRect 实时滚动而不写回projection.layout但这会偏离 current公式的假设需要维护者决策。维护备注与后续演进该修复只解决 snap-to-cursor。sticky 下的拖拽约束仍然近似要等到下一次measure事件才刷新完整的 sticky/fixed 故事属于投影系统应作为已知限制记录在 PR 中。本文件处于计划 019–021拖拽引擎移植到 motion-dom的覆盖范围内re-measure-on-snap行为必须随移植保留——它同时也是计划 020 中 vanilladrag()想要的正确行为。交付完成标准包括新 spec吸顶 未吸顶两分支在 React 18/19 通过、吸顶分支在修复前失败、既有拖拽 Cypress 与 Jest 套件通过、仅改动范围内文件、并在 plans/issues/README.md 中更新本 issue 的状态行。延伸阅读plans/issues/issue-2236.md本方案原始计划文档含完整命令表与 STOP 条件VisualElementDragControls.ts拖拽控制核心snapToCursor/resolveRefConstraints/addListeners均在此create-projection-node.ts投影节点measurePageBox页面盒换算与updateLayout测量事件measure.ts视口盒 / 页面盒测量的底层工具event-info.tspageX/pageY页面坐标系指针信息的来源drag-snap-to-cursor.tsx外部触发式 snap-to-cursor 的既有示范页drag-ref-constraints-absolute-scrolled.tsx?scroll参数 useLayoutEffect滚动模拟模式的出处plans/issues/issue-2024.md同家族「陈旧测量」问题的姊妹计划提供cfccb0300缓存清理先例赞分享前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐Fluent Bit 内嵌的 lwrb 轻量环形缓冲区库从 v1.0.0 到 v2.0.3 的版本演进与实现原理解析Fluent Bit 内嵌的 lwrb 轻量环形缓冲区库从 v1.0.0 到 v2.0.3 的版本演进与实现原理解析 导读 lwrbLightweight前端UI组件深入 go-openapi/jsonpointernhost 仓库中基于 Go 结构体的 JSON PointerRFC 6901实现解析与实战深入 go openapi/jsonpointernhost 仓库中基于 Go 结构体的 JSON PointerRFC 6901实现解析与实战 导读 本前端UI组件上一篇Vuetify UnoCSS (presetWind4) 集成指南以 TailwindCSS v4 类名按需生成样式下一篇CANN/pypto Matmul性能优化指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑