Scanat 支持复制测量项:用户在 3D 模型上完成一条长度或一块面积测量后,可以复制一份,再移动到新的位置继续使用,不必重新测量。
副本需要能够整体移动。问题在于,用户应该拖什么来完成这个动作?
一条长度测量包含两个真实几何端点,以及一个显示测量结果的数字标签,例如"328mm"。整体移动既可以由端点触发,也可以由标签触发,但两种方式对应不同的操作重点:前者更利于精确落点,后者不需要额外的模式切换。
我和产品经理最初分别倾向不同的方案。讨论之后才发现,分歧并不在于哪个手势"更自然",而在于两个不同的操作目标:降低模式成本,还是保证端点的精确落位。
| 维度 | 方案 A:拖端点 | 方案 B:拖数字标签 |
|---|---|---|
| 精确落点 | 强。手指或鼠标始终对应真实几何点,松手位置就是端点位置。 | 弱。标签与端点存在偏移,需要目测补偿。 |
| 模式成本 | 需要区分"调整端点"和"整体移动"。 | 不需要额外模式,拖端点与拖标签天然区分。 |
| 界面占用 | 需要提供模式入口。 | 不需要额外 UI。 |
| 操作对象 | 真实几何端点。 | 系统生成的展示标签。 |
两种方案在单点调整上没有区别:拖动端点都只修改当前端点。需要决定的只有一件事:整体移动由什么触发。
把问题收窄之后,取舍也更明确。方案 B 的优势是简单,不需要引入模式;方案 A 的优势是精确,尤其适合需要把某个端点从 A 点准确移动到 B 点的场景。
两套手势都先做进可运行原型,在 iPad、iPhone 和桌面浏览器上直接比较。最终方案的重点落在方案 A 的改造:保留端点作为整体移动的操作对象,但把原本常驻的模式开关改成按需出现。
这样处理后,需要重新回答的已经不是"要不要模式切换",而是"模式切换是否需要一直显示"。模式本身仍然存在,但只在相关任务发生时出现。
两套方案在实现上互斥,因此状态也需要明确表达这种从属关系。
场景内部使用 moveScheme: 'endpoint' | 'label' 决定当前采用哪一种整体移动方式;moveMode: 'endpoint' | 'whole' 只在端点方案下生效,用来区分单点调整和整体移动。
moveScheme = 'label' moveScheme = 'endpoint'
│ │
├─ 拖端点 → 单点调整 ├─ moveMode = 'endpoint' → 单点调整
└─ 拖标签 → 整体移动 └─ moveMode = 'whole' → 整体移动早期版本把这些状态拆成独立布尔值,出现过两套逻辑同时生效的冲突。改成从属状态后,任何时刻只有一条明确的交互路径。
同一个"整体移动"语义,在桌面和触屏上也不需要使用完全相同的触发方式:
底层的位移和表面贴合逻辑相同,只把"什么时候进入拖拽状态"交给不同输入设备分别处理。
真机测试暴露了两个静态设计稿里不明显的问题。
1. 背景变化后的文字对比度。 移动模式面板从深色胶囊改成白色悬浮卡片后,标题仍沿用了旧的浅灰文字颜色,在浅色背景上几乎不可见。这个问题与交互逻辑无关,但说明容器样式变化时,依赖背景的文字颜色也需要一起检查。
2. 新手势与已有行为冲突。 设计稿里原本写着"点击空白处收起面板"。但在 3D 场景中,点击空白区域已经绑定了"取消选中当前测量项"。
最后让两件事保持一致:取消选中时,面板自然收起。更重要的是,这次检查说明,像"点击空白处"这样的描述不能只看当前功能,还需要确认同一手势在场景里是否已经承担其他行为。
这次讨论里,我一开始理解错了产品经理的论据。我以为对方强调"端点是更合理的操作对象",后来才确认,他关注的是"用户希望把某个具体端点准确移动到目标位置"。两句话听起来接近,但对应的是不同的判断依据。
重新按这个前提比较后,端点方案的精确性才成为一个明确的设计优势。
对设计讨论来说,一个很实用的习惯是:在反驳之前,先把对方的判断复述一遍,确认双方讨论的是同一个问题。逻辑是否成立,取决于前提是否一致。
复制后自动展开面板。 复制测量项时,内部先选中新副本并把 moveMode 重置为 'endpoint',随后在端点方案下再切到 'whole'。UI 没有通过 useEffect 根据 selectedId 猜测这次选中来自哪里,而是直接利用两个回调已有的固定顺序:先关闭,再由后续的模式变化重新打开。普通选中只触发第一步,因此最终仍保持关闭。
setSelected(copy.id);
if (state.moveScheme === 'endpoint') {
state.moveMode = 'whole';
callbacks.onMoveModeChange?.('whole');
}拖拽开始时收起面板。 单点调整和整体移动最终都会经过 beginEndpointOrWholeMove()。因此"开始拖拽后收起面板"只需要挂在这个统一入口,不必分别写进鼠标点击、长按计时器等调用位置。
function beginEndpointOrWholeMove(id, index, clientPoint) {
callbacks.onMoveInteractionStart?.();
if (state.moveScheme === 'endpoint' && state.moveMode === 'whole') {
// 整体移动
} else {
// 单点调整
}
}