· 5 分钟阅读 · 1807 字
第二根手指抬起后,道具拖拽为何停住:输入状态需要事务所有者
道具已经被指针 7 从屏幕纵坐标 300 拖到 240,预览位置因此从 0 变成 -60。此时另一根手指以指针 12 点了一下空白区域并抬起;若组件只保存一个 dragging 布尔值,第二根手指的 Up 会把它清零。指针 7 随后移动到 210 不再生效,道具停在 -60,既没有到达手指终点,也没有回到原位。

这个错误表面上是少检查一次 pointerId,实际缺少的是一次拖拽的提交边界。移动中的位置用于即时显示,尚未成为业务状态;只有捕获该拖拽的指针能够提交终点。取消、窗口失焦或上层手势接管则应丢弃预览,让道具回到上一次已提交位置。把这些事件都改写为对同一个布尔值的开关,无法表达谁拥有事务,也无法区分提交和回滚。
预览位置不能冒充已提交位置
探针把状态拆成 CommittedY 与 PreviewY。Begin 捕获唯一所有者,同时冻结本次位移的屏幕起点与业务起点;Move 只更新预览;End 必须先消费抬起事件携带的终点坐标,再提交预览。终点不能依赖最近一次 Move,因为输入适配层可能合并移动事件,释放坐标仍可能比最后一帧移动得更远。
核心实现位于 .tmp/daily-labs/2026-10-28-1930/DragTransaction.cs:
public readonly record struct DragState(
bool IsDragging,
int? PointerId,
float CommittedY,
float PreviewY);
public sealed class DragTransaction
{
private int? _pointerId;
private float _pressY;
private float _baseY;
public float CommittedY { get; private set; }
public float PreviewY { get; private set; }
public DragState State => new(_pointerId.HasValue, _pointerId, CommittedY, PreviewY);
public bool Begin(int pointerId, float screenY)
{
if (_pointerId.HasValue || !float.IsFinite(screenY))
return false;
_pointerId = pointerId;
_pressY = screenY;
_baseY = CommittedY;
PreviewY = CommittedY;
return true;
}
public bool Move(int pointerId, float screenY)
{
if (_pointerId != pointerId || !float.IsFinite(screenY))
return false;
var candidate = _baseY + screenY - _pressY;
if (!float.IsFinite(candidate))
return false;
PreviewY = candidate;
return true;
}
public bool End(int pointerId, float screenY)
{
if (!Move(pointerId, screenY))
return false;
CommittedY = PreviewY;
_pointerId = null;
return true;
}
public bool Cancel(int pointerId)
{
if (_pointerId != pointerId)
return false;
Rollback();
return true;
}
public bool LoseFocus()
{
if (!_pointerId.HasValue)
return false;
Rollback();
return true;
}
private void Rollback()
{
PreviewY = CommittedY;
_pointerId = null;
}
}
有限值检查必须包住运算结果,而不只检查输入。两个有限 float 相减仍可能溢出为无穷大;探针用 -float.MaxValue 到 float.MaxValue 覆盖了这条路径。候选值验证通过前不写 PreviewY,拒绝事件不会留下半次更新。
End 内部的顺序同样不能倒置:先按终点坐标调用 Move,验证成功后提交,最后释放 _pointerId。如果组件先释放所有者,再发现终点是 NaN 或计算溢出,它既不能提交,也无法接受随后到达的取消事件,事务会停在一个没有所有者的预览位置。当前实现遇到无效终点时完整保留活动事务,输入适配层仍可用同一身份重试正常结束,或显式发送取消。
乱序终止事件只能碰自己的事务
错误样本依次执行 Begin(7, 300)、Move(7, 240)、Begin(12, 180)、End(12)。错误实现的 End 不核对所有者,结果如下:
{
"owner": null,
"preview_y": -60,
"pointer_7_move_accepted": false
}
正确实现拒绝指针 12 的终止事件,状态仍是 owner=7, committedY=0, previewY=-60。指针 7 在坐标 210 抬起时,终点位移 210-300=-90 被纳入提交,最终得到 committedY=-90, previewY=-90。重复的指针 7 Up、非所有者 Cancel 和无活动事务时的失焦都返回 false,不会修改已经提交的结果。

取消走另一条路径:从已提交位置 -90 开始,指针 7 把预览拖到 -190;Cancel(7) 不提交 -190,而是恢复 PreviewY=-90 并释放所有者。渲染组件读取预览即可保持跟手,物品排序或槽位模型只读取已提交值,两者不需要争抢同一个可变字段。
输入适配层负责身份,不负责业务提交
鼠标、触摸与笔输入可以共用这套事务,但平台事件必须先被适配成三个合同:一次接触期间稳定的指针身份、统一坐标系中的位置、带终点坐标的正常结束。窗口失焦、系统取消和手势仲裁失败统一映射为取消,不伪造成正常结束。吸附、容器边界、槽位合法性与跨窗口拖放仍属于业务候选验证,不应塞进输入身份适配器。
探针把屏幕纵向坐标与预览位移按 1:1 像素计算,只为了隔离事务问题。实际 Canvas 存在缩放、旋转或嵌套变换时,适配层应先把 Begin、Move 与 End 都转换到同一个容器局部坐标系,再把局部坐标交给事务;不能让起点使用屏幕坐标、终点使用局部坐标。坐标转换失败也属于拒绝事件,不能用零值代替后继续提交。
回滚策略还取决于交互语义。本文处理的是道具位置候选:取消意味着放弃本次搬运,所以预览恢复到已提交位置。若对象换成带惯性的滚动列表,系统取消可能需要把当前预览固化为新的滚动起点,再终止速度采样;那是另一种提交政策,但所有者校验、终点归属与非所有者事件隔离仍然不变。事务边界可以复用,取消政策不能靠事件名称硬编码成全局规则。
这层还有一个明确上限:如果平台在迟到事件尚未排空时复用同一个 pointerId,仅比较整数身份无法区分新旧接触。存在这种输入源时,适配层需要把接触代次并入身份;本探针没有假装解决它。
当天在 .NET 9 Release 下执行:
$ cd "$WORK_DIR/.tmp/daily-labs/2026-10-28-1930"
$ dotnet build PointerDragLab.csproj -c Release --nologo
Build succeeded. 0 Warning(s), 0 Error(s). Exit code: 0
$ dotnet run --project PointerDragLab.csproj -c Release --no-build --nologo
passed=15 failed=0 skipped=0 samples=10000 elapsed_ms=61.5222
fixed: pointer-12-up -> owner=7, committedY=0, previewY=-60
fixed: pointer-7-up-at-210 -> owner=null, committedY=-90, previewY=-90
Exit code: 0
10,000 组固定种子输入分别打乱非所有者 Begin、Move、End 与 Cancel 的顺序,活动事务均保持不变。当前切片没有覆盖拖拽吸附、惯性或多选手势;它只固定更早的边界:预览由当前所有者驱动,提交属于所有者的终点,取消只做回滚。缺少这条边界,后续任何槽位校验或跨平台适配都会建立在可能被旁观指针提前终止的状态上。
对应回归位于 .tmp/daily-labs/2026-10-28-1930/Program.cs:owner-endpoint-is-committed 固定终点提交,out-of-order-secondary-up 与 duplicate-up-is-idempotent 固定乱序和重复终止,cancel-rolls-back-preview 固定取消回滚,overflowing-delta-is-atomic 固定有限输入产生非有限候选时的原子拒绝。