☰
Unity 滚动吸附式年份选择器:用数学定位替代 Dropdown
2026/10/27 6:27:45 网站建设 项目流程

1. 年份这一栏,为什么不能用下拉框糊过去

做 Unity 的年份选择器,起点往往不是技术难题,而是被人机交互细节反复摩擦。我给某项目做一份信息登记页时,表单里有一栏要选年份:车辆年款、入学年份、入职年份这类,跨度大概从 1990 年到 2035 年,四十多个选项。第一版我图省事,直接挂了一个 Unity 自带的 Dropdown,把年份列表塞进 options 里跑通了,然后就被打回来了。原因很简单:四十多个选项垂直展开之后,屏幕装不下,用户得在一堆等宽文字里用眼睛逐行扫,扫到哪一行全凭运气,选个「2008」要滑三四屏。那一刻我才意识到,年份是一个有序的连续整数序列,它天然适合像旋钮一样被"拨动",而不是像菜单一样被"扫描"。

这篇文章要讲的,就是怎么用最朴素的数学,在 Unity 里从零搭一个滚动吸附式的年份选择器。核心逻辑小到可以写在一张便签纸上:一个除法算出索引,一个乘法算出位置,一个缓动函数把位置送过去。没有第三方插件,没有复杂的循环列表算法,纯 uGUI 加两百行左右的 C#。适合谁看?适合正在做表单页、筛选面板、报表时间维度选择,又被 Dropdown 体验坑过的人;也适合刚接触 uGUI 滚动视图,想弄明白ScrollRect那套坐标系到底怎么回事的初中级开发者。我这里讲的是原理示范,不是拿来即用的成品插件,你看完之后能自己改出月份选择器、体重选择器、金额区间选择器,那些都是同一个骨架换一张皮。

需要先说清楚的是,我的方案建立在"数据量不大"这个前提上。年份这种东西,正常业务里最多也就一百来年的跨度,一百个 UI 元素全部实例化放在 Content 下面,对现代设备来说毫无压力。真正需要对象池、需要循环复用的场景是几百上千项的滚动列表,那是另一个量级的问题,我会在最后一节讲清楚边界在哪、什么时候该换方案,但在这篇里,我坚持用全量创建 + 数学定位,因为它足够简单,简单就意味着三个月后你回来改它不会骂自己。

1.1 Dropdown 在年份场景下暴露的三个硬伤

把 Dropdown 摊开来看,它在年份选择这件事上有三个绕不过去的问题,理解了这三点,你才知道自己为什么要多花半天时间手写一个组件。

第一个问题是展开区域与内容量的矛盾。Dropdown 的下拉模板默认是一个固定高度的 ScrollRect,展开后要么遮挡页面主体,要么被屏幕边缘裁掉。年份有四十到一百个选项,用户展开后看到的永远是一个窄条窗口,滚动条细得像头发丝,拖拽精度极差。更麻烦的是模板本身是个 Mask 包着的 Viewport,如果你的表单放在一个带遮罩的弹窗里,Dropdown 的模板有时会被父子遮罩关系搞得出不来,这是 uGUI 里非常经典的层级坑。

第二个问题是选中态不直观。Dropdown 展开时,当前选中的那一项只是被一个浅色底衬托一下,用户滑到中间根本记不住自己原来选的是哪一年。而滚动选择器的好处在于,被选中的那一项永远固定在视口正中央,视觉锚点极其稳定,用户眼睛不需要重新定位。这个差别在触屏设备上尤其明显——手指拨动滚轮是最自然的动作,点击展开菜谱再滑动反而是反直觉的。

第三个问题是性能与内容重建的隐性开销。Dropdown 每次ClearOptions+AddOptions都会触发整个模板的 Layout 重建,而且它的每一项都是一个独立的小 Toggle,带独立 Image 和 Text。你可能会说"我这辈子就构建一次",那确实无所谓。但如果你做的是多语言切换的年份列表,或者年份后面要附加"(今年)""(闰年)"这种动态文案,频繁刷新 Dropdown 的代价就很难看了。自己写的选择器,文本只在初始化时写一次,后续所有交互只改 Transform 和颜色的数值,不触发任何重建。

对比维度自带 Dropdown滚动吸附选择器
首屏可见项数量5~8 项(受高度限制)5~9 项,可通过项高调节
定位目标项的方式手动滑动 + 眼睛扫描拨动到中心,或代码直接跳转
选中态视觉锚点跟随内容滚动,不稳定固定在视口中心,稳定
内容变更成本整表重建仅改文本,一次性的
移动端手感需要精确点击小号滚动条惯性拨动,符合直觉
实现成本零半天到一天

1.2 滚动选择器真正解决的交互问题

我后来复盘,发现被驳回的根本原因不是"不好看",而是选择成本。用户在四十多个等长文本里找一个目标,靠的是逐行比对,这是一个 O(n) 的线性搜索,每多一个选项就多一分疲劳。滚动选择器把这件事变成了 O(1):用户只需要记住目标年份大概在什么位置(比如"90 年代末"),一次大幅拨动就能到那个区间,然后微调。因为年份本身是等距、单调、可预测的数列,用户在心里对它有一张地图,滚动选择器正好把这张心理地图投射到了屏幕上。

还有个更微妙的点:滚动选择器天然带阻尼感和边界感。手指拨得快,它滑得远;拨得慢,它挪一格。这种"物理感"让界面显得有分量,用户会觉得自己的操作被认真对待了。而 Dropdown 的列表页滑动是没有这种映射关系的,你滑多快它就滚多快,松手即停,干巴巴的。

1.3 这套方案的适用规模写在明面上

我不想让读者产生误解,所以这一段必须独立成段讲清楚。这套"全量创建 + 数学定位"的方案,在项目数量小于两百的时候是最优解,超过这个量级开始出现两个隐患:一是初始化瞬间的实例化卡顿(Instantiate 一百次在低端机上有十几毫秒的尖峰),二是大量不可见 UI 元素带来的 Canvas 重建成本——只要其中任意一个元素的 Transform 或颜色变化,整个 Canvas 的合批都可能被打断。

真到那一步,改造方向不是推翻重来,而是在现有骨架上加两层东西:一是可视区裁剪,只让索引落在currentIndex ± 5范围的 Item 激活,其余SetActive(false);二是位置复用,把滚动位置对count * step取模,让有限的十几个 Item 循环映射到无限的位置上。这两层改造都需要你现在这套数学骨架作为基础,所以我建议先把这一百行的核心逻辑吃透,别一上来就抄循环列表的代码,抄完你也不知道自己在改什么。

2. 剥开看:整个选择器的数学骨架只有一行除法

很多人觉得滚动选择器复杂,是被"吸附""惯性""缓动"这些词唬住了。把视觉层全部剥掉之后,它的内核只是一个映射关系:滚动位置的像素值 ↔ 选中项的整数索引。这两者之间是线性的,因为有间距和项高构成的固定步长。所以反推索引就是一次除法加一次四舍五入,正向定位就是一次乘法。剩下的所有代码,都是在伺候这个映射关系——让它平滑、让它有边界、让它在视觉上好看。

我见过的失败实现,九成不是数学写错,而是坐标系没对齐。ScrollRect改的是content.anchoredPosition,这个值受 anchor、pivot、父级尺寸三重影响,你不把这三者钉死,写出来的公式今天对明天错,换个分辨率就偏移二十像素。所以我这一节的顺序是:先钉坐标系,再推导留白,最后才是索引公式。

2.1 先把 Content 的锚点和轴心钉死

我的约定是这样的:Content的anchorMin = (0, 1)、anchorMax = (1, 1)、pivot = (0.5, 1)。翻译成人话:Content 的宽度自动跟随父级 Viewport 撑满,高度由我们自己设置,而它的轴心点在顶部正中。这样设置之后,content.anchoredPosition.y就有了一个非常干净的含义——Content 的顶边相对于 Viewport 顶边的垂直偏移量,向上为正。

这一点必须画在纸上记住。当anchoredPosition.y = 0时,Content 的顶边和 Viewport 的顶边重合,也就是列表停在最开头,第一个 Item 位于视口上方区域。当anchoredPosition.y增大,Content 整体上移,我们看到的是越靠后的内容。这个方向和"滚动条往下拉"是反的,第一次写很容易搞反,症状是拖拽方向和内容移动方向相反,或者吸附总是跳到错误的位置。

再来定 Item 的坐标系。每个 Item 的anchorMin和anchorMax都设成(0, 1)到(1, 1),也就是顶部横向撑满,高度由sizeDelta.y指定为固定的itemHeight。pivot设成(0.5, 0.5),让锚点落在 Item 自己的中心。这样item.anchoredPosition.y的含义就是"这个 Item 的中心,相对 Content 顶边往下偏了多少(取负值)"。

把这两个约定合起来看,就得到了整篇文章最核心的一条式子:

第 i 个 Item 的中心,距离 Content 顶边 = topPadding + i * step + itemHeight / 2

其中step = itemHeight + spacing,是相邻两项中心之间的固定距离。这个式子是全篇的地基,后面所有的索引反推、位置定位、边界对齐,都是从它变形出来的。我用topPadding表示顶部留白,下一节专门讲它为什么必须是那个值。

2.2 顶端留白取 (视口高 - 单项高) / 2 的推导

如果topPadding取 0,那么第一个 Item 会紧贴 Content 顶边,而 Content 顶边又贴着 Viewport 顶边,结果是第一个年份永远无法滚到视口中央,它最多停在视口最上面。用户选 1990 年的时候,那一行只能出现在屏幕顶端,视觉上非常别扭,而且和其他年份的呈现方式不一致。同样的问题也出现在最后一个年份上。

解决办法是在 Content 的顶部和底部各留出一段空白。留多少?答案是把"第一个 Item 的中心顶到视口正中央"所需要的距离算出来。视口高度记为H,单项高度记为h。视口中心距离视口顶边是H / 2。我们希望第一个 Item 的中心正好落在这里,即:

topPadding + 0 * step + h / 2 = H / 2 => topPadding = (H - h) / 2

这就是那个公式的来源。底部的留白取同一个值,因为对称,且能保证最后一个 Item 也能滚到中心。于是 Content 的总高度可以写死:

contentHeight = 2 * topPadding + count * h + (count - 1) * spacing

注意最后一项后面没有 spacing,所以是count - 1个间隙。很多实现这里多加了一个 spacing,导致最后一项永远差十几像素滚不到位,用户选最后一年时总觉得吸附偏了一格——这个坑我踩过,排查了很久才发现是多加了一个间隙。

顺手验算一下ScrollRect的边界。ScrollRect 在 Clamped 模式下,content.anchoredPosition.y的合法范围是[0, contentHeight - H]。把上面两式代入:

contentHeight - H = 2 * (H - h) / 2 + count * h + (count - 1) * spacing - H = H - h + count * h + (count - 1) * spacing - H = (count - 1) * h + (count - 1) * spacing = (count - 1) * step

而最后一个 Item(索引count - 1)居中时,需要的滚动位置正好也是(count - 1) * step。两者完全重合,意味着在 Clamped 模式下,滚动到底刚好等于最后一项居中,不多不少。这说明留白的取值不是拍脑袋试出来的,而是被边界条件反推出来的唯一解。理解了这一点,你在改项高、改间距、改视口大小的时候,就知道该联动调整哪个量了。

2.3 从滚动位置反推索引,一次除法就够

正着推完了,反过来推同样简单。当前滚动位置是P = content.anchoredPosition.y,我们想知道此刻最靠近视口中心的是第几项。因为第i项居中时P = i * step,所以:

rawIndex = P / step currentIndex = Mathf.Clamp(Mathf.RoundToInt(rawIndex), 0, count - 1)

就这样,没有遍历,没有逐个比较距离,没有foreach找最近。这一点是年份选择器和一般滚动列表的关键区别:数据是有序等距的,索引和像素位置之间是一条直线,所以可以直接反解。如果换成不规则高度的列表(比如聊天记录),就必须遍历可见项找最近中心,或者维护一份累积高度表做二分查找,复杂度立刻上去了。我在很多项目里看到有人用年份列表也写了个foreach求最小距离,每次滚动结束跑一遍全量循环,二十项无所谓,但这就是没想清楚数据特性的典型表现。

取整用RoundToInt而不是FloorToInt,是因为我们要的是离中心最近的那一项,而不是中心上方的那一项。用向下取整会导致吸附总是往"上一格"偏,手感上像是有一点点阻力拖着你,滑到 2001 和 2002 中间时它非要回到 2001。这个细节很小,但用户是感觉得到的。

另外,Mathf.RoundToInt在.5时的行为是四舍六入五成双(银行家舍入),在某些恰好落在半格的位置上会出现轻微的不可预测。实际使用中影响可以忽略,因为手拨动很少精确停在半格,但如果你要写自动化测试断言某个位置对应的索引,记得避开整半格。

2.4 拖拽中与拖拽后,两套读数各司其职

代码结构上,我把"读数"分成两个时机,这一点值得单独说。拖拽过程中,我需要的是一个连续的浮点索引floatIndex = P / step,用来驱动视觉——让离中心越远的项越小、越淡。这里不能用取整后的整数,否则视觉会在相邻两项之间跳动,看起来一格一格的。拖拽结束之后,我需要的是取整后的整数索引,用它算出目标位置targetP = index * step,然后缓动过去,并在到位时抛出事件。

这两个值分开算,职责清晰,也避免了"视觉缩放算出来是 3.7,但事件抛的是 4"这类时序错乱。很多实现把两件事塞在一个OnValueChanged里,一边更新视觉一边抛事件,结果是滚动过程中事件疯狂触发,外部监听的业务逻辑被调用了几十次,如果有网络请求或者存档写入,那就直接炸了。记住一条:滚动过程中的每一帧都是中间态,只有吸附完成才是有效状态。

3. 从零搭出可用的年份选择器

原理讲完了,接下来是落地。我把工程搭建拆成三块:场景层级、数据与事件的协议、核心脚本。层级这一块我会讲得细一点,因为 uGUI 的遮罩和滚动视图有它的规矩,层级搭错了后面全在救火;脚本部分我给的是可直接编译的完整代码,注释里标了每一句在干什么,你可以复制到一个空场景里先跑起来看效果,再往自己的工程里搬。

3.1 层级结构与组件清单

层级我建议这样摆,从上到下:

YearPicker (RectTransform + Image[背景] + YearView 脚本) └─ Viewport (RectTransform + RectMask2D + Image[可选,raycastTarget开]) └─ Content (RectTransform, 尺寸由脚本控制, 不挂任何 Layout 组件) ├─ Item_0 (RectTransform + YearItem 脚本 + CanvasGroup) ├─ Item_1 └─ ... └─ SelectionMask (RectTransform + Image[半透明高亮条], raycastTarget关)

几个必须强调的细节。第一,Viewport 用RectMask2D而不是Mask。Mask依赖模板缓冲,会给被遮罩的内容额外增加一次绘制,而且和嵌套 Canvas 配合时容易出现"遮罩失效"的诡异现象;RectMask2D是纯矩形裁剪,没有模板开销,性能明显更好,代价是只能裁矩形——但滚动列表本来就是矩形,完全没有损失。

第二,Content 上不要挂VerticalLayoutGroup和ContentSizeFitter。这两个组件确实能自动排列和撑高,但它们会覆盖你在脚本里设置的anchoredPosition,导致每次布局重建你手动算的位置就被冲掉。我后面会讲这个坑的具体表现。这个方案里所有位置和尺寸都由脚本手动算,Layout 组件一个都不能留。

第三,SelectionMask 是纯装饰,它在 Viewport 外面(或者里面但排在 Item 之上),画一条半透明的高亮横条,告诉用户"中间这一行才是选中的"。它的raycastTarget必须关掉,否则会挡住 Viewport 的拖拽。

第四,每个 Item 内部的Text组件,raycastTarget也要关掉,只在 Item 根节点上保留一个透明的Image作为点击热区。UI 元素的射线检测是逐个进行的,Text 撑满整个 Item 的时候,鼠标点击会先命中 Text,虽然事件最终会冒泡到 Item,但多一层检测就是多一层开销,一百个 Item 就是一百次无谓判断。

3.2 数据协议:年份从哪来,事件往哪去

年份列表我不做序列化配置,运行时生成。原因很实际:写死在一份数组里,明年就得改一次;而业务需要的范围通常是"今年往前推 N 年"到"今年往后推 M 年",用DateTime.Now.Year算出来最省心。这里要注意一个细节,DateTime.Now取的是本地时间,如果服务器判定年份另有标准,两边可能出现跨年那一天的错位。稳妥做法是让服务端下发当前年份,客户端只负责展示范围。

参数我定了四个:startYear、endYear、descending(是否倒序,最新的年份排在上面,符合大多数人的直觉)、defaultYear。倒序与否只影响生成 Item 时的年度映射:

int year = descending ? endYear - i : startYear + i;

事件只对外暴露一个:event Action<int> OnYearChanged。它只在吸附完成后触发一次,参数是选中年份。拖拽过程中的中间态绝不抛事件。如果业务侧需要实时反馈(比如滚动时年份数字跟着变),那应该另开一个OnYearPreview,并且明确告诉调用方"这个回调每帧都可能触发,别在里面做重活"。

另外提供一个SetYear(int year, bool instant)方法,用于外部代码把选择器定位到指定年份。instant = true时直接跳过去不缓动,适合初始化;instant = false时走缓动,适合"回到今天"这类按钮。这个方法内部要做的是:index = descending ? endYear - year : year - startYear,然后targetP = index * step。看,还是那一个乘法。

3.3 核心脚本逐段拆解

先给 Item 的脚本,很短:

using System; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class YearItem : MonoBehaviour, IPointerClickHandler { [SerializeField] private Text label; [SerializeField] private Image background; private CanvasGroup group; private int year; private Action<int> onClicked; public RectTransform Rect => (RectTransform)transform; private void Awake() { group = GetComponent<CanvasGroup>(); if (group == null) group = gameObject.AddComponent<CanvasGroup>(); if (background != null) background.raycastTarget = false; if (label != null) label.raycastTarget = false; } public void Setup(int y, Action<int> onPick) { year = y; onClicked = onPick; label.text = y.ToString(); // 只在初始化写一次,之后永不修改 } // 由外层每帧驱动:t 是离中心的归一化距离,0 表示正中 public void ApplyVisual(float t, Color normal, Color selected) { float scale = Mathf.Lerp(1f, 0.72f, Mathf.Clamp01(t)); Rect.localScale = new Vector3(scale, scale, 1f); group.alpha = Mathf.Lerp(1f, 0.35f, Mathf.Clamp01(t)); if (background != null) background.color = Color.Lerp(selected, normal, Mathf.Clamp01(Mathf.InverseLerp(0f, 0.5f, t))); } public void OnPointerClick(PointerEventData eventData) => onClicked?.Invoke(year); }

label.text只在Setup里写一次,这一点很重要。Text.text的 setter 每次赋值都会触发一次文本重建,哪怕你赋的是同一个字符串(Unity 内部确实做了相等判断,但依赖它不如自己控制)。所以每帧更新视觉的函数里,绝对不要碰text。localScale和CanvasGroup.alpha的修改不会触发 Layout 重建,是可以放心每帧改的。

然后是滚动宿主。为了拿到"拖拽结束"这个钩子,我选择继承ScrollRect而不是用EventTrigger:

using System; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class YearScrollRect : ScrollRect { public Action OnDragEnded; public override void OnEndDrag(PointerEventData eventData) { base.OnEndDrag(eventData); OnDragEnded?.Invoke(); } }

为什么不用EventTrigger?因为EventTrigger挂在同一个 GameObject 上时,会和ScrollRect争抢事件处理权,谁先收到OnBeginDrag取决于组件顺序,很不稳定。继承重写则完全确定,base.OnEndDrag保证原生行为不变,回调在其中执行。这十几行代码是省不掉的,除非你打算自己从头实现拖拽逻辑,那要多写一百行。

主体脚本:

using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class YearView : MonoBehaviour { [Header("引用")] [SerializeField] private YearScrollRect scrollRect; [SerializeField] private RectTransform viewport; [SerializeField] private RectTransform content; [SerializeField] private YearItem itemPrefab; [Header("配置")] [SerializeField] private int startYear = 1970; [SerializeField] private int endYear = 2035; [SerializeField] private bool descending = true; [SerializeField] private float itemHeight = 60f; [SerializeField] private float spacing = 6f; [SerializeField] private float smoothTime = 0.12f; [Header("配色")] [SerializeField] private Color normalColor = new Color(1f, 1f, 1f, 0.35f); [SerializeField] private Color selectedColor = new Color(1f, 1f, 1f, 1f); public event Action<int> OnYearChanged; public int CurrentYear { get; private set; } private readonly List<YearItem> items = new List<YearItem>(); private int count; private float step; private float topPadding; private float targetY; private float smoothVel; private bool settling; private bool dragging; private void Start() { // 关键:第一帧 viewport 尺寸可能还是 0,强制刷新一次布局 Canvas.ForceUpdateCanvases(); Build(); SetYear(descending ? Mathf.Min(DateTime.Now.Year, endYear) : Mathf.Min(DateTime.Now.Year, endYear), true); } private void Build() { count = endYear - startYear + 1; step = itemHeight + spacing; topPadding = (viewport.rect.height - itemHeight) * 0.5f; content.anchorMin = new Vector2(0f, 1f); content.anchorMax = new Vector2(1f, 1f); content.pivot = new Vector2(0.5f, 1f); content.sizeDelta = new Vector2(0f, topPadding * 2f + count * itemHeight + (count - 1) * spacing); content.anchoredPosition = Vector2.zero; for (int i = 0; i < count; i++) { var item = Instantiate(itemPrefab, content); var rt = item.Rect; rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(0.5f, 0.5f); rt.sizeDelta = new Vector2(0f, itemHeight); rt.anchoredPosition = new Vector2(0f, -(topPadding + i * step + itemHeight * 0.5f)); int year = descending ? endYear - i : startYear + i; item.Setup(year, OnItemClicked); items.Add(item); } scrollRect.onValueChanged.AddListener(OnScrollValueChanged); scrollRect.onDragEnded += OnDragEnded; }

Build里最关键的是topPadding那一行,它用的viewport.rect.height必须已经是最终值。这就是为什么Start下面第一句是Canvas.ForceUpdateCanvases()。如果不加这一句,而 Viewport 的尺寸又依赖父级的锚点拉伸(大概率是这样),那么Start执行时rect.height很可能还是预制体上的初始值,算出来的留白是错的,症状是整体偏移,第一个年份不在中心。

OnDragEnded和OnItemClicked这两个回调:

private void OnDragEnded() { dragging = false; SnapTo(Mathf.RoundToInt(content.anchoredPosition.y / step)); } private void OnItemClicked(int year) { SetYear(year, false); } public void SetYear(int year, bool instant) { int idx = descending ? endYear - year : year - startYear; idx = Mathf.Clamp(idx, 0, count - 1); SnapTo(idx); if (instant) { content.anchoredPosition = new Vector2(content.anchoredPosition.x, targetY); settling = false; smoothVel = 0f; CommitSelection(idx); } } private void SnapTo(int idx) { idx = Mathf.Clamp(idx, 0, count - 1); targetY = idx * step; settling = true; scrollRect.velocity = Vector2.zero; // 接管惯性 }

SnapTo里那句scrollRect.velocity = Vector2.zero是这个手感的灵魂,下一节详细说。

每帧的驱动逻辑:

private void Update() { if (dragging) { UpdateVisual(); return; } if (settling) { float y = content.anchoredPosition.y; float next = Mathf.SmoothDamp(y, targetY, ref smoothVel, smoothTime, Mathf.Infinity, Time.unscaledDeltaTime); content.anchoredPosition = new Vector2(content.anchoredPosition.x, next); if (Mathf.Abs(next - targetY) < 0.5f) { content.anchoredPosition = new Vector2(content.anchoredPosition.x, targetY); settling = false; smoothVel = 0f; CommitSelection(Mathf.RoundToInt(targetY / step)); } } UpdateVisual(); } private void UpdateVisual() { float floatIndex = content.anchoredPosition.y / step; for (int i = 0; i < items.Count; i++) { float t = Mathf.Abs(i - floatIndex); if (t > 3.5f) { items[i].ApplyVisual(3.5f, normalColor, selectedColor); continue; } items[i].ApplyVisual(t, normalColor, selectedColor); } } private void CommitSelection(int idx) { int year = descending ? endYear - idx : startYear + idx; if (year == CurrentYear) return; CurrentYear = year; OnYearChanged?.Invoke(year); }

UpdateVisual里对超过 3.5 格的项做截断,是因为ApplyVisual内部的Mathf.Clamp01已经饱和,继续算下去是浪费。这个截断看起来微不足道,但在一百项的列表里每帧省下的插值计算是实打实的。

dragging标志需要你来维护,我在YearScrollRect里再加一个OnDragStarted回调,或者更简单——在OnBeginDrag里把它置真。别偷懒用scrollRect.velocity.magnitude > 0来判断,因为手指按住不动时速度也是零,会被误判成"没在拖拽"然后开始吸附,手感和鬼一样。

3.4 缩放与透明度:让"近大远小"成立

视觉层这块,公式简单但效果拔群。核心是一个归一化距离t = |i - floatIndex|,t = 0表示正对中心,t = 1表示偏了一整格,往下递增。然后用Lerp把它映射到缩放和不透明度上:

scale = Lerp(1.0, 0.72, clamp01(t)) alpha = Lerp(1.0, 0.35, clamp01(t))

这里有两个经验值值得说。缩放的下限不要低于 0.7,再小下去文字会糊,尤其是字号较小的时候,Unity 的 Text 在缩放后没有重新光栅化,会明显发虚。如果确实想要更强的纵深感,正确做法是在t > 1的时候把 Item 的 Text 换成更小的字号重新生成,而不是靠缩放硬压,那是另一个复杂度了。

透明度的衰减要比缩放更激进,因为透明度承担了"把注意力收束到中心"的主要职责。缩放只是暗示层次,真正让用户的视线聚焦的是中间那一行更亮、更实。我用 0.35 是比较克制的值,如果你希望效果更强烈,可以到 0.2,但注意不要让边缘项淡到看不见——用户需要能看见"上面还有内容",这是一种可发现性,太淡了会让人以为到底了。

颜色过渡我是用Mathf.InverseLerp(0f, 0.5f, t)做的,含义是"t在 0 到半格之间时,从选中色渐变到普通色,超过半格就固定为普通色"。这样中心那一项的高亮会有一个很窄的过渡带,拨动的时候颜色变化细腻,不会一格格跳。这个 0.5 的阈值是我试出来的,你可以按自己的项高和间距调。

4. 手感调优:把"能用"推到"好用"

代码跑起来只是及格线,从"能选"到"愿意用",中间隔着的是手感。这一节讲的全是拧旋钮的活,没有新原理,但每一步我都试过至少三四个取值,下面给的是我认为最稳的区间,你可以在这个基础上微调,不建议大幅偏离。

4.1 接管惯性是必须的,不是可选的

原生ScrollRect松手之后有惯性滑行,decelerationRate控制衰减速度。如果你不管它,直接在OnEndDrag里算出目标位置开始缓动,会出现两种糟糕的情况:一是惯性还没停,你的缓动就在跟它抢anchoredPosition的写入权,结果是抖动;二是你缓动到位了,残余的速度还在,下一帧又被推走一点,然后再触发一次吸附,来回弹。

所以我选择彻底接管:在OnEndDrag里第一件事就是velocity = Vector2.zero,把原生惯性掐死,然后用自己的SmoothDamp从当前位置平滑过渡到目标位置。这样整段运动的曲线完全由我控制,smoothTime一个参数就能调手感,不用再和decelerationRate、elasticity纠缠。

这里还有个隐藏问题:ScrollRect在LateUpdate里会根据velocity更新位置。如果我在Update里写anchoredPosition,而ScrollRect在LateUpdate里又根据残留速度写一次,两者在同一帧内打架,最终呈现的就是一帧抖一下。所以清零velocity必须在松手的那一帧立刻做,不能等到下一帧。

另外movementType建议设为Clamped。Elastic会允许内容拖出边界再弹回来,听起来很有弹性,但在这种网格吸附的场景里,边界回弹和吸附缓动叠加,会出现"先弹出去再吸回来"的诡异运动。Unrestricted更不行,能一直拖到天上去。Clamped会让内容在边界处硬停,配合我们前面推导出的"边界正好等于末项居中",效果非常干净。

4.2 缓动参数的实际区间

smoothTime我用 0.12 秒。这个值的含义是"接近目标的特征时间",实际运动大约在 3 倍这个时间内完成,也就是 0.36 秒左右,用户感觉是"跟手但不拖沓"。给几个参考:

smoothTime主观感受适用场景
0.05干脆、几乎瞬移调试用,或极简风
0.08 ~ 0.10轻快,拨一下就到位移动端,追求效率
0.12 ~ 0.15有明显的"落位感"通用,我默认用这个
0.20 以上拖泥带水,有粘滞感不建议,除非做慢节奏展示

还有一个细节:SmoothDamp的时间参数我传的是Time.unscaledDeltaTime。原因很实际——如果这个选择器出现在一个暂停面板里(比如游戏中按 ESC 弹出的设置界面),Time.timeScale可能是 0,用Time.deltaTime会导致缓动完全停住,用户点"设置"按钮结果年份纹丝不动,非常迷惑。UI 动画绝大多数都应该用 unscaled 时间,这条经验值很多钱。

到达判定我用的是Mathf.Abs(next - targetY) < 0.5f,半像素。为什么不等到完全相等?因为浮点累加永远到不了精确相等,会永远处于settling状态,事件永远不抛。半像素在视觉上已经完全看不出来了,而且能保证在一帧内收敛。

4.3 滚轮、键盘与点击跳转的补强

桌面端用户很自然会去滚鼠标滚轮。原生ScrollRect收到OnScroll事件后会按scrollSensitivity移动内容,但它不会吸附。如果放任不管,滚轮滚完之后就停在一个半格的位置,视觉上歪着,很脏。

有两种处理法。懒一点的做法是在UpdateVisual之外加一个"静止检测":连续 0.15 秒内anchoredPosition没有变化,就触发一次SnapTo。这个方案代码少,但对滚轮是够用的,因为滚轮本质上是离散事件。干净一点的做法是重写YearScrollRect.OnScroll,把滚动量直接换算成索引步进:

public override void OnScroll(PointerEventData data) { if (Mathf.Abs(data.scrollDelta.y) < 0.01f) return; pendingSteps += data.scrollDelta.y > 0 ? -1 : 1; // 向上滚 = 索引减一 OnScrollStep?.Invoke(pendingSteps); pendingSteps = 0; }

然后在外层接收OnScrollStep,SnapTo(currentIndex + delta)。这样滚轮一次固定走一格,节奏非常清晰,而且不管滚轮多灵敏,都不会一次冲出去五格。这里有个方向问题:scrollDelta.y为正表示滚轮向上滚,而在我们的坐标系里向上滚应该是往前翻(索引减小),所以是减一。这个符号我一开始写反了,测试的时候滚上去年份反而往后走,很滑稽。

键盘支持更简单,在Update里检测Input.GetKeyDown(KeyCode.UpArrow)和Down,映射到SnapTo(currentIndex ∓ 1)。注意要判断这个面板是否处于焦点状态,否则用户在别的输入框里按方向键,年份也被改了。uGUI 里可以用EventSystem.current.currentSelectedGameObject判断,或者干脆只在面板激活且有鼠标悬停时才响应。

点击某项直接跳到该年,我已经在YearItem里接了OnPointerClick回调到SetYear(year, false)。这个功能的实际使用率比想象中高——用户看到目标年份就在视口边缘,会下意识去点它,而不是拨动。既然手已经在那儿了,就别逼人家再滑一下。

4.4 反馈:音效和震动的接入点

滚动选择器这种"齿轮感"强的控件,加一点点反馈,质感提升非常明显。关键是触发时机要和"格"绑定,而不是和时间绑定。

音效的接入点是CommitSelection,也就是吸附完成、索引真正变化的那一刻,播一次轻微的"咔"。不要在UpdateVisual里根据floatIndex的变化播,那会在一格之内触发多次,变成机关枪。

震动(移动端)同理,用一次极短的轻震,长度控制在 10 到 15 毫秒。注意要在设置里给用户关掉的选项,这类反馈对部分人来说是干扰。另外,如果你打算在每次索引变化时都播,要确认音源的数量够用——用AudioSource.PlayOneShot播同一段极短音频,节流做不好会糊成一片噪音,正确做法是用一个单独的AudioSource并在播之前Stop()。

5. 五个我真实踩过的坑

这一节不含新功能,全是排错记录。我按照"症状、原因、排查路径、修复"的结构写,你可以直接照着症状对号入座。

5.1 初始化那一帧,Viewport 的尺寸是假的

症状:选择器跑起来之后整体偏移,第一个年份明显不在正中央,而且偏移量随分辨率变化。改startYear或者改窗口大小,偏移量也跟着变。

原因:Build()在Start里执行,读取viewport.rect.height。但 Viewport 的尺寸是通过锚点拉伸从父级继承来的,如果父级本身也是拉伸的,uGUI 的RectTransform尺寸计算要等到布局系统跑完一轮才稳定。Start的执行时机在Awake之后,但不保证在所有 RectTransform 尺寸计算完成之后,所以此时读到的可能是预制体上保存的初始值。

排查路径:在Build()里打一行Debug.Log(viewport.rect.height),和运行后 Inspector 里看到的值对比。如果两者不一致,就是这个坑。再打印content.sizeDelta.y,验证它是不是按错误的topPadding算出来的。

修复:Build()之前先Canvas.ForceUpdateCanvases(),强制同步跑一轮布局。如果还不行(极少数情况,比如父级用了ContentSizeFitter需要多帧收敛),就改成协程延迟一帧:yield return null;之后再Build(),代价是有一帧的空白,可以用一个全屏遮罩盖住。

还有个衍生问题:运行时改变分辨率或者旋转屏幕,viewport.rect.height变了,但topPadding还是旧值,整体又会偏移。严谨的做法是监听viewport的尺寸变化,重新算topPadding和content.sizeDelta,然后重新定位到当前年份。这段代码不复杂,但很容易忘,尤其在你要做适配多端的时候。

5.2 LayoutGroup 与手动定位混用的灾难现场

症状:Item 的位置每隔几帧就"跳"一下,或者干脆全部挤到顶上,间距全乱。点一下空白处,位置突然变了。

原因:Content上如果残留了VerticalLayoutGroup或ContentSizeFitter,它们会在布局重建时接管子物体的anchoredPosition和sizeDelta,把你手动算的全部覆盖掉。而布局重建的触发时机非常隐蔽——改个文本、开关一个物体、甚至某些情况下纯粹的时间推移都可能触发。

排查路径:在Build()之后打印第一个 Item 的anchoredPosition,然后在Update里隔一段时间再打印一次,看是否被篡改。同时检查 Content 和 Item 上的组件列表,找任何LayoutGroup、ContentSizeFitter、LayoutElement的痕迹。

修复:全部移除。这个方案里,位置和尺寸的唯一权威是脚本。如果 Item 内部需要排列(比如年份左边有个小图标),那就在 Item 内部单独放一个节点用HorizontalLayoutGroup,不要让它影响到外层的位置计算。一个原则:手动算位置的容器,身上不能有任何自动布局组件,子物体的根节点也不能有。

5.3 遮罩、射线与 Canvas 重建的三重开销

症状:低端机上拖拽有明显掉帧,用 Profiler 看,Canvas.BuildBatch或者CanvasRenderer相关的耗时很高。

原因:几个常见问题叠加。一是用了Mask而不是RectMask2D,模板缓冲导致额外绘制;二是所有 Text 的raycastTarget都开着,每次鼠标移动都要做上百次射线检测;三是每帧修改Image.color或CanvasGroup.alpha时,如果同一个 Canvas 下有很多元素,会导致合批被打断。

排查路径:用 Frame Debugger 看绘制调用数,看每个 Item 是不是被拆成了独立批次。用 Profiler 的UI模块看Canvas.SendWillRenderCanvases的耗时。再检查每个 UI 元素的raycastTarget勾选情况。

修复:换成RectMask2D;把所有纯展示文字和背景图的raycastTarget关掉,只留 Item 根节点一个热区;把会发生变化的 Item(改 scale、改 alpha)和静态的背景、边框放在不同的 Canvas 下,或者至少保证它们的层级连续,减少合批打断。如果项数真的很多,考虑把不可见区域外的 Item 直接SetActive(false),这比改 alpha 到 0 更省,因为失活的元素不参与合批。

5.4 索引计算的三个边界陷阱

症状:滚到最上面或最下面时,吸附位置差一点,或者日志里报数组越界。

原因:三种情况。第一,Elastic模式下内容会超出合法范围,P变成负数或者大于(count-1)*step,算出的索引越界。第二,content.sizeDelta.y里把间距的数量算成了count而不是count - 1,导致边界差一个间距。第三,RoundToInt在极端位置算出count而不是count - 1,因为浮点误差。

排查路径:在最顶端和最底端各打一行日志,打印content.anchoredPosition.y和算出来的索引,跟预期值对比。

修复:movementType改Clamped;间距数量核对一遍;SnapTo里永远Clamp(idx, 0, count - 1),哪怕你觉得不可能越界。防御性代码在这里不是洁癖,是必要成本。

5.5 数量级上来之后,改造的切入点在哪

如果你看完上面的内容,发现自己的场景其实需要几百项(比如选分钟、选金额、选日期加时间),那就不能全量创建了。改造的切入点不在数学,数学是对的,改的是实例的管理方式。

第一个改造是可视区激活。在UpdateVisual里,除了算视觉,顺便判断t > 4.5的项SetActive(false),回到范围内的SetActive(true)。这能砍掉绝大部分的合批负担,改动量不到十行,收益立竿见影。

第二个改造是位置复用。当你需要无限循环(比如时间选择器里小时数循环),做法是把 Item 的实际位置对count * step取模,让固定数量的 Item 不断跳到另一端。这个改造会破坏"索引 = 位置 / step"的简单映射,需要维护一个偏移量,复杂度立刻上一个台阶。我的建议是:除非产品明确要求循环,否则不要做。年份不循环,用户滚到 1970 就该停住,这符合心理预期。

第三个改造是数据虚拟化。如果年份列表本身很大(比如从公元 1 年开始?),那就不是 UI 的问题了,而是数据不该全量送给前端。这种情况应该按需加载,滚动到边界附近时请求下一批,和长列表分页一个思路。

6. 几点不那么技术的话

这个组件我从第一版到现在,前前后后改了六七次,每次都是上线之后被真实用户的行为教育。印象最深的一次是,我原本把吸附判定写成"松手后 0.2 秒触发",结果用户里有人习惯快速连续拨动,每次拨完立刻再拨,导致动画被反复打断,最终落位非常随机。后来改成"松手即接管速度",才彻底解决。这说明手感的参数最好不要和时间绑定,要和用户动作的完成绑定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询