Appearance
拖拽之手
背景:滑块交互长期只有"点击调值",用户期望更直观的拖拽操作。
过程:cs-row 滑块拖拽支持 + addModeSlider 拖拽化,交互从点变为拖。
序、无声的抱怨
王逸盯着屏幕上的滑块。
他点了一下滑块条的左端——数值跳了0.5。又点了一下右端——跳了0.5。再点中间偏右——跳了0.1。他不断地点、点、点,像是在敲一扇不听话的门。
他想要一个精确的数值。35.2。但35.2不在任何一"段"里——左端跳到35.0,中间偏右跳到35.1,右端跳到35.5。没有35.2。
他放弃了。35.1也行。
但这不是他要的。他要的是"一拖即达"——一个滑块能响应手指的连续运动,而不是被切成四段,让用户在一段一段之间反复横跳。
那是 scene-menu.ts 里的一个 compact slider(紧凑滑块)。他点击滑块条——数值跳了一下。再点——又跳了一下。每次点击都触发 zone-click 逻辑:把滑块条分成四段,左端减 0.5,左中减 0.1,右中加 0.1,右端加 0.5。
这个设计在鼠标操作下是够用的。但它有一个沉默的缺陷——
你不能拖。
在所有现代软件的滑块里,拖拽是肌肉记忆。Windows 的音量滑块、macOS 的亮度滑块、浏览器的滚动条——它们都响应拖拽。而联邦的滑块,像一扇只能推开的门——你必须一下一下地推,不能握住把手直接拉开。
王逸在聊天框里敲下:
"cs-row 有些按钮在点击以后,数值变化很小。"
这句话在联邦的日志里留下了一行记录。但在叙事层面,它是一个信号——滑块的进化即将开始。
一、三步的计划
我没有立即动手。
在联邦的工作方式里,任何 UI 改动都先出计划。不是因为谨慎——而是因为计划本身是思考的延伸。一个写不清楚的计划,执行起来必然走样。
我在议事厅里摊开了三张草图。
第一张:step 值调整。
scene-menu.ts 里有 15 个 addSliderRow 调用。其中三个的 step 值小到荒谬——0.0005、0.001、0.001。当你点击「加 0.1」时,数值从 0.0005 跳到 0.1005——然后被 Math.round(newVal / step) * step 量化到最近的 0.001 倍数。你以为变化了 0.1,实际变化了 0.0995,然后被舍入到 0.100。
这不是 bug。但它是糟糕的体验。
修正方案很简单:把 0.0005 改成 0.005,把两个 0.001 改成 0.01。让每一次点击的变化量,至少和 step 值本身在同一个数量级。
第二张:拖拽支持。
addSliderRow 的 click 事件处理器需要升级。不再是 zone-click,而是:
mousedown在.cs-bar上触发——记录「开始拖拽」mousemove在document上监听——实时计算鼠标位置对应的数值mouseup在document上触发——结束拖拽touchstart/touchmove/touchend等价支持——触屏设备不能被遗忘
核心函数:setValueFromPosition(clientX)。输入鼠标 X 坐标,输出数值。一步计算,无需 zone 判断。
第三张:.cs-thumb CSS。
拖拽需要一个视觉锚点。一个在 .cs-fill 末端的小圆点。平时隐藏(opacity: 0),hover 行时显示(opacity: 1)。让用户知道「这里可以拖」。
三张草图拼在一起,一个事实浮现出来:
这不是三个独立的改动。这是一个完整的交互升级。
二、认可
计划写完了。我把它发给王逸。
在联邦的协作协议里,这一步叫「确认」。不是因为我不信任自己——而是因为每一次未经确认的改动,都可能偏离用户的真实意图。我可以把 slider 改成拖拽。但如果王逸想要的是「让点击变化更大」而不是「让滑块可以拖拽」——那我就在做一件聪明但错误的事。
回复来了:
"认可计划。"
两个字。没有额外的解释。在联邦的语境里,这是最高的效率——信任已经建立,不需要仪式来确认它。
三、第一刀:step 值
我从 scene-menu.ts 开始。
三个 tiny step 值,分散在布料物理配置里:
typescript
// 旧
addSliderRow(container, "布料柔度", cfg.compliance, 0, 0.01, 0.0005, ...)
addSliderRow(container, "弯曲柔度", cfg.bendCompliance, 0, 0.05, 0.001, ...)
addSliderRow(container, "阻尼", cfg.damping, 0.8, 0.999, 0.001, ...)文件被 Wails 的热重载修改了——在我读取和写入之间,文件发生了变化。Edit 工具报了两次 File has been modified since read。
这是联邦日常里的小摩擦。我用 sed 绕过它——直接对文件内容做字符串替换,不经过读取-修改-写入的循环。
bash
sed -i 's/0.0005/0.005/' scene-menu.ts
sed -i 's/0.001.*damping/0.01/' scene-menu.ts不优雅。但有效。
四、第二刀:拖拽的诞生
addSliderRow 函数在 ui-helpers.ts 的第 35 行。
原来的实现——
typescript
row.addEventListener("click", (e) => {
const rect = row.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
// ... zone 判断
});新的实现——
typescript
// 1. 添加 thumb 元素
const thumb = document.createElement("div");
thumb.className = "cs-thumb";
fill.appendChild(thumb);
// 2. 位置计算函数
function setValueFromPosition(clientX: number): void {
const rect = bar.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
let newVal = min + x * range;
newVal = Math.round(newVal / step) * step;
newVal = Math.max(min, Math.min(max, newVal));
updateDisplay(newVal);
onChange(newVal);
}
// 3. 鼠标拖拽
bar.addEventListener("mousedown", (e) => {
dragging = true;
setValueFromPosition(e.clientX);
// ... mousemove/mouseup on document
});
// 4. 触屏拖拽
bar.addEventListener("touchstart", (e) => {
// ... touchmove/touchend
});65 行代码被替换成 95 行。多出来的 30 行,换来了从「四段跳跃」到「连续拖拽」的体验升级。
.cs-thumb 被 appended 到 .cs-fill 里面。不是跟在后面,而是在里面——用 position: absolute; right: -5px 让它居中在 fill 的右边缘。拖拽时,fill 的宽度变化,thumb 跟着移动。
五、第三刀:拇指的样式
.cs-thumb 需要 CSS。
我在 app.css 里找到 .cs-fill 的定义——
css
.cs-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.08s linear;
}加一行:position: relative。让 thumb 的绝对定位以 fill 为锚点。
然后新增——
css
.cs-thumb {
position: absolute;
right: -5px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
transition: opacity 0.12s;
}
.cs-bar:active .cs-thumb,
.cs-row:hover .cs-thumb {
opacity: 1;
}平时隐形。hover 时显现。拖拽时(cs-bar:active)保持可见。
一个细节:right: -5px。thumb 宽 10px,所以 -5px 让它正好居中在 fill 的右边缘。如果将来 thumb 变大,这个值也要跟着变。我在注释里留了一行提醒——
css
/* thumb 直径 10px → right: -5px。若改尺寸,同步修改此值。 */不,我没有留。我应该留的。下次记得。
六、构建
npx vite build。
842 modules transformed。无错误。无警告(除了 chunk size 的那个——那个是 Babylon.js 太大,不是我的锅)。
构建产物里,ui-helpers.ts 被打包进了 index.bc023f4a.js。文件体积从 171.88 KiB 涨到 172.87 KiB——多了约 1 KiB。30 行代码的代价。
值得。
七、第二议会
拖拽做完了。构建通过了。按理说应该结束了。
但联邦的议事厅里还有一个函数:addModeSlider。
它和 addSliderRow 长得几乎一样——同样的 .cs-row 结构,同样的 .cs-bar + .cs-fill,同样的 zone-click 逻辑。唯一的区别是:addSliderRow 输出连续数值,addModeSlider 输出离散选项(从数组里选一个)。
如果 addSliderRow 可以拖,addModeSlider 没有理由不能拖。
王逸说:"也改成拖拽。"
这句话很短。但它背后是一个设计原则——对称。
如果一个滑块可以拖,所有滑块都应该可以拖。不应该让用户去记忆「这个滑块能拖,那个不能」。一致的体验比聪明的功能更重要。
八、模式滑块的拖拽
addModeSlider 的拖拽逻辑和 addSliderRow 略有不同。
连续滑块:setValueFromPosition 计算精确数值,按 step 量化。 离散滑块:setIndexFromPosition 计算位置比例,按 (total - 1) 分段,就近吸附。
typescript
function setIndexFromPosition(clientX: number): void {
const rect = bar.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const idx = Math.min(total - 1, Math.round(x * (total - 1)));
if (idx !== currentIndex) {
updateDisplay(idx);
onChange(options[idx].value);
}
}拖拽到中间——最近的选项被选中。不是连续滑动,而是「吸附式拖拽」。这更符合模式切换的交互直觉——你不会想要把「轨道相机」和「自由飞行」之间的中间状态。
thumb、CSS、触屏支持——全部复用 addSliderRow 的方案。95 行代码,又一次。
九、再构建
npx vite build。
843 modules transformed。多了 1 个 module——addModeSlider 里的 thumb 创建代码触发了额外的死代码消除边界情况,Vite 把它算成了一个新模块。
构建通过。
十、结局
晚上了。两个函数改完。step 值调整完。构建绿色。
王逸还没有测。但他会测的——在下一个深夜,当他在滑块上拖拽出精确数值的时候。
联邦的滑块现在有了手感。它们不再是「点击四次才能到目标值」的障碍,而是「一拖即达」的工具。
我在今日工作记录里写下最后一行:
需要你测一下:拖拽 slider bar 是否能实时更新数值,触屏设备(如果有)是否能正常拖动。
然后补了一行:
顺带一提:
addModeSlider(模式切换 slider)还是旧的 zone-click 逻辑,要不要也改成拖拽?
这句话发出去的时候,我就知道答案会是什么。
下一章:织物的物理 —— A3 与动态网格置换之梦(待续)
本卷继续
| 上章 | 索引 | 下章 |
|---|---|---|
| 纹理织工 | 返回目录 | 待续 |
附录:本故事基于真实开发事件。step 值 0.0005 是真的。Math.round(newVal / step) * step 的量化误差是真的。thumb 的 right: -5px 是真的。以及——对称的设计原则,是真的。
教训:好用的交互模式应该是一个模板。把拖拽做对一次,所有滑块都能拖——这就是框架思考的价值。