前言:一个日期滚轮,为什么不只是修改 translateY?

源码

移动端的日期选择器通常由年、月、日三列组成。用户上下拖动某一列,数字跟随手指移动;拖过首项或末项时,列表还能被稍微拉出去,但越拉越费力;松手后,列表又弹回最近的合法选项。

看上去只是几个数字在移动,实际上需要同时处理四件事:

  1. 跟手:正常范围内,手指移动多少,列表就移动多少。
  2. 越界阻尼:超过首尾边界后,不突然卡住,也不无限移动。
  3. 离散吸附:松手时,选中项必须对齐到一整行,不能停在两项中间。
  4. 弹性过渡:从松手位置到目标项之间,有自然的回弹过程,而且可以被下一次手势打断。

本文结合山竹记账中的以下文件,拆解这套交互的实现:

文件 职责
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。本文代码与分析以当前实现为准,不把后文讨论的改进方向描述成已经完成的功能。

先给出全文最重要的结论:

拖动时的“阻力感”来自位移映射,松手后的“回弹感”来自弹簧动画。前者解决“显示在哪里”,后者解决“如何随时间到达目标”。两者不是同一件事。

一、先看完整数据流:从手指坐标到选中日期

一次交互可以分成两条连续的链路。

拖动阶段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
手指按下
  ↓
停止尚未结束的动画,读取列表当前可见位移
  ↓
记录:手指起点 + 列表起点
  ↓
手指移动
  ↓
getDragTranslateY:计算原始位移
  ↓
getResistedTranslateY:压缩越界部分
  ↓
springApi.set:立即设置可见位移
  ↓
animated.ol:更新 transform

松手阶段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
手指松开
  ↓
读取列表当前可见位移
  ↓
getNearestDateTimeIndex:找到最近的合法索引
  ↓
目标位移 = -索引 × 行高
  ↓
springApi.start:弹簧动画移动到目标位移
  ↓
同时通过 onChange 通知父组件新的选中值

这里刻意区分 set 和 start:拖动时优先跟手,不额外追赶手指;松手后才让动画负责过渡。

二、建立坐标系:为什么首项是 0,后面的项是负数?

2.1 列表移动,选中框不动

这个组件不是把选中框上下移动,而是把选中框固定在容器中央,让整列选项在框下移动。

可以把界面理解成三个层次:

1
2
3
4
5
日期列容器:裁剪超出区域的内容
  ├─ 列表定位层:把第一行放到选中框位置
  │    └─ 动画列表:通过 translateY 上下移动
  ├─ 上下渐变遮罩:弱化非选中区域
  └─ 中央选中框:始终保持不动

实际代码中,列表定位层使用:

1
2
3
4
5
6
7
<div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
  <animated.ol
    style={{ transform: springs.y.to((y) => `translate3d(0, ${y}px, 0)`) }}
    className="date-time-picker text-center">
    {/* 每一项的高度和行高都等于 dateItemHeight */}
  </animated.ol>
</div>

假设容器高 H,每行高 h,那么第一行顶部的初始位置为:

1
H / 2 - h / 2

第一行中心自然就位于 H / 2,恰好落在容器中央。

注意:这里的 translateY = 0 并不表示列表贴在整个弹窗顶部,而是表示列表在定位层内部没有额外平移,首项已经位于中央选中位置。

如果脱离当前项目单独复用,需要同时保证 ol 的默认外边距、内边距和列表标记已经被重置,否则浏览器默认样式会破坏这套几何关系。

2.2 索引与位移的对应关系

浏览器的屏幕纵坐标向下增大,因此:

  • translateY 增大:列表向下移动。
  • translateY 减小:列表向上移动。
  • 要让后面的选项进入中央选中框,就要把列表向上移动。

第 i 项对齐时的位移为:

1
y(i) = -i × h

以 5 个选项、行高 36px 为例:

索引 对齐位移 说明
0 0px 首项位于中央
1 -36px 列表向上移动一行
2 -72px 列表向上移动两行
3 -108px 列表向上移动三行
4 -144px 末项位于中央

因此合法位移范围为:

1
2
3
minTranslateY = -(listLength - 1) × itemHeight

minTranslateY ≤ translateY ≤ 0

为什么是 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 函数实现

1
2
3
4
5
export const getDragTranslateY = (
  startTranslateY: number,
  startPointerY: number,
  currentPointerY: number
) => startTranslateY + currentPointerY - startPointerY

对应公式:

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。

这样,无论触摸事件触发多少次、是否合并了中间事件,当前位置都可以由“起点 + 总位移”直接算出来,不依赖前面处理过多少帧。

尤其不要把已经经过阻尼的可见位移,反复当作下一次计算的原始起点。

举个错误实现的例子:

1
2
3
4
5
第一次:原始越界 36px → 阻尼后约 22.76px

第二次手指再向下移动 1px:
错误地用 22.76 + 1 作为原始位移,再做一次阻尼
结果反而只有约 17.4px

手指继续向下,列表却向上缩回,这是重复压缩导致的错误。

正确做法是仍然从原来的手势起点计算:原始越界从 36px 变成 37px,然后只对这次算出的原始结果施加一次阻尼。

4.4 回拖时为什么不会丢失距离?

假设首项位置开始向下拖:

1
2
3
4
5
6
手指起点:100px
列表起点:0px

手指到 136px → 原始位移 36px → 显示约 22.76px
手指回到 100px → 原始位移 0px → 显示 0px
手指继续到 90px → 原始位移 -10px → 显示 -10px

无论之前被压缩了多少,只要回到相同的手指坐标,就会得到相同的原始位移和可见位移。

因此,在起点固定的一次手势内,阻尼映射是由当前位置决定的,而不是由历史事件累计出来的。

五、getResistedTranslateY:用指数函数实现越界阻尼

5.1 完整实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const getResistedTranslateY = (
  translateY: number,
  listLength: number,
  itemHeight: number
) => {
  if (listLength <= 0 || itemHeight <= 0) {
    return 0
  }

  const minTranslateY = -(listLength - 1) * itemHeight

  if (translateY > 0) {
    return itemHeight * (1 - Math.exp(-translateY / itemHeight))
  }

  if (translateY < minTranslateY) {
    const overscroll = minTranslateY - translateY
    return minTranslateY - itemHeight * (1 - Math.exp(-overscroll / itemHeight))
  }

  return translateY
}

这个函数只负责“原始位移映射到显示位移”,不启动动画,也不决定最终选中哪一项。

5.2 范围内:完全跟手

当位移满足:

1
minTranslateY ≤ translateY ≤ 0

函数直接返回输入。

例如 5 项、行高 36px,合法范围为 [-144, 0],原始位移 -81px 会原样返回 -81px。

注意,合法范围指的是连续区间,而不是只有 0、-36、-72 等整行位置才合法。整行对齐是松手后才处理的事情。

5.3 首项边界越界:向下拉出列表

当 translateY > 0,说明列表越过了首项边界。这里的原始越界距离就是 translateY 本身。

设行高为 h,越界距离为 d,可见拉伸量为:

1
R(d) = h × (1 - exp(-d / h)),其中 d ≥ 0

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,列表已经越过末项位置,继续向上移动。

此时先计算正的越界距离:

1
d = minTranslateY - translateY

再把压缩后的距离从末项边界减去:

1
可见位移 = minTranslateY - R(d)

例如:

1
2
3
4
5
6
7
8
listLength = 5
itemHeight = 36px
minTranslateY = -144px
原始位移 = -180px

越界距离 = -144 - (-180) = 36px
压缩后拉伸量 ≈ 22.76px
最终显示位移 ≈ -144 - 22.76 = -166.76px

首项和末项使用同一条阻尼曲线,只是以不同的边界为原点、向相反方向延伸。

5.5 为什么这个公式在边界处比较自然?

把函数的性质展开,就能理解这不是随意选择的一个指数表达式。

第一,越界为零时,拉伸也为零。

1
R(0) = h × (1 - 1) = 0

所以刚跨过边界时,显示位置不会突然跳变。

第二,刚越界时,变化速度与正常拖动一致。

对越界距离求导:

1
2
R'(d) = exp(-d / h)
R'(0) = 1

范围内的位移映射斜率也是 1,因此边界两侧不仅位置连续,一阶变化率也连续,手感不会突然从“一比一跟手”切换到另一个固定比例。

从近似关系看,当 d 很小时:

1
2
exp(-d / h) ≈ 1 - d / h
R(d) ≈ d

第三,越往外拖,新增位移越少。

1
R''(d) = -exp(-d / h) / h < 0

这表示曲线向下弯,边际增长持续减小。比如原始越界达到一行高度时,局部斜率已经下降到约 0.368。

第四,拉伸有渐近上限。

1
当 d → +∞ 时,R(d) → h

数学上,对于有限的正 d,拉伸量小于一行高度;但实际浮点计算中,指数项足够小时,1 - 指数项 可能被舍入成 1,结果恰好等于 h。

因此测试极大越界距离时,使用“小于等于一行高度”比“严格小于”更符合 JavaScript 数值计算的实际情况。

5.6 用分段函数统一理解

设 m = -(n - 1) × h,原始位移为 y,显示位移为 F(y):

1
2
3
         ⎧ R(y)          ,y > 0
F(y)  =  ⎨ y             ,m ≤ y ≤ 0
         ⎩ m - R(m - y)  ,y < m

这可以总结为:

合法范围内不改动;越界后保留方向,只压缩超出最近边界的那一部分。

对于有效的有限输入,可见位移大致被限制在:

1
minTranslateY - itemHeight ≤ 可见位移 ≤ itemHeight

两端是考虑浮点舍入后的界限;在实数模型中,有限越界距离对应的外侧界限不会真正取到。

5.7 为什么不直接截断,或者统一乘以 0.3?

三种方案的差别如下:

方案 越界反馈 问题或取舍
直接截断到边界 完全拉不出去 边界明确,但缺少弹性反馈
越界距离乘固定比例 可以继续拉出,阻力固定 位移仍会无限增长;边界斜率通常突变
指数压缩 刚越界接近跟手,随后越来越难拉动 计算稍复杂,但手感更渐进,且拉伸量有界

没有一种方式适合所有产品。日期滚轮通常希望边界柔和、可见拉伸可控,所以这里选择指数压缩。

六、getNearestDateTimeIndex:把连续位置转换成离散选项

6.1 完整实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const getNearestDateTimeIndex = (
  translateY: number,
  listLength: number,
  itemHeight: number
) => {
  if (listLength <= 0 || itemHeight <= 0) {
    return 0
  }

  const minTranslateY = -(listLength - 1) * itemHeight
  const boundedTranslateY = Math.max(minTranslateY, Math.min(0, translateY))
  return Math.max(0, Math.min(listLength - 1, Math.round(-boundedTranslateY / itemHeight)))
}

它只返回索引,不修改列表位置,也不调用弹簧动画。

6.2 第一步:把位移截断到合法范围

1
const boundedTranslateY = Math.max(minTranslateY, Math.min(0, translateY))

先用 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 第二步:换算成连续索引,再四舍五入

1
Math.round(-boundedTranslateY / itemHeight)

合法位移非正,取反后除以行高,就能得到当前位于第几行附近。

位移 连续索引 最近索引 目标位移
-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 第三步:再次限制索引范围

1
Math.max(0, Math.min(listLength - 1, roundedIndex))

在列表长度为正整数、行高为正、数值正常的条件下,前面的位移截断通常已经足够。最后再限制一次索引范围,是让返回值约束更明确的一道保护。

6.5 为什么使用可见位移,而不是拖动原始位移?

松手代码读取的是:

1
springs.y.get()

也就是用户当前实际看到的位置。

因此,确定目标和启动动画都以当前显示位置为依据。在合法范围内,原始位移与可见位移相同;在范围外,两者虽然不同,但截断后都会落到相同的首项或末项。

这一步不需要反解阻尼函数。

6.6 它不是惯性滚动算法

这个函数没有速度参数,也没有时间参数。只要松手位置相同,无论用户之前拖得快还是慢,它计算出的目标索引就相同。

因此,当前实现是“按当前位置吸附”,不是“根据松手速度向前甩动若干项”。弹簧动画内部可能涉及运动速度的连续演化,但那不等于业务层实现了速度投影选项。

七、React Spring:把目标位置变成随时间变化的动画

7.1 初始化动画值

1
2
3
4
const [springs, springApi] = useSpring(() => ({
  y: currentDateIndex * -dateItemHeight,
  config: { tension: 300, friction: 30 },
}))

这里保存的不是每次渲染都立即固定下来的普通数值,而是可以被动画系统持续更新的 SpringValue。

  • springs.y:动画值对象。
  • springs.y.get():读取此刻的数值。
  • springApi.set({ y }):立即设定位置,不请求一段追赶动画。
  • springApi.start({ y }):从当前状态向目标位置运动。
  • springApi.stop():停止正在运行的动画。

useSpring 的初始化回调建立初始值;后续父组件值变化,还需要代码显式调用 start,不能只依赖初始化表达式重新执行。

7.2 为什么动画列表使用 animated.ol?

1
2
3
4
<animated.ol
  style={{ transform: springs.y.to((y) => `translate3d(0, ${y}px, 0)`) }}>
  {/* 选项 */}
</animated.ol>

.to(...) 把数值型动画值映射为 CSS 变换字符串,animated.ol 负责把动画值的更新应用到元素样式。

这种方式不需要为了每一个动画帧都调用 React 的 setState,也不需要整列选项逐帧重新渲染。

这里使用 transform,主要是让列表通过视觉变换移动,而不是每帧修改 top 去参与普通布局。浏览器通常能够较高效地处理这种更新,但 translate3d 不是“必然零开销”或“必然获得独立 GPU 图层”的保证,仍应以实际性能分析为准。

7.3 弹簧动画和固定时长动画有什么不同?

固定时长动画通常思考的是:

1
用 300ms,按某条缓动曲线,从 A 走到 B

弹簧动画更接近:

1
2
目标在 B,当前位置与 B 有距离,于是产生恢复力;
运动又受到阻力,最终逐渐稳定在 B。

可以用经典弹簧阻尼模型帮助理解:

1
m × x'' + c × x' + k × (x - target) = 0

其中:

  • m:质量。
  • k:弹簧刚度,对应理解上的 tension。
  • c:阻尼系数,对应理解上的 friction。
  • x - target:当前位置到目标位置的偏差。

这是理解参数作用的模型,不是说业务代码直接以像素、秒等物理单位精确模拟了一根真实弹簧。

7.4 tension 与 friction 如何影响手感?

松手时使用:

1
config: { tension: 300, friction: 18 }

一般来说:

  • tension 越大,回到目标的趋势越强,运动通常越利落。
  • friction 越大,运动衰减越明显,通常越不容易反复振荡。
  • friction 较低时,更容易出现越过目标后再回来的弹性效果。

初始化配置中的阻尼为 30,松手配置中的阻尼为 18,体现了后者希望有更明显回弹的意图。

为了形成数量级直觉,可以在简化模型中假设 m = 1,则临界阻尼为:

1
2
3
cCritical = 2 × sqrt(k × m)
          = 2 × sqrt(300)
          ≈ 34.64

在这个假设下,30 比 18 更接近临界阻尼,通常更稳;18 则更容易产生可见回摆。但实际表现还与库的求解、初始状态、精度阈值及配置方式有关,不能只用这一个算式断言动画时长或回弹次数。

还要注意:React Spring 可以沿用或合并已有动画配置。当前源码在部分 start({ y }) 中没有再次指定 config,因此不能简单声称“外部同步和取消一定使用 friction: 30”。如果产品要求每条交互路径参数固定,应在对应路径显式传入配置。

7.5 空间阻尼与时间阻尼不能互相代替

对比项 越界指数阻尼 弹簧动画阻尼
输入 原始越界距离 当前运动状态与目标位置
主要发生阶段 手指拖动期间 松手后的归位期间
是否需要时间演化 不需要,是静态映射 需要,逐帧演化
控制什么 能被拉出多远、是否跟手 如何回到目标、是否回摆
对应代码 getResistedTranslateY springApi.start 的 config

给弹簧加大阻尼,不能替代拖动时的越界压缩;把越界曲线调得更硬,也不会自动产生松手后的回弹动画。

八、触摸生命周期:让动画、手势和业务状态交接清楚

8.1 为什么用 useRef 保存手势?

1
const gestureRef = useRef<{ pointerY: number; translateY: number } | null>(null)

这里保存的是一次手势的起点信息,不是界面需要直接展示的数据。

  • null:当前没有有效拖动。
  • 非 null:已经按下,可以根据记录的起点继续计算。

使用 useRef 可以在事件之间共享数据,而不必为了高频的触摸更新触发 React 渲染。

这并不意味着“动画组件里不能使用 state”,而是要区分职责:业务值可以通过 React 状态管理,瞬时手势和动画状态不必逐帧进入 React 渲染流程。

8.2 按下:停止旧动画,接管当前可见位置

关键顺序是:

1
2
3
4
5
settlingTargetRef.current = null
springApi.stop()
const translateY = springs.y.get()
springApi.set({ y: translateY })
gestureRef.current = { pointerY: touch.clientY, translateY }

逐步理解:

  1. 清除旧的吸附目标,让这次手势拥有新的控制权。
  2. 停止尚未结束的弹簧运动。
  3. 读取此刻真正显示的位置,而不是之前动画的目标位置。
  4. 显式把这个位置作为接管时的值。
  5. 记录手指位置和列表位置,作为本次拖动的共同起点。

假设上次松手目标为 -72px,动画还只走到 -50.4px。如果新手势从 -72px 开始计算,用户一按下就可能看到跳动。

正确起点应该是 -50.4px:

1
2
3
4
5
手指按下时:250px
列表此刻:-50.4px

手指仍在 250px → 原始位移 -50.4px
手指移到 260px → 原始位移 -40.4px

在合法范围内,这样能自然地从动画过渡到跟手拖动。对于“按下时仍处于越界区域”的特殊情况,还存在再次应用阻尼的问题,后文会单独讨论,不能仅凭这段逻辑就断言所有场景绝对无跳变。

8.3 移动:先算原始位移,再做一次阻尼

1
2
3
4
const translateY = getDragTranslateY(gesture.translateY, gesture.pointerY, touch.clientY)
springApi.set({
  y: getResistedTranslateY(translateY, dateTimeList.length, dateItemHeight),
})

这里没有 start,因为手指已经提供了每一刻的位置。如果每次移动都再启动一段弹簧动画,列表就会不断追赶手指,产生拖泥带水的延迟感。

拖动过程只改变显示位置,不立即调用 onChange;这使得业务选中值在松手时才正式提交,而不是每经过半行就改变一次日期。

8.4 松手:确定目标、启动动画、提交选中值

松手时依次完成:

  1. 清除手势记录。
  2. 根据当前可见位置计算最近合法索引。
  3. 记录本次吸附目标。
  4. 启动到目标整行位置的弹簧动画。
  5. 立即调用 onChange 通知父组件。

这里的“立即”很重要:业务值提交不需要等待动画结束。

例如选中项已经变成 11 月,界面仍在向 11 月的位置收敛,这是正常的“业务目标先确定,视觉再到达”。

相应地,父组件应当把 onChange 理解为“选中值提交”,而不是“动画播放完毕”。如果有埋点或操作必须等视觉稳定后才执行,应单独设计完成事件。

8.5 取消:回到已经提交的选中值

1
2
gestureRef.current = null
springApi.start({ y: currentDateIndex * -dateItemHeight })

系统中断、触摸被取消等情况会触发 touchcancel。

当前实现把它视为“本次选择未提交”:回到父组件当前选中的值,不调用 onChange。

这与 touchend 的“按当前位置提交最近项”不同。明确区分完成和取消,是手势组件保持语义稳定的重要细节。

九、父组件同步:为什么不能每次 onChange 后都重新启动动画?

9.1 一个容易出现的控制权冲突

DateTimeColumn 通过 dateTimeValue 接收业务选中值,通过 onChange 把用户的新选择交给父组件。

松手时会发生:

1
2
3
4
5
6
7
子组件启动吸附动画
  ↓
子组件调用 onChange
  ↓
父组件更新 dateTimeValue
  ↓
子组件渲染,相关 effect 执行

如果 effect 不加区分,看到值变化就重新启动同一个目标的动画,会形成重复发令。即使目标相同,也属于不必要的竞争,可能干扰预期的动画过程或配置。

9.2 settlingTargetRef 记录“当前正在自行完成的目标”

1
2
3
4
5
const settlingTargetRef = useRef<{
  index: number
  itemHeight: number
  listLength: number
} | null>(null)

这里不仅记录索引,还记录行高与列表长度,因为这些数据共同决定当前吸附上下文。

effect 的逻辑是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
useEffect(() => {
  if (gestureRef.current) {
    return
  }

  const settlingTarget = settlingTargetRef.current
  if (settlingTarget) {
    if (
      settlingTarget.index === currentDateIndex &&
      settlingTarget.itemHeight === dateItemHeight &&
      settlingTarget.listLength === dateTimeList.length
    ) {
      return
    }
    settlingTargetRef.current = null
  }

  springApi.start({ y: currentDateIndex * -dateItemHeight })
}, [currentDateIndex, dateItemHeight, dateTimeList.length, springApi])

可以把它理解成一个轻量的控制权协议:

情况 处理
正在拖动 暂不让 effect 抢走手势的位置控制权
父组件回传的正是当前吸附目标,且几何信息一致 保留已有动画,不重复启动
外部选中值或相关几何信息变了 清除旧目标,向新的受控位置动画

这里比较的是“位置同步是否还有效”,不是做完整的列表内容深比较。列表业务语义变化、父组件拒绝提交等复杂情况,仍需要另外约定。

9.3 为什么 onRest 要比较对象身份?

松手时会新建一个目标对象,并在回调中保留它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const settlingTarget = {
  index: resultIndex,
  itemHeight: dateItemHeight,
  listLength: dateTimeList.length,
}

settlingTargetRef.current = settlingTarget

springApi.start({
  y: resultIndex * -dateItemHeight,
  config: { tension: 300, friction: 18 },
  onRest: () => {
    if (settlingTargetRef.current === settlingTarget) {
      settlingTargetRef.current = null
    }
  },
})

假设旧动画 A 之后又启动了新动画 B。即使旧动画的结束或停止路径触发了回调,也不能让 A 清掉 B 的目标记录。

对象身份比较表示:

只有 ref 里仍然保存着“我这一次创建的目标对象”,我才有资格清除它。

单纯比较索引不够,因为两次不同的动画可能恰好拥有相同索引。新建对象相当于给每次吸附分配一个轻量的身份令牌。

这个思路也适用于异步请求、延迟任务和其他可中断动画:旧任务完成时,先确认自己是否仍是当前任务,再更新共享状态。

十、核心组件完整代码:把前面的环节接起来

下面给出当前 DateTimeColumn.tsx 的核心完整实现。三个运动计算函数分别使用前文的定义;样式类依赖当前项目的工具类体系,脱离项目复用时需要提供等价 CSS。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import { animated, useSpring } from '@react-spring/web'
import { cn } from 'cn'
import { useEffect, useRef } from 'react'
import type { TouchEventHandler } from 'react'
import {
  getDragTranslateY,
  getNearestDateTimeIndex,
  getResistedTranslateY,
} from './dateTimeColumnMotion'

interface ColumnProps {
  className?: string
  dateItemHeight?: number
  dateTimeValue: string
  dateTimeList: string[]
  onChange: (value: string) => void
}

export const DateTimeColumn: React.FC<ColumnProps> = ({
  className,
  dateItemHeight = 36,
  dateTimeValue,
  dateTimeList,
  onChange,
}) => {
  const halfDateItemHeight = dateItemHeight / 2
  const itemHeightVarStyle = {
    '--date-item-height': `${dateItemHeight}px`,
    '--half-date-item-height': `${halfDateItemHeight}px`,
    touchAction: 'none',
  }

  const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
  if (currentDateIndex === -1) {
    throw new Error('dateTimeValue is not in dateTimeList')
  }

  const gestureRef = useRef<{ pointerY: number; translateY: number } | null>(null)
  const settlingTargetRef = useRef<{
    index: number
    itemHeight: number
    listLength: number
  } | null>(null)

  const [springs, springApi] = useSpring(() => ({
    y: currentDateIndex * -dateItemHeight,
    config: { tension: 300, friction: 30 },
  }))

  useEffect(() => {
    if (gestureRef.current) {
      return
    }

    const settlingTarget = settlingTargetRef.current
    if (settlingTarget) {
      if (
        settlingTarget.index === currentDateIndex &&
        settlingTarget.itemHeight === dateItemHeight &&
        settlingTarget.listLength === dateTimeList.length
      ) {
        return
      }
      settlingTargetRef.current = null
    }

    springApi.start({ y: currentDateIndex * -dateItemHeight })
  }, [currentDateIndex, dateItemHeight, dateTimeList.length, springApi])

  const onTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
    const touch = e.touches[0]
    if (!touch) {
      return
    }

    settlingTargetRef.current = null
    springApi.stop()
    const translateY = springs.y.get()
    springApi.set({ y: translateY })
    gestureRef.current = { pointerY: touch.clientY, translateY }
  }

  const onTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {
    const gesture = gestureRef.current
    const touch = e.touches[0]
    if (!gesture || !touch) {
      return
    }

    const translateY = getDragTranslateY(gesture.translateY, gesture.pointerY, touch.clientY)
    springApi.set({
      y: getResistedTranslateY(translateY, dateTimeList.length, dateItemHeight),
    })
  }

  const onTouchEnd: TouchEventHandler<HTMLDivElement> = () => {
    if (!gestureRef.current) {
      return
    }

    gestureRef.current = null
    const resultIndex = getNearestDateTimeIndex(
      springs.y.get(),
      dateTimeList.length,
      dateItemHeight
    )
    const settlingTarget = {
      index: resultIndex,
      itemHeight: dateItemHeight,
      listLength: dateTimeList.length,
    }
    settlingTargetRef.current = settlingTarget

    springApi.start({
      y: resultIndex * -dateItemHeight,
      config: { tension: 300, friction: 18 },
      onRest: () => {
        if (settlingTargetRef.current === settlingTarget) {
          settlingTargetRef.current = null
        }
      },
    })
    onChange(dateTimeList[resultIndex])
  }

  const onTouchCancel: TouchEventHandler<HTMLDivElement> = () => {
    if (!gestureRef.current) {
      return
    }

    gestureRef.current = null
    springApi.start({ y: currentDateIndex * -dateItemHeight })
  }

  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      onTouchCancel={onTouchCancel}
      style={itemHeightVarStyle}
      className={cn(
        'date-picker-column h-[var(--popup-height)] overflow-clip relative',
        className
      )}>
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <animated.ol
          style={{ transform: springs.y.to((y) => `translate3d(0, ${y}px, 0)`) }}
          className="date-time-picker text-center">
          {dateTimeList.map((dateTimeStr) => (
            <li
              key={dateTimeStr}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {dateTimeStr}
            </li>
          ))}
        </animated.ol>
      </div>
    </div>
  )
}

从职责上看,这个组件只关心“一组字符串组成的等高选项如何选择”,并不需要知道自己代表年、月还是日。这也是它可以复用为其他滚轮选择列的原因。

十一、用一次完整手势串起所有计算

11.1 普通范围内,从第三项拖向第四项

条件:5 个选项、行高 36px,当前索引为 2。

1
2
3
初始位移:-2 × 36 = -72px
手指起点:200px
手指松开位置:180px

拖动时:

1
2
3
原始位移 = -72 + (180 - 200) = -92px
合法范围 = [-144, 0]
可见位移 = -92px

松手时:

1
2
3
连续索引 = 92 / 36 ≈ 2.556
最近索引 = 3
目标位移 = -3 × 36 = -108px

最终由弹簧动画从 -92px 移动到 -108px,并把索引 3 对应的字符串交给父组件。

11.2 从首项向下越界,再弹回首项

1
2
初始位移:0px
手指向下移动:72px

拖动时:

1
2
3
原始位移 = 72px
可见位移 = 36 × (1 - exp(-72 / 36))
         ≈ 31.13px

松手时:

1
2
3
可见位移 31.13px → 截断为 0px
目标索引 = 0
目标位移 = 0px

列表从约 31.13px 回到 0px,形成首项边界的回弹。

11.3 从末项继续向上越界

1
2
3
4
初始位移:-144px
手指再向上移动:36px
原始位移:-180px
可见位移:约 -166.76px

松手后可见位移被截断到末项边界,得到索引 4,再由弹簧动画回到 -144px。

11.4 动画途中重新按下

1
2
3
4
上次目标:-108px
此刻动画位置:-96px
用户重新按下:停止动画,以 -96px 作为新起点
手指向下移动 10px:列表变为 -86px

在合法范围内,这个过程不需要先跳到旧目标,也不需要等待旧动画完成。它体现的是“用户输入优先于未完成动画”。

十二、布局、遮罩与触摸行为如何配合?

12.1 等高是索引公式成立的前提

每一项同时设置高度与行高:

1
className="h-[var(--date-item-height)] leading-[var(--date-item-height)]"

索引与位移的线性关系依赖于“所有项高度相等”。如果选项可以换行、行高不一致,就不能继续使用 -index × itemHeight,而需要累计每项高度,松手时再查找最近的中心点。

12.2 overflow-clip 裁剪视觉内容,不提供原生滚动

日期列容器使用 overflow-clip,超出窗口的列表内容被裁剪。这里不是通过原生 scrollTop 滚动,真正的位置来自动画列表的 transform。

所以浏览器不会自动替我们处理吸附、边界回弹和惯性;这些行为需要由当前的手势与动画逻辑负责。

12.3 遮罩与选中框不要拦截触摸

父组件中,遮罩和选中框位于列表上方,并使用:

1
pointer-events: none

否则,透明覆盖层也可能挡住下方日期列的触摸命中,导致“看得见数字,却拖不动”。

遮罩通过上下两个渐变弱化远离中心的选项;中间选框强调当前选择区域。它们只负责视觉层次,不参与位移计算。

12.4 touch-action: none 的作用与边界

日期列内联设置:

1
touchAction: 'none'

目的是告诉支持该机制的浏览器:这个区域的触摸平移、缩放不按默认直接操作行为处理,由组件接管。

需要注意:

  • touch-action 不等同于调用 preventDefault(),也不等同于阻止事件冒泡。
  • 它会影响这个区域内的浏览器默认手势,不能不加区分地作用到整页。
  • 当前使用 Touch Events,实际滚动竞争、取消和嵌套容器行为仍要在目标移动浏览器上验证。
  • 如果后续确实需要主动取消触摸默认行为,要检查事件是否可取消,以及监听器是否为被动监听,不能在不确认的情况下机械添加 preventDefault()。

十三、年月日联动:动画只是视图,日期合法性属于业务层

DatePickerPopup 分别生成:

  • 年列表:起始年到结束年。
  • 月列表:1 到 12。
  • 日列表:当前年月对应的天数。

三个 DateTimeColumn 复用同一套运动逻辑,只传入不同的列表和值。

但日期选择器还有另一类问题:业务合法性与动画合法性不是一回事。

例如从 1 月 31 日切换到 2 月,日列表可能从 31 项变为 28 或 29 项。此时必须先决定日期规则:是把日截断到当月最后一天,还是采用其他明确的交互策略。

动画列要求:

1
dateTimeList.includes(dateTimeValue)

当前组件在值不属于列表时直接抛错,因此父组件或日期辅助层必须确保值与列表一致。不能只更新列表,仍把 "31" 作为二月的选中值传进去。

本文不展开日期辅助类对月份赋值的内部处理,也不据此假定所有月末与闰年边界都已验证。更稳妥的设计顺序是:

1
2
3
4
5
6
7
8
9
计算新的年月日
  ↓
按业务规则规范化日期
  ↓
生成新的合法选项列表
  ↓
传入合法的选中值
  ↓
动画层同步到新位置

另外,生成了起止年份,并不等于已经实现“精确到某一天的起止日期限制”。如果产品要求最早只能选某年某月某日,还需要在对应年份限制月份、在边界月份限制日期。

十四、用 Vitest 测试核心运动规则

14.1 为什么把计算抽成纯函数?

如果所有公式都写进触摸处理函数里,验证一次位移计算就可能需要构造 DOM、模拟触摸和等待动画。

抽成纯函数后,测试只关心:

1
给定输入 → 返回什么数值

它们不依赖浏览器布局、不读取 React 状态,也不依赖动画时钟,因此可以直接进行确定性的数值测试。

当前已有五个 Vitest 用例,覆盖以下规则:

用例 验证重点
范围内一比一移动 中间位移及两个合法边界保持原值
首尾越界阻尼 两端允许可见拉伸、方向正确、对称、极大输入不超过拉伸上限
回拖计算 原始位移由起点决定,能跨过边界进入合法范围
中断弹簧后的新拖动 纯拖动函数能以当前可见位置作为起点,不丢失这段位置
最近项吸附 四舍五入、首尾截断、单项列表

14.2 代表性断言

下面节选现有测试中的关键断言:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { describe, expect, it } from 'vitest'
import {
  getDragTranslateY,
  getNearestDateTimeIndex,
  getResistedTranslateY,
} from '@/components/form/dateTimeColumnMotion.ts'

const itemHeight = 36
const listLength = 5
const minTranslateY = -(listLength - 1) * itemHeight

describe('dateTimeColumnMotion', () => {
  it('keeps in-range movement at a one-to-one distance', () => {
    expect(getResistedTranslateY(-81, listLength, itemHeight)).toBe(-81)
    expect(getResistedTranslateY(0, listLength, itemHeight)).toBe(0)
    expect(getResistedTranslateY(minTranslateY, listLength, itemHeight)).toBe(minTranslateY)
  })

  it('resists top and bottom overscroll while allowing visible stretch', () => {
    const topStretch = getResistedTranslateY(itemHeight, listLength, itemHeight)
    const bottomStretch = getResistedTranslateY(minTranslateY - itemHeight, listLength, itemHeight)

    expect(topStretch).toBeGreaterThan(0)
    expect(topStretch).toBeLessThan(itemHeight)
    expect(bottomStretch).toBeLessThan(minTranslateY)
    expect(bottomStretch).toBeGreaterThan(minTranslateY - itemHeight)
    expect(Math.abs(topStretch - (minTranslateY - bottomStretch))).toBeLessThan(1e-10)
    expect(getResistedTranslateY(itemHeight * 100, listLength, itemHeight)).toBeLessThanOrEqual(
      itemHeight
    )
  })

  it('supports dragging back across an edge without losing the raw pointer delta', () => {
    expect(getDragTranslateY(12, 100, 90)).toBe(2)
    expect(getResistedTranslateY(getDragTranslateY(0, 100, 90), listLength, itemHeight)).toBe(-10)
  })

  it('starts a new drag at the visible position after an interrupted spring', () => {
    const visibleTranslateY = -itemHeight * 1.4

    expect(
      Math.abs(getDragTranslateY(visibleTranslateY, 250, 250) - visibleTranslateY)
    ).toBeLessThan(1e-10)
    expect(
      Math.abs(getDragTranslateY(visibleTranslateY, 250, 260) - (visibleTranslateY + 10))
    ).toBeLessThan(1e-10)
  })

  it('snaps to the nearest legal item and clamps overscroll to the list', () => {
    expect(getNearestDateTimeIndex(-itemHeight * 1.6, listLength, itemHeight)).toBe(2)
    expect(getNearestDateTimeIndex(itemHeight * 2, listLength, itemHeight)).toBe(0)
    expect(getNearestDateTimeIndex(minTranslateY - itemHeight * 2, listLength, itemHeight)).toBe(4)
    expect(getNearestDateTimeIndex(0, 1, itemHeight)).toBe(0)
  })
})

项目已提供非交互测试脚本 "test": "vitest run",在项目根目录执行以下命令可以只运行目标文件:

1
pnpm test src/components/form/__test__/dateTimeColumnMotion.test.ts

这里的 @ 导入依赖项目现有路径别名。把示例移到独立项目时,需要配置相同别名,或者改成相对路径。

14.3 浮点断言为什么使用误差范围?

指数运算和小数乘法可能产生浮点舍入误差,不适合对所有结果都使用严格相等。

例如首尾阻尼对称性采用:

1
expect(Math.abs(topStretch - (minTranslateY - bottomStretch))).toBeLessThan(1e-10)

这明确表达的是“误差必须严格小于 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 记录为起点,这是读取视觉状态的正确方向。

但第一次移动时,又会把这个起点当作原始位移送入阻尼函数。即使指针没有明显移动,也可能出现:

1
2
原本显示:20px
再次映射:36 × (1 - exp(-20 / 36)) ≈ 15.34px

因此,“读取可见位置”能避免从旧目标开始拖动,却不能单独解决“越界可见值被再次压缩”的问题。

一种改进思路是:先把可见拉伸量反解成原始越界距离,再作为新手势的计算基准。

对于:

1
v = h × (1 - exp(-d / h))

可以得到:

1
d = -h × ln(1 - v / h)

其中 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 依赖没有再次变化,之前被跳过的同步不会自动重放。

可选策略包括:

  1. 手势期间冻结这一列的列表和行高。
  2. 外部数据变化时取消当前手势,再按新列表归位。
  3. 显式记录待同步状态,手势结束时重新协调。

具体选哪一种取决于产品语义,但必须把它设计成协议,而不是假定所有外部变化都会自动被 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 同时决定:

  1. 每项视觉高度。
  2. 索引对应的位移间距。
  3. 越界最大拉伸尺度。

这样参数少,也容易保持不同尺寸下的相似手感。但如果设计希望行高 36px,最多只能拉出 20px,就需要把阻尼尺度独立出来。

可以在设计上改为:

1
R(d) = L × (1 - exp(-d / L))

其中 L 是独立的最大拉伸尺度,而 itemHeight 继续只负责行间距。这个形式仍保持边界斜率为 1。

如果进一步分别设置幅度 A 和距离尺度 B:

1
2
R(d) = A × (1 - exp(-d / B))
R'(0) = A / B

那么要与合法范围的一比一跟手平滑衔接,就需要注意 A / B 是否为 1。调参不仅是“看起来舒服”,也涉及边界连续性。

16.2 调整弹簧参数时,一次只改一个维度

一个比较实际的调参顺序是:

  1. 先确定行高和允许的越界拉伸量。
  2. 确认正常拖动没有延迟、跳动或重复阻尼。
  3. 再调整松手弹簧的恢复力度。
  4. 最后调整阻尼,控制回摆幅度与收敛速度。

如果拖动坐标本身算错,单纯提高 friction 只能掩盖问题,不能修复问题。

16.3 惯性滚动需要新增哪些信息?

如果希望用户快速一甩,列表继续滚动若干项,就需要额外记录指针位置与时间,估计松手速度。

一种概念上的流程是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
采样指针位置与时间
  ↓
估计松手速度
  ↓
用衰减或投影模型预测目标位置
  ↓
把目标限制在合法范围
  ↓
转换成最近的合法索引
  ↓
弹簧吸附到整行位置

真正实现时还要考虑速度采样窗口、方向变化、极短手势、异常时间间隔和边界能量处理。惯性增加的是一套“目标预测规则”,不是把 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 比较目标对象身份 旧任务完成前先确认自己仍是当前任务
测试分层 纯函数测试与交互验证分开 数学正确、事件正确、平台体验正确需要不同证据

如果把整个实现压缩成五句话,就是:

  1. 按下时接管当前可见位置,建立固定手势起点。
  2. 移动时从起点计算原始位移,只对越界部分做一次非线性压缩。
  3. 松手时把可见位移转换成最近的合法索引。
  4. 用弹簧动画到达目标位置,同时把业务选中值提交给父组件。
  5. 通过控制权和任务身份管理,避免新手势、旧动画与父组件同步互相覆盖。

日期选择器只是一个具体载体。真正可以复用的,是这套“输入坐标 → 几何约束 → 视觉映射 → 离散决策 → 动画收敛”的设计方法。

当我们把每一层的输入、输出和边界说清楚,所谓“有弹性的手感”就不再是凭感觉拼出的效果,而是可以推导、调节、测试和逐步完善的交互系统。