前言:一个日期滚轮,为什么不只是修改 translateY?
移动端的日期选择器通常由年、月、日三列组成。用户上下拖动某一列,数字跟随手指移动;拖过首项或末项时,列表还能被稍微拉出去,但越拉越费力;松手后,列表又弹回最近的合法选项。
看上去只是几个数字在移动,实际上需要同时处理四件事:
- 跟手:正常范围内,手指移动多少,列表就移动多少。
- 越界阻尼:超过首尾边界后,不突然卡住,也不无限移动。
- 离散吸附:松手时,选中项必须对齐到一整行,不能停在两项中间。
- 弹性过渡:从松手位置到目标项之间,有自然的回弹过程,而且可以被下一次手势打断。
本文结合山竹记账中的以下文件,拆解这套交互的实现:
| 文件 | 职责 |
|---|---|
src/components/form/DatePickerPopup.tsx |
组织年月日三列、生成选项、绘制遮罩和选中框 |
src/components/form/DateTimeColumn.tsx |
处理触摸生命周期、驱动动画、同步选中值 |
src/components/form/dateTimeColumnMotion.ts |
提供拖动、阻尼、索引计算三个纯函数 |
src/components/form/__test__/dateTimeColumnMotion.test.ts |
用 Vitest 验证核心数值规则 |
动画使用 @react-spring/web,当前项目中的版本是 10.1.2。本文代码与分析以当前实现为准,不把后文讨论的改进方向描述成已经完成的功能。
先给出全文最重要的结论:
拖动时的“阻力感”来自位移映射,松手后的“回弹感”来自弹簧动画。前者解决“显示在哪里”,后者解决“如何随时间到达目标”。两者不是同一件事。
一、先看完整数据流:从手指坐标到选中日期
一次交互可以分成两条连续的链路。
拖动阶段:
|
|
松手阶段:
|
|
这里刻意区分 set 和 start:拖动时优先跟手,不额外追赶手指;松手后才让动画负责过渡。
二、建立坐标系:为什么首项是 0,后面的项是负数?
2.1 列表移动,选中框不动
这个组件不是把选中框上下移动,而是把选中框固定在容器中央,让整列选项在框下移动。
可以把界面理解成三个层次:
|
|
实际代码中,列表定位层使用:
|
|
假设容器高 H,每行高 h,那么第一行顶部的初始位置为:
|
|
第一行中心自然就位于 H / 2,恰好落在容器中央。
注意:这里的 translateY = 0 并不表示列表贴在整个弹窗顶部,而是表示列表在定位层内部没有额外平移,首项已经位于中央选中位置。
如果脱离当前项目单独复用,需要同时保证 ol 的默认外边距、内边距和列表标记已经被重置,否则浏览器默认样式会破坏这套几何关系。
2.2 索引与位移的对应关系
浏览器的屏幕纵坐标向下增大,因此:
translateY增大:列表向下移动。translateY减小:列表向上移动。- 要让后面的选项进入中央选中框,就要把列表向上移动。
第 i 项对齐时的位移为:
|
|
以 5 个选项、行高 36px 为例:
| 索引 | 对齐位移 | 说明 |
|---|---|---|
0 |
0px |
首项位于中央 |
1 |
-36px |
列表向上移动一行 |
2 |
-72px |
列表向上移动两行 |
3 |
-108px |
列表向上移动三行 |
4 |
-144px |
末项位于中央 |
因此合法位移范围为:
|
|
为什么是 listLength - 1,不是 listLength?因为从索引 0 移动到索引 4,只需要经过四个行间距。
2.3 位移方向不等于手指此刻的移动方向
这是最容易混淆的一点:
translateY > 0表示列表越过首项边界,向下偏移;它不能直接说明手指此刻正在向下滑。
例如列表已经被向下拉到正位移,手指开始往上回拖,在重新进入合法范围之前,列表位移仍可能大于 0。
需要判断当前运动方向时,应看两次指针坐标的变化;需要判断是否越界时,才看列表当前位置与边界的关系。
三、不要混用三种位移和一个业务值
理解后续代码之前,先把数据分清楚。
| 数据 | 含义 | 例子 |
|---|---|---|
| 原始位移 | 根据手势起点和指针总移动量直接计算的结果 | 向下拉出 36px,得到 36 |
| 可见位移 | 原始位移经过越界阻尼后,真正绘制到列表上的结果 | 原始 36 被压缩为约 22.76 |
| 目标位移 | 松手后应该停靠的合法整行位置 | 首项为 0,第三项为 -72 |
| 业务选中值 | 父组件保存的年、月或日字符串 | "2026"、"11"、"8" |
正常范围内,原始位移和可见位移相同;发生越界后,它们就不相同了。
目标位移一定属于离散的整行位置,但拖动过程中的可见位移可以是任意连续数值,例如 -51.8px。
业务值也不等于动画当前位置。松手后,父组件可以已经拿到新日期,但列表还在向新日期的位置运动。
四、getDragTranslateY:从手势起点计算原始位移
4.1 函数实现
|
|
对应公式:
|
|
三个参数分别是:
startTranslateY:这次手势开始时,列表实际可见的位移。startPointerY:手指按下时的clientY。currentPointerY:手指当前的clientY。
clientY 使用视口坐标。按下和移动阶段使用同一坐标系,二者相减后得到手指的纵向移动距离。
4.2 数值示例
假设列表从 -72px 开始,手指按下时位于 100px:
| 当前手指位置 | 手指总位移 | 列表原始位移 |
|---|---|---|
100px |
0px |
-72px |
120px |
20px |
-52px |
80px |
-20px |
-92px |
因此,手指向下,列表就向下;手指向上,列表就向上。
4.3 起点在一次手势中固定,不要逐帧覆盖
startPointerY 和 startTranslateY 在同一次手势中保持不变,移动时只更新 currentPointerY。
这样,无论触摸事件触发多少次、是否合并了中间事件,当前位置都可以由“起点 + 总位移”直接算出来,不依赖前面处理过多少帧。
尤其不要把已经经过阻尼的可见位移,反复当作下一次计算的原始起点。
举个错误实现的例子:
|
|
手指继续向下,列表却向上缩回,这是重复压缩导致的错误。
正确做法是仍然从原来的手势起点计算:原始越界从 36px 变成 37px,然后只对这次算出的原始结果施加一次阻尼。
4.4 回拖时为什么不会丢失距离?
假设首项位置开始向下拖:
|
|
无论之前被压缩了多少,只要回到相同的手指坐标,就会得到相同的原始位移和可见位移。
因此,在起点固定的一次手势内,阻尼映射是由当前位置决定的,而不是由历史事件累计出来的。
五、getResistedTranslateY:用指数函数实现越界阻尼
5.1 完整实现
|
|
这个函数只负责“原始位移映射到显示位移”,不启动动画,也不决定最终选中哪一项。
5.2 范围内:完全跟手
当位移满足:
|
|
函数直接返回输入。
例如 5 项、行高 36px,合法范围为 [-144, 0],原始位移 -81px 会原样返回 -81px。
注意,合法范围指的是连续区间,而不是只有 0、-36、-72 等整行位置才合法。整行对齐是松手后才处理的事情。
5.3 首项边界越界:向下拉出列表
当 translateY > 0,说明列表越过了首项边界。这里的原始越界距离就是 translateY 本身。
设行高为 h,越界距离为 d,可见拉伸量为:
|
|
Math.exp(x) 表示自然常数 e 的 x 次方。
以 h = 36px 为例:
| 原始越界距离 d | 可见拉伸量 R(d) | 可见量占原始距离的比例 |
|---|---|---|
0px |
0px |
— |
9px |
约 7.96px |
约 88.5% |
18px |
约 14.16px |
约 78.7% |
36px |
约 22.76px |
约 63.2% |
72px |
约 31.13px |
约 43.2% |
108px |
约 34.21px |
约 31.7% |
可见拉伸量一直增加,但增长速度越来越慢,因此表现为“还能拉动,但越拉越费力”。
这里并没有真实计算手指施加的力,所谓阻力感,是通过“同样的手指移动,换来越来越少的视觉位移”模拟出来的。
5.4 末项边界越界:以末项位置为基准向上拉伸
当 translateY < minTranslateY,列表已经越过末项位置,继续向上移动。
此时先计算正的越界距离:
|
|
再把压缩后的距离从末项边界减去:
|
|
例如:
|
|
首项和末项使用同一条阻尼曲线,只是以不同的边界为原点、向相反方向延伸。
5.5 为什么这个公式在边界处比较自然?
把函数的性质展开,就能理解这不是随意选择的一个指数表达式。
第一,越界为零时,拉伸也为零。
|
|
所以刚跨过边界时,显示位置不会突然跳变。
第二,刚越界时,变化速度与正常拖动一致。
对越界距离求导:
|
|
范围内的位移映射斜率也是 1,因此边界两侧不仅位置连续,一阶变化率也连续,手感不会突然从“一比一跟手”切换到另一个固定比例。
从近似关系看,当 d 很小时:
|
|
第三,越往外拖,新增位移越少。
|
|
这表示曲线向下弯,边际增长持续减小。比如原始越界达到一行高度时,局部斜率已经下降到约 0.368。
第四,拉伸有渐近上限。
|
|
数学上,对于有限的正 d,拉伸量小于一行高度;但实际浮点计算中,指数项足够小时,1 - 指数项 可能被舍入成 1,结果恰好等于 h。
因此测试极大越界距离时,使用“小于等于一行高度”比“严格小于”更符合 JavaScript 数值计算的实际情况。
5.6 用分段函数统一理解
设 m = -(n - 1) × h,原始位移为 y,显示位移为 F(y):
|
|
这可以总结为:
合法范围内不改动;越界后保留方向,只压缩超出最近边界的那一部分。
对于有效的有限输入,可见位移大致被限制在:
|
|
两端是考虑浮点舍入后的界限;在实数模型中,有限越界距离对应的外侧界限不会真正取到。
5.7 为什么不直接截断,或者统一乘以 0.3?
三种方案的差别如下:
| 方案 | 越界反馈 | 问题或取舍 |
|---|---|---|
| 直接截断到边界 | 完全拉不出去 | 边界明确,但缺少弹性反馈 |
| 越界距离乘固定比例 | 可以继续拉出,阻力固定 | 位移仍会无限增长;边界斜率通常突变 |
| 指数压缩 | 刚越界接近跟手,随后越来越难拉动 | 计算稍复杂,但手感更渐进,且拉伸量有界 |
没有一种方式适合所有产品。日期滚轮通常希望边界柔和、可见拉伸可控,所以这里选择指数压缩。
六、getNearestDateTimeIndex:把连续位置转换成离散选项
6.1 完整实现
|
|
它只返回索引,不修改列表位置,也不调用弹簧动画。
6.2 第一步:把位移截断到合法范围
|
|
先用 Math.min(0, translateY) 限制首项边界,再用 Math.max 限制末项边界。
取值范围可以理解为 [minTranslateY, [translateY, 0]] 即 minTranslateY <= translateY <= 0
以合法范围 [-144, 0] 为例:
| 输入位移 | 截断后位移 |
|---|---|
22.76px |
0px |
-57.6px |
-57.6px |
-166.76px |
-144px |
因此,松手时即使仍处于越界拉伸状态,也会选择首项或末项,而不会产生不存在的负索引或超长索引。
6.3 第二步:换算成连续索引,再四舍五入
|
|
合法位移非正,取反后除以行高,就能得到当前位于第几行附近。
| 位移 | 连续索引 | 最近索引 | 目标位移 |
|---|---|---|---|
-43.2px |
1.2 |
1 |
-36px |
-54px |
1.5 |
2 |
-72px |
-57.6px |
1.6 |
2 |
-72px |
恰好处于两项中点时,换算后的数值非负,Math.round 会选择索引更大的那一项。例如 -18px 对应 0.5,选索引 1。
6.4 第三步:再次限制索引范围
|
|
在列表长度为正整数、行高为正、数值正常的条件下,前面的位移截断通常已经足够。最后再限制一次索引范围,是让返回值约束更明确的一道保护。
6.5 为什么使用可见位移,而不是拖动原始位移?
松手代码读取的是:
|
|
也就是用户当前实际看到的位置。
因此,确定目标和启动动画都以当前显示位置为依据。在合法范围内,原始位移与可见位移相同;在范围外,两者虽然不同,但截断后都会落到相同的首项或末项。
这一步不需要反解阻尼函数。
6.6 它不是惯性滚动算法
这个函数没有速度参数,也没有时间参数。只要松手位置相同,无论用户之前拖得快还是慢,它计算出的目标索引就相同。
因此,当前实现是“按当前位置吸附”,不是“根据松手速度向前甩动若干项”。弹簧动画内部可能涉及运动速度的连续演化,但那不等于业务层实现了速度投影选项。
七、React Spring:把目标位置变成随时间变化的动画
7.1 初始化动画值
|
|
这里保存的不是每次渲染都立即固定下来的普通数值,而是可以被动画系统持续更新的 SpringValue。
springs.y:动画值对象。springs.y.get():读取此刻的数值。springApi.set({ y }):立即设定位置,不请求一段追赶动画。springApi.start({ y }):从当前状态向目标位置运动。springApi.stop():停止正在运行的动画。
useSpring 的初始化回调建立初始值;后续父组件值变化,还需要代码显式调用 start,不能只依赖初始化表达式重新执行。
7.2 为什么动画列表使用 animated.ol?
|
|
.to(...) 把数值型动画值映射为 CSS 变换字符串,animated.ol 负责把动画值的更新应用到元素样式。
这种方式不需要为了每一个动画帧都调用 React 的 setState,也不需要整列选项逐帧重新渲染。
这里使用 transform,主要是让列表通过视觉变换移动,而不是每帧修改 top 去参与普通布局。浏览器通常能够较高效地处理这种更新,但 translate3d 不是“必然零开销”或“必然获得独立 GPU 图层”的保证,仍应以实际性能分析为准。
7.3 弹簧动画和固定时长动画有什么不同?
固定时长动画通常思考的是:
|
|
弹簧动画更接近:
|
|
可以用经典弹簧阻尼模型帮助理解:
|
|
其中:
m:质量。k:弹簧刚度,对应理解上的tension。c:阻尼系数,对应理解上的friction。x - target:当前位置到目标位置的偏差。
这是理解参数作用的模型,不是说业务代码直接以像素、秒等物理单位精确模拟了一根真实弹簧。
7.4 tension 与 friction 如何影响手感?
松手时使用:
|
|
一般来说:
tension越大,回到目标的趋势越强,运动通常越利落。friction越大,运动衰减越明显,通常越不容易反复振荡。friction较低时,更容易出现越过目标后再回来的弹性效果。
初始化配置中的阻尼为 30,松手配置中的阻尼为 18,体现了后者希望有更明显回弹的意图。
为了形成数量级直觉,可以在简化模型中假设 m = 1,则临界阻尼为:
|
|
在这个假设下,30 比 18 更接近临界阻尼,通常更稳;18 则更容易产生可见回摆。但实际表现还与库的求解、初始状态、精度阈值及配置方式有关,不能只用这一个算式断言动画时长或回弹次数。
还要注意:React Spring 可以沿用或合并已有动画配置。当前源码在部分 start({ y }) 中没有再次指定 config,因此不能简单声称“外部同步和取消一定使用 friction: 30”。如果产品要求每条交互路径参数固定,应在对应路径显式传入配置。
7.5 空间阻尼与时间阻尼不能互相代替
| 对比项 | 越界指数阻尼 | 弹簧动画阻尼 |
|---|---|---|
| 输入 | 原始越界距离 | 当前运动状态与目标位置 |
| 主要发生阶段 | 手指拖动期间 | 松手后的归位期间 |
| 是否需要时间演化 | 不需要,是静态映射 | 需要,逐帧演化 |
| 控制什么 | 能被拉出多远、是否跟手 | 如何回到目标、是否回摆 |
| 对应代码 | getResistedTranslateY |
springApi.start 的 config |
给弹簧加大阻尼,不能替代拖动时的越界压缩;把越界曲线调得更硬,也不会自动产生松手后的回弹动画。
八、触摸生命周期:让动画、手势和业务状态交接清楚
8.1 为什么用 useRef 保存手势?
|
|
这里保存的是一次手势的起点信息,不是界面需要直接展示的数据。
null:当前没有有效拖动。- 非
null:已经按下,可以根据记录的起点继续计算。
使用 useRef 可以在事件之间共享数据,而不必为了高频的触摸更新触发 React 渲染。
这并不意味着“动画组件里不能使用 state”,而是要区分职责:业务值可以通过 React 状态管理,瞬时手势和动画状态不必逐帧进入 React 渲染流程。
8.2 按下:停止旧动画,接管当前可见位置
关键顺序是:
|
|
逐步理解:
- 清除旧的吸附目标,让这次手势拥有新的控制权。
- 停止尚未结束的弹簧运动。
- 读取此刻真正显示的位置,而不是之前动画的目标位置。
- 显式把这个位置作为接管时的值。
- 记录手指位置和列表位置,作为本次拖动的共同起点。
假设上次松手目标为 -72px,动画还只走到 -50.4px。如果新手势从 -72px 开始计算,用户一按下就可能看到跳动。
正确起点应该是 -50.4px:
|
|
在合法范围内,这样能自然地从动画过渡到跟手拖动。对于“按下时仍处于越界区域”的特殊情况,还存在再次应用阻尼的问题,后文会单独讨论,不能仅凭这段逻辑就断言所有场景绝对无跳变。
8.3 移动:先算原始位移,再做一次阻尼
|
|
这里没有 start,因为手指已经提供了每一刻的位置。如果每次移动都再启动一段弹簧动画,列表就会不断追赶手指,产生拖泥带水的延迟感。
拖动过程只改变显示位置,不立即调用 onChange;这使得业务选中值在松手时才正式提交,而不是每经过半行就改变一次日期。
8.4 松手:确定目标、启动动画、提交选中值
松手时依次完成:
- 清除手势记录。
- 根据当前可见位置计算最近合法索引。
- 记录本次吸附目标。
- 启动到目标整行位置的弹簧动画。
- 立即调用
onChange通知父组件。
这里的“立即”很重要:业务值提交不需要等待动画结束。
例如选中项已经变成 11 月,界面仍在向 11 月的位置收敛,这是正常的“业务目标先确定,视觉再到达”。
相应地,父组件应当把 onChange 理解为“选中值提交”,而不是“动画播放完毕”。如果有埋点或操作必须等视觉稳定后才执行,应单独设计完成事件。
8.5 取消:回到已经提交的选中值
|
|
系统中断、触摸被取消等情况会触发 touchcancel。
当前实现把它视为“本次选择未提交”:回到父组件当前选中的值,不调用 onChange。
这与 touchend 的“按当前位置提交最近项”不同。明确区分完成和取消,是手势组件保持语义稳定的重要细节。
九、父组件同步:为什么不能每次 onChange 后都重新启动动画?
9.1 一个容易出现的控制权冲突
DateTimeColumn 通过 dateTimeValue 接收业务选中值,通过 onChange 把用户的新选择交给父组件。
松手时会发生:
|
|
如果 effect 不加区分,看到值变化就重新启动同一个目标的动画,会形成重复发令。即使目标相同,也属于不必要的竞争,可能干扰预期的动画过程或配置。
9.2 settlingTargetRef 记录“当前正在自行完成的目标”
|
|
这里不仅记录索引,还记录行高与列表长度,因为这些数据共同决定当前吸附上下文。
effect 的逻辑是:
|
|
可以把它理解成一个轻量的控制权协议:
| 情况 | 处理 |
|---|---|
| 正在拖动 | 暂不让 effect 抢走手势的位置控制权 |
| 父组件回传的正是当前吸附目标,且几何信息一致 | 保留已有动画,不重复启动 |
| 外部选中值或相关几何信息变了 | 清除旧目标,向新的受控位置动画 |
这里比较的是“位置同步是否还有效”,不是做完整的列表内容深比较。列表业务语义变化、父组件拒绝提交等复杂情况,仍需要另外约定。
9.3 为什么 onRest 要比较对象身份?
松手时会新建一个目标对象,并在回调中保留它:
|
|
假设旧动画 A 之后又启动了新动画 B。即使旧动画的结束或停止路径触发了回调,也不能让 A 清掉 B 的目标记录。
对象身份比较表示:
只有 ref 里仍然保存着“我这一次创建的目标对象”,我才有资格清除它。
单纯比较索引不够,因为两次不同的动画可能恰好拥有相同索引。新建对象相当于给每次吸附分配一个轻量的身份令牌。
这个思路也适用于异步请求、延迟任务和其他可中断动画:旧任务完成时,先确认自己是否仍是当前任务,再更新共享状态。
十、核心组件完整代码:把前面的环节接起来
下面给出当前 DateTimeColumn.tsx 的核心完整实现。三个运动计算函数分别使用前文的定义;样式类依赖当前项目的工具类体系,脱离项目复用时需要提供等价 CSS。
|
|
从职责上看,这个组件只关心“一组字符串组成的等高选项如何选择”,并不需要知道自己代表年、月还是日。这也是它可以复用为其他滚轮选择列的原因。
十一、用一次完整手势串起所有计算
11.1 普通范围内,从第三项拖向第四项
条件:5 个选项、行高 36px,当前索引为 2。
|
|
拖动时:
|
|
松手时:
|
|
最终由弹簧动画从 -92px 移动到 -108px,并把索引 3 对应的字符串交给父组件。
11.2 从首项向下越界,再弹回首项
|
|
拖动时:
|
|
松手时:
|
|
列表从约 31.13px 回到 0px,形成首项边界的回弹。
11.3 从末项继续向上越界
|
|
松手后可见位移被截断到末项边界,得到索引 4,再由弹簧动画回到 -144px。
11.4 动画途中重新按下
|
|
在合法范围内,这个过程不需要先跳到旧目标,也不需要等待旧动画完成。它体现的是“用户输入优先于未完成动画”。
十二、布局、遮罩与触摸行为如何配合?
12.1 等高是索引公式成立的前提
每一项同时设置高度与行高:
|
|
索引与位移的线性关系依赖于“所有项高度相等”。如果选项可以换行、行高不一致,就不能继续使用 -index × itemHeight,而需要累计每项高度,松手时再查找最近的中心点。
12.2 overflow-clip 裁剪视觉内容,不提供原生滚动
日期列容器使用 overflow-clip,超出窗口的列表内容被裁剪。这里不是通过原生 scrollTop 滚动,真正的位置来自动画列表的 transform。
所以浏览器不会自动替我们处理吸附、边界回弹和惯性;这些行为需要由当前的手势与动画逻辑负责。
12.3 遮罩与选中框不要拦截触摸
父组件中,遮罩和选中框位于列表上方,并使用:
|
|
否则,透明覆盖层也可能挡住下方日期列的触摸命中,导致“看得见数字,却拖不动”。
遮罩通过上下两个渐变弱化远离中心的选项;中间选框强调当前选择区域。它们只负责视觉层次,不参与位移计算。
12.4 touch-action: none 的作用与边界
日期列内联设置:
|
|
目的是告诉支持该机制的浏览器:这个区域的触摸平移、缩放不按默认直接操作行为处理,由组件接管。
需要注意:
touch-action不等同于调用preventDefault(),也不等同于阻止事件冒泡。- 它会影响这个区域内的浏览器默认手势,不能不加区分地作用到整页。
- 当前使用 Touch Events,实际滚动竞争、取消和嵌套容器行为仍要在目标移动浏览器上验证。
- 如果后续确实需要主动取消触摸默认行为,要检查事件是否可取消,以及监听器是否为被动监听,不能在不确认的情况下机械添加
preventDefault()。
十三、年月日联动:动画只是视图,日期合法性属于业务层
DatePickerPopup 分别生成:
- 年列表:起始年到结束年。
- 月列表:
1到12。 - 日列表:当前年月对应的天数。
三个 DateTimeColumn 复用同一套运动逻辑,只传入不同的列表和值。
但日期选择器还有另一类问题:业务合法性与动画合法性不是一回事。
例如从 1 月 31 日切换到 2 月,日列表可能从 31 项变为 28 或 29 项。此时必须先决定日期规则:是把日截断到当月最后一天,还是采用其他明确的交互策略。
动画列要求:
|
|
当前组件在值不属于列表时直接抛错,因此父组件或日期辅助层必须确保值与列表一致。不能只更新列表,仍把 "31" 作为二月的选中值传进去。
本文不展开日期辅助类对月份赋值的内部处理,也不据此假定所有月末与闰年边界都已验证。更稳妥的设计顺序是:
|
|
另外,生成了起止年份,并不等于已经实现“精确到某一天的起止日期限制”。如果产品要求最早只能选某年某月某日,还需要在对应年份限制月份、在边界月份限制日期。
十四、用 Vitest 测试核心运动规则
14.1 为什么把计算抽成纯函数?
如果所有公式都写进触摸处理函数里,验证一次位移计算就可能需要构造 DOM、模拟触摸和等待动画。
抽成纯函数后,测试只关心:
|
|
它们不依赖浏览器布局、不读取 React 状态,也不依赖动画时钟,因此可以直接进行确定性的数值测试。
当前已有五个 Vitest 用例,覆盖以下规则:
| 用例 | 验证重点 |
|---|---|
| 范围内一比一移动 | 中间位移及两个合法边界保持原值 |
| 首尾越界阻尼 | 两端允许可见拉伸、方向正确、对称、极大输入不超过拉伸上限 |
| 回拖计算 | 原始位移由起点决定,能跨过边界进入合法范围 |
| 中断弹簧后的新拖动 | 纯拖动函数能以当前可见位置作为起点,不丢失这段位置 |
| 最近项吸附 | 四舍五入、首尾截断、单项列表 |
14.2 代表性断言
下面节选现有测试中的关键断言:
|
|
项目已提供非交互测试脚本 "test": "vitest run",在项目根目录执行以下命令可以只运行目标文件:
|
|
这里的 @ 导入依赖项目现有路径别名。把示例移到独立项目时,需要配置相同别名,或者改成相对路径。
14.3 浮点断言为什么使用误差范围?
指数运算和小数乘法可能产生浮点舍入误差,不适合对所有结果都使用严格相等。
例如首尾阻尼对称性采用:
|
|
这明确表达的是“误差必须严格小于 1e-10”,而不是随意放宽到肉眼看起来差不多。
对于确切的整数结果和未改变的输入,可以继续使用 toBe;对于单调性、边界等性质,则使用大小比较。
14.4 纯函数测试通过,不代表交互全部正确
现有五个用例没有真正挂载 DateTimeColumn,也没有驱动真实触摸或 React Spring 的时间演化。
它们不能直接证明以下行为:
- 新手势是否真的停止了旧动画。
onChange和父组件更新是否正确交接。- 旧
onRest回调是否不会覆盖新目标。 - 越界位置重新按下是否完全连续。
- 触摸取消、多指操作、嵌套滚动是否符合预期。
- 年月切换导致日列表变化时是否始终保持合法日期。
这些需要组件测试、浏览器交互测试或真机验证。测试的结论要与测试层级匹配,不能因为“有一个动画中断相关的纯函数用例”,就认为动画中断流程已经获得完整覆盖。
十五、现有实现的边界与进一步完善方向
本节是对当前源码的工程化补充,不表示下面这些能力已经实现。
15.1 空列表兜底不等于支持空状态
两个涉及列表范围的纯函数都在 listLength <= 0 或 itemHeight <= 0 时返回 0。
这是数值计算的兜底,不意味着组件可以安全显示空列表:组件还要求 dateTimeValue 存在于列表中,空列表会在前面的合法性检查中抛错。
如果产品确实需要空状态,应在组件边界单独设计禁用、占位或不渲染策略,不能拿索引 0 直接访问空数组。
同样,行高非正时即使纯函数返回了 0,视觉布局也已经不成立,调用方仍应保证正行高。
15.2 当前参数校验不是全面的数值防御
源码没有完整检查:
NaN、无穷大等非有限输入。- 列表长度是不是整数。
- 项目之间是否存在重复字符串。
例如 NaN <= 0 为 false,不能被当前的非正值判断拦住;后续 Math.min、Math.max 也可能继续传播 NaN。
在组件内部,dateTimeList.length 天然是非负整数,触摸坐标通常也是有效数值,所以可以通过调用约束简化纯函数。但如果把这些函数发布成通用工具库,应明确参数契约,必要时使用 Number.isFinite 等校验。
重复字符串不仅会影响 React 的 key,也会让 indexOf 无法区分同名选项。通用组件更适合使用唯一标识,而不是默认展示文本本身就是身份。
15.3 越界位置重新按下:可见坐标和原始坐标并不相同
这是比“不要累计阻尼”更进一步的细节。
假设旧动画还在首项外侧,可见位移为 20px。新手势按下时,当前代码把 20px 记录为起点,这是读取视觉状态的正确方向。
但第一次移动时,又会把这个起点当作原始位移送入阻尼函数。即使指针没有明显移动,也可能出现:
|
|
因此,“读取可见位置”能避免从旧目标开始拖动,却不能单独解决“越界可见值被再次压缩”的问题。
一种改进思路是:先把可见拉伸量反解成原始越界距离,再作为新手势的计算基准。
对于:
|
|
可以得到:
|
|
其中 0 ≤ v < h。
- 首项外侧:
v就是当前正的可见位移。 - 末项外侧:先取
v = minTranslateY - visibleY,再把反解距离加到末项外侧。 - 合法范围内:原始坐标与可见坐标相同,无需反解。
工程实现需要处理接近上限时的数值稳定性,避免 v / h 被舍入为 1 后对 0 取对数。也可以改为单独保存原始坐标、重新设计手势接管映射,或限制动画在边界外的状态。
在数值实现上,极小距离对应的 1 - Math.exp(-d / h) 还存在两个接近数相减的精度损失,可以考虑等价写法 -Math.expm1(-d / h);反解则可以考虑 -h * Math.log1p(-v / h)。这些写法能改善小量计算,但不能替代对输入范围和上限舍入的检查,当前源码也尚未采用这些改写。
无论采用哪一种方案,都要先明确它如何兼顾“按下位置不跳变”和“后续拖动手感一致”,并补充专门的组件级回归测试。当前五个纯函数用例没有覆盖这个完整链路。
15.4 拖动中列表变化,不能只靠 effect 的提前返回
当前 effect 在拖动时直接返回,目的是保护手势控制权。但如果此时父组件改变了列表长度、行高或选中值,旧手势起点仍可能对应旧几何信息。
而且修改 ref 不会触发渲染;当拖动结束后,如果 effect 依赖没有再次变化,之前被跳过的同步不会自动重放。
可选策略包括:
- 手势期间冻结这一列的列表和行高。
- 外部数据变化时取消当前手势,再按新列表归位。
- 显式记录待同步状态,手势结束时重新协调。
具体选哪一种取决于产品语义,但必须把它设计成协议,而不是假定所有外部变化都会自动被 effect 补上。
15.5 父组件接受、拒绝或延迟提交,要有明确约定
当前方案适合父组件及时接受 onChange 并回传新值的常见情况。
如果父组件只是忽略回调,且原来的 dateTimeValue 没有变化,相关 effect 不一定重新运行。此时子组件已经向新目标运动,视觉位置可能与旧的受控值不一致。
如果业务需要校验、异步提交或拒绝选择,应该另外设计明确的确认机制,例如区分临时选择与已提交值,或通过独立的同步信号触发回滚。
同理,“正在拖动时跳过外部同步”只是一个控制权选择,不代表组件已经具备完整的并发数据协调能力。
15.6 当前只取 touches[0],并没有完整的多指管理
现在的处理适合单指拖动,但没有记录触摸点的 identifier。
多指加入、某一根手指先离开时,touches[0] 不一定始终对应最初那根手指,touchend 也未必意味着主手势已经结束。
如果需要更可靠的输入管理,可以:
- 在 Touch Events 中记录并查找同一个
identifier。 - 或改用 Pointer Events,记录
pointerId,结合指针捕获与取消事件管理生命周期。
Pointer Events 还可以统一鼠标、触摸和触控笔。但这需要重新梳理交互协议,不是简单把事件名称替换一下就完成。
当前代码也没有鼠标拖动、滚轮和键盘交互,不能直接把它当作完整的桌面端选择器。
15.7 自定义行高时,选中框也要同步
日期列接收 dateItemHeight 并使用 CSS 变量布局,但当前父组件选中框仍写着 h-[36px]。
默认行高 36px 时两者一致;如果传入 44px,列表计算和选框视觉尺寸就可能脱节。
改进时应让选中框、列表项、居中偏移和阻尼尺度共享同一个行高来源,而不是只改运动公式。
15.8 可访问性和减少动态效果
ol、li 能表达列表结构,但不会自动提供一个完整选择控件的交互语义。
面向生产环境,还应根据产品形式选择合适的可访问模式,并配套:
- 清晰的列标签,例如年、月、日。
- 当前选中状态与可理解的读屏反馈。
- 焦点管理及键盘上下选择、首尾跳转等操作。
- 对
prefers-reduced-motion的处理:减少回摆、缩短过渡或直接定位。 - 避免每一帧动画都向辅助技术重复播报。
这些能力需要语义、键盘和视觉状态一起设计,不能只添加一个 role 就认为完成。
十六、如何调参,以及是否应该加入惯性?
16.1 当前行高同时承担三个角色
当前的 itemHeight 同时决定:
- 每项视觉高度。
- 索引对应的位移间距。
- 越界最大拉伸尺度。
这样参数少,也容易保持不同尺寸下的相似手感。但如果设计希望行高 36px,最多只能拉出 20px,就需要把阻尼尺度独立出来。
可以在设计上改为:
|
|
其中 L 是独立的最大拉伸尺度,而 itemHeight 继续只负责行间距。这个形式仍保持边界斜率为 1。
如果进一步分别设置幅度 A 和距离尺度 B:
|
|
那么要与合法范围的一比一跟手平滑衔接,就需要注意 A / B 是否为 1。调参不仅是“看起来舒服”,也涉及边界连续性。
16.2 调整弹簧参数时,一次只改一个维度
一个比较实际的调参顺序是:
- 先确定行高和允许的越界拉伸量。
- 确认正常拖动没有延迟、跳动或重复阻尼。
- 再调整松手弹簧的恢复力度。
- 最后调整阻尼,控制回摆幅度与收敛速度。
如果拖动坐标本身算错,单纯提高 friction 只能掩盖问题,不能修复问题。
16.3 惯性滚动需要新增哪些信息?
如果希望用户快速一甩,列表继续滚动若干项,就需要额外记录指针位置与时间,估计松手速度。
一种概念上的流程是:
|
|
真正实现时还要考虑速度采样窗口、方向变化、极短手势、异常时间间隔和边界能量处理。惯性增加的是一套“目标预测规则”,不是把 tension 调大就能得到。
对于日期选择,当前这种按位置吸附的方案更简单、可预测;对于长选项滚轮,惯性可能更省操作。应先判断交互需求,再决定是否引入复杂度。
十七、这套方案可以应用到哪些场景?
17.1 等高离散选项滚轮
最直接的复用场景包括:
- 时间选择器的时、分、秒。
- 身高、体重、年龄、数量选择。
- 有限档位的设置面板。
- 地区选择器中的省、市、区列。
共性是:选项离散、每项等高、最终需要对齐一项。
地区选择器还涉及上级改变后下级列表重建,因此要额外处理列表变化与选中值合法性,不能只复用动画就忽略数据联动。
17.2 横向轮播与卡片吸附
把纵坐标换成横坐标,把行高换成卡片步长,就可以复用“位移 → 最近索引 → 弹簧归位”的结构。
但要把卡片间隙也计入步长,并根据布局、左右方向和可能的 RTL 模式重新定义坐标,不应机械地只把变量名 Y 改成 X。
17.3 抽屉、底部面板和可拖拽弹层
同样可以借鉴:
- 合法区域跟手。
- 超出允许高度后施加非线性阻尼。
- 松手时选择最近的停靠点。
- 使用可中断的弹簧动画归位。
区别在于这些组件的停靠点不一定等距,需要比较多个目标位置的距离,必要时还要结合速度决定展开还是关闭。
17.4 下拉刷新与边界反馈
下拉刷新可以复用“原始拖动距离映射为更小可见距离”的思路,但释放后的行为由刷新状态机决定,不是简单吸附到列表项。
还要区分触发阈值、加载中停留位置、刷新完成后的收起位置,以及与原生页面滚动的关系。
17.5 不适合直接照搬的场景
- 无限循环滚轮:需要处理索引取模、循环数据与视觉重定位,当前有限边界模型不够。
- 不等高列表:需要累计高度和查找最近中心点,不能只除以固定行高。
- 长列表自由滚动:通常更适合优先评估原生滚动、虚拟列表与 CSS Scroll Snap。
- 高可访问性表单:应先评估原生选择控件是否已经足够,避免为了视觉效果承担不必要的交互成本。
选择自定义方案的理由,应是产品确实需要这套手势与视觉,而不是默认所有选择控件都必须手写动画。
十八、验证清单:不仅要看“会不会动”
下面这份清单是进一步验证组件时的建议,不代表这些项目都已由现有五个用例覆盖。
18.1 数学与纯函数
- 合法区间内,位移是否保持原值。
- 首尾边界值是否连续,刚越界时是否没有明显折点。
- 越界拉伸是否保持方向正确、两端对称、单调增长。
- 极大越界距离是否仍受最大拉伸量限制。
- 中点前后是否选择预期索引,恰好中点是否符合约定。
- 空列表、单项列表和非法尺寸是否符合参数契约。
18.2 手势与动画
- 慢拖、快拖、停住再拖是否跟手。
- 一次手势越界后再回到范围内,是否没有累计误差。
- 动画中途重新按下,是否从可见位置接管。
- 在合法范围与越界范围重新按下,是否都连续。
- 快速连续拖动多次,旧回调是否不会清除新目标。
touchcancel是否回到已提交项,而不是意外提交新值。- 多指加入、移出组件区域、系统中断是否有明确行为。
18.3 数据、布局与平台
- 外部直接修改选中值,是否同步到正确位置。
- 父组件接受、拒绝或延迟提交时,视觉与业务值是否一致。
- 交互途中改变列表长度、行高,是否按既定策略协调。
- 大小月、闰年和起止日期边界是否保持合法值。
- 自定义行高后,选框、列表、居中偏移是否仍一致。
- 目标移动浏览器中,页面滚动与日期列拖动是否冲突。
- 键盘、读屏与减少动态效果设置是否得到支持。
十九、知识点总结:把“弹性动画”拆成可理解的工程问题
| 知识点 | 在当前组件中的体现 | 可以迁移的思路 |
|---|---|---|
| 坐标与索引转换 | y = -index × itemHeight |
先确定坐标方向和几何约定,再写交互公式 |
| 手势起点建模 | 起始列表位置加指针总位移 | 避免高频事件累计误差和历史依赖 |
| 原始值与显示值分离 | 拖动原始位移不累计阻尼结果 | 非线性映射之后的值不要未经区分再次作为原始输入 |
| 非线性压缩 | 指数函数处理首尾越界 | 用单调、有界、边界平滑的曲线模拟阻力感 |
| 连续到离散的映射 | 截断、除以行高、四舍五入 | 手势结束时把连续位置转换成合法业务目标 |
| 空间与时间分工 | 拖动时 set,松手后 start |
跟手阶段与归位阶段采用不同控制方式 |
| 动画可中断性 | 停止旧动画,读取当前可见值 | 用户再次输入时,接管当前状态而不是旧目标 |
| 控制权协调 | 手势 ref、吸附目标 ref、同步 effect | 明确输入、动画和外部状态谁在什么阶段拥有控制权 |
| 异步回调隔离 | onRest 比较目标对象身份 |
旧任务完成前先确认自己仍是当前任务 |
| 测试分层 | 纯函数测试与交互验证分开 | 数学正确、事件正确、平台体验正确需要不同证据 |
如果把整个实现压缩成五句话,就是:
- 按下时接管当前可见位置,建立固定手势起点。
- 移动时从起点计算原始位移,只对越界部分做一次非线性压缩。
- 松手时把可见位移转换成最近的合法索引。
- 用弹簧动画到达目标位置,同时把业务选中值提交给父组件。
- 通过控制权和任务身份管理,避免新手势、旧动画与父组件同步互相覆盖。
日期选择器只是一个具体载体。真正可以复用的,是这套“输入坐标 → 几何约束 → 视觉映射 → 离散决策 → 动画收敛”的设计方法。
当我们把每一层的输入、输出和边界说清楚,所谓“有弹性的手感”就不再是凭感觉拼出的效果,而是可以推导、调节、测试和逐步完善的交互系统。