ARTICLE · 1230635
UGUI源码学习笔记_第六篇_Slider/Scrollbar/Dropdown/InputField

这四个控件分别是什么
- Slider(滑块)
:简言之它就是进度条、档位条。它把 [minValue, maxValue]里的业务值归一化到 0~1,再驱动 Handle(手柄)和 Fill(已填充条)两个可视元素;支持方向、反向、整型步进。本质是一个“带 UI 反馈的数值选择器”。 - Scrollbar(滚动条)
:天生就是“0~1 的一个比例”,表达的是“当前视口在内容里的位置”和“视口占内容的比例( size)”。可以单独拿来做任意进度指示,但绝大多数时候是和 ScrollRect 双向绑定,化身列表/文本框的滚动条。 - Dropdown(下拉框)
:从一组固定选项里“选一个”。展开时才临时创建列表和全屏遮罩层,收起即销毁。适合选项不多、用户只是偶尔选一次的场景,比如设置项、筛选条件。 - InputField(输入框)
:让“用户自己输入/编辑一段文本”。它区分两条路径——桌面原地编辑(逐帧从键盘事件取字符、支持 IME 中文合成)和移动软键盘( TouchScreenKeyboard)——并自带光标、选区、密码掩码、内容类型校验。
Slider:value 不是 normalizedValue
Slider 有 minValue、maxValue、value 和 normalizedValue。真正的业务值是 value,范围由前两者决定;normalizedValue 才固定映射到 0~1。
// Slider.cs 源码关键部分publicvirtualfloatvalue{get { return wholeNumbers ? Mathf.Round(m_Value) : m_Value; }set { Set(value); }}publicfloat normalizedValue{get {if (Mathf.Approximately(minValue, maxValue))return0;return Mathf.InverseLerp(minValue, maxValue, value); }set { this.value = Mathf.Lerp(minValue, maxValue, value); }}Set 先用 ClampValue 把输入限制到 [minValue, maxValue];开启 wholeNumbers 时还会 Mathf.Round。值真的变化后,它先 UpdateVisuals(),再按需调用 onValueChanged。所以 Handle 和 Fill 都是值的视觉结果,拖拽只是反过来算出比例并写回 normalizedValue。
Slider 的拖拽处理器挂在 Slider 所在对象上,但原始射线命中不一定就是它。Handle 上的 Graphic 可以先被命中,ExecuteHierarchy 再沿父链找到 Slider。这里常见的一句经验“关掉 Handle 的 Raycast Target 也不影响拖动”并不严谨:只有下方仍有可命中的 Graphic 时,事件链才接得上。
IInitializePotentialDragHandler 也没有负责计算 Handle 偏移,它只做一件事:
publicvirtualvoidOnInitializePotentialDrag(PointerEventData eventData){ eventData.useDragThreshold = false;}真正的 m_Offset 初始化、点轨道后跳转到新值,都在 OnPointerDown;持续回算位置则在 OnDrag。
Slider的方向、锚点驱动与键盘步进
Slider 有四种 Direction(LeftToRight / RightToLeft / BottomToTop / TopToBottom)。源码内部把它归一成两个维度:axis(横还是竖)和 reverseValue(最小值在右/上时为 true)。UpdateVisuals 里填充和手柄的位置不是直接设 localPosition,而是通过 DrivenRectTransformTracker 去驱动 Fill / Handle 的 RectTransform 锚点:
// Slider.cs UpdateVisuals(节选)m_Tracker.Add(this, m_FillRect, DrivenTransformProperties.Anchors);if (reverseValue) anchorMin[(int)axis] = 1 - normalizedValue; // RightToLeft:从右往左填else anchorMax[(int)axis] = normalizedValue; // LeftToRight:从左往右填这点延续了第 06 篇讲过的思路:Selectable 之外,UGUI 常靠 DrivenRectTransformTracker 接管子物体 Transform,外部手动改这些锚点会被它覆盖。也正因驱动的是锚点,当 Fill 的 Image.type == Image.Type.Filled 时会走另一条路:直接 m_FillImage.fillAmount = normalizedValue,不再动锚点。
另外,在调交互手感时容易撞上的边界:
SetValueWithoutNotify(float)与 InputField 的 SetTextWithoutNotify思路一致:改值但不触发onValueChanged。程序里想静默设初始值就用它。键盘/手柄方向键走 OnMove,步长stepSize = wholeNumbers ? 1 : (maxValue - minValue) * 0.1f。当navigation为 Automatic 且方向与axis一致时,FindSelectableOnLeft/Right/Up/Down直接返回null,于是方向键不再切换选中物体、专门用来改value。动画也能驱动: OnDidApplyAnimationProperties会在动画改了归一化值后重新UpdateVisuals,并在必要时onValueChanged.Invoke。所以你用 Animator 控 Slider 的值是通的。编辑器下 Rebuild(Prelayout)会补发一次onValueChanged,对应 case 1037681 的延迟刷新机制(m_DelayedUpdateVisuals);运行时不会无故触发。
Scrollbar:能独立用,也能和 ScrollRect 双向同步
Scrollbar 的 value 固定在 0~1,size 同样会经过 Mathf.Clamp01。它自己实现了拖动、轨道长按、键盘导航和 onValueChanged,完全可以当成一个独立的归一化输入控件,不必依附 ScrollRect 才能工作。
绑定 ScrollRect 后,双方才组成常见的滚动链路。ScrollRect 监听 Scrollbar:
m_HorizontalScrollbar.onValueChanged.AddListener( SetHorizontalNormalizedPosition);内容位置或 bounds 变化时,ScrollRect 又会回写:
m_HorizontalScrollbar.size = Mathf.Clamp01( (m_ViewBounds.size.x - Mathf.Abs(offset.x)) / m_ContentBounds.size.x);m_HorizontalScrollbar.value = horizontalNormalizedPosition;因此 size 可以理解为可视范围占内容范围的比例,但不能永远简化成“视口像素 / 内容像素”。Elastic 越界时,公式还会减去 offset;内容尺寸不大于零时则直接设为 1。
Scrollbar的步进、轨道长按与“不夹紧”
Scrollbar 比 Slider 多一个 numberOfSteps:当 m_NumberOfSteps > 1 时,value 的 getter 会先把内部值按 (steps-1) 等分再四舍五入,所以它能当“离散档位”用:
// Scrollbar.cs value getterfloat val = m_Value;if (m_NumberOfSteps > 1) val = Mathf.Round(val * (m_NumberOfSteps - 1)) / (m_NumberOfSteps - 1);还有两个和 Slider 不一样、容易忽略的点:
Scrollbar 实现了 IBeginDragHandler,Slider 没有。分工是:OnBeginDrag先把m_Offset设为“按下点到手柄中心的偏移”,真正的拖动在OnDrag→UpdateDrag→DoUpdateDrag里按四个方向换算成Set(...)。- 点轨道(没点到手柄)会持续步进
: OnPointerDown起一个ClickRepeat协程,只要指针还按在手柄之外,每帧就把value按±size跳动一档(像翻页),直到松手。这是 Scrollbar 独有的“长按轨道翻页”行为。 注意 Scrollbar.Set不做 Clamp:它允许拖内容时惯性把值甩到 [0,1] 之外,越界交给 ScrollRect 的弹性逻辑兜住;而Slider.Set是ClampValue夹紧的。一个容忍越界、一个不 tolerance,调滚动手感时别搞混。
ScrollRect:滚动视图的真正引擎
上面 Scrollbar 只有 0~1 的“位置值”,真正把“内容比视口大、可以滚动”这件事落地的,是 ScrollRect。它和前面几个组件不同:它不是 Selectable,而是 UIBehaviour 同时实现 ICanvasElement / ILayoutElement / ILayoutGroup。而且它自己不做裁剪——要裁掉超出部分得配 Mask 或 RectMask2D,这点在第 10 篇会展开。
三个核心概念
content:要滚的那块内容,必须是 ScrollRect 的子物体。 viewport:可视窗口;不填就退化成 ScrollRect 自身(源码里 viewRect先取m_Viewport,再退化成transform)。movementType: Unrestricted(随便甩)/Elastic(越界橡皮筋拉回,默认)/Clamped(硬边界)。elasticity控制回弹快慢,inertia+decelerationRate(默认 0.135)控制松手后的惯性衰减。
拖拽与惯性:LateUpdate 才是主循环
拖拽时 OnBeginDrag 记下 m_PointerStartLocalCursor 和 m_ContentStartPosition;OnDrag 算出指针位移 pointerDelta,叠到起始位置,再用 CalculateOffset 求越界量,若是 Elastic 就过一道 RubberDelta(橡皮筋公式,越拉越沉),最后 SetContentAnchoredPosition 移动 content。
松手后的滑行、橡皮筋回弹、以及与 Scrollbar 的双向同步,全在 LateUpdate 里:
// ScrollRect.cs LateUpdate(节选)offset = CalculateOffset(Vector2.zero);if (!m_Dragging && (offset != Vector2.zero || m_Velocity != Vector2.zero)){if (m_MovementType == MovementType.Elastic && offset[axis] != 0) position[axis] = Mathf.SmoothDamp(...); // 橡皮筋,smoothTime = elasticityelseif (m_Inertia) m_Velocity[axis] *= Mathf.Pow(m_DecelerationRate, deltaTime); // 惯性衰减else m_Velocity[axis] = 0;if (m_MovementType == MovementType.Clamped) position += CalculateOffset(position - m_Content.anchoredPosition); SetContentAnchoredPosition(position);}if (bounds 或 anchoredPosition 变了){ UpdateScrollbars(offset); // 写回 Scrollbar 的 size / value m_OnValueChanged.Invoke(normalizedPosition);}简言之:拖拽只负责“记录起点 + 算位移”,真正的物理(惯性、回弹、同步)都在每帧 LateUpdate。OnScroll(滚轮/触控板)则是把 data.scrollDelta(注意 y 取反,因为 UI 系统上为正)乘 scrollSensitivity 直接挪 content。

归一化位置怎么算
normalizedPosition 把 content 的左下角对齐到 viewport 的左下角。公式里 hiddenLength = contentBounds.size - viewBounds.size 是“藏起来的长度”,再反推 content 应处的 anchoredPosition:
// ScrollRect.cs SetNormalizedPositionfloat hiddenLength = m_ContentBounds.size[axis] - m_ViewBounds.size[axis];float newAnchoredPosition = m_Content.anchoredPosition[axis] + (m_ViewBounds.min[axis] - value * hiddenLength) - m_ContentBounds.min[axis];关键前提:只有 content 比 view 大、hiddenLength > 0,才滚得动。AdjustBounds会保证 content 的 bounds 至少和 view 一样大(不够就按 pivot 撑大),所以内容小的时候你再怎么拖也是纹丝不动——这不是 bug。
和 Scrollbar 的双向绑定(回看上一节)
上一节贴的那两段就是 UpdateScrollbars:ScrollRect 监听 Scrollbar 的 onValueChanged 来设归一化位置,又在每个 LateUpdate 把 content 的 bounds 变化写回 Scrollbar 的 size 和 value。ScrollbarVisibility 还能让滚动条在不需要时自动隐藏(AutoHideAndExpandViewport 还会用 DrivenRectTransformTracker 把 viewport 撑大一圈给滚动条腾地方)。
Dropdown:打开一次,创建一次;关掉一次,销毁一次
Dropdown 的 m_Options 只是 List<OptionData>。可视列表不在 Awake 或 Start 中常驻,而是在 Show() 里临时创建:
// 逻辑示意:摘取并合并 Dropdown.Show 中的关键调用m_Dropdown = CreateDropdownList(m_Template.gameObject);for (int i = 0; i < options.Count; ++i){ OptionData data = options[i]; DropdownItem item = AddItem(data, value == i, itemTemplate, m_Items);}m_Blocker = CreateBlocker(rootCanvas);默认 CreateDropdownList 对 Template 调用 Instantiate,AddItem 内部又通过 CreateItem 为每个选项实例化 Item。Blocker 是另建的全屏对象,用来截住列表外点击并触发关闭。
重点不是“第一次展开”,而是“每次展开”。Hide() 先淡出列表,并立即销毁 Blocker;淡出时间结束后,ImmediateDestroyDropdownList() 遍历 Item 调 DestroyItem,再调用 DestroyDropdownList 销毁整张列表。默认 DestroyItem 为空,因为 Item 已经是列表的子对象,会随列表一起销毁。
所以预展开一次并不能把后续创建成本消掉。高频打开、选项又多时,要么继承 Dropdown,重写创建和销毁方法接对象池;要么直接使用可复用的列表控件。
Dropdown:为什么它永远在最上层、点外面能关
Show() 里 SetupTemplate 干了一件关键事:给模板实例化的列表挂一个独立 Canvas,overrideSorting = true、sortingOrder = kHighSortingLayer(常量 30000),并把根 Canvas 的 sortingLayerID 抄过去、把父 Canvas 的 BaseRaycaster(通常是 GraphicRaycaster)也复制一份,再加 CanvasGroup。这一套保证了下拉列表一定压在所有 UI 之上、且能被射线击中。
列表定位还有个细节:算完所有 Item 位置后,取列表四个角反算到根 Canvas 坐标系,只要任一角溢出根 Canvas 矩形,就对那条轴 FlipLayoutOnAxis 翻转——于是下拉框会自动“往上弹”而不是跑到屏幕外。
关闭靠 Blocker:CreateBlocker 在根 Canvas 下建一个全屏对象,自带一个 sortingOrder 比下拉列表小 1 的 Canvas(所以它盖住其它 UI、但被列表盖住)、透明 Image + GraphicRaycaster + Button;点它任何地方 → Hide()。这就是为什么你点列表外区域就能收起下拉。
几个易踩的坑:
valuesetter 在“值没变”或“选项为空”时直接 return、不发回调;取值会被 Mathf.Clamp(value, 0, options.Count - 1)夹到合法区间。淡入淡出走 TweenRunner<FloatTween>,ignoreTimeScale = true,alphaFadeSpeed默认 0.15 秒。- 禁用 Dropdown 会立即销毁列表和 Blocker
( OnDisable里ImmediateDestroyDropdownList+DestroyBlocker,对应 case 935649——所以你在展开途中把组件关掉,不会有残留。 选中项后 OnSelectItem通过“在父物体 children 里找 Toggle 下标、再减 1(扣掉模板项)”得到selectedIndex,设value然后Hide();ICancelHandler/ISubmitHandler让 Esc 和回车也能关/开。

InputField:桌面键盘不是一个逐字符 Update
这一版 InputField 没有一段 Update() 去轮询 Input.inputString。桌面原地编辑时,EventSystem 给当前选中对象发送 IUpdateSelectedHandler.OnUpdateSelected,InputField 再从 Unity 事件队列取键盘事件:
// 逻辑示意:省略命令事件等分支publicvirtualvoidOnUpdateSelected(BaseEventData eventData){if (!isFocused)return;while (Event.PopEvent(m_ProcessingEvent)) {if (m_ProcessingEvent.rawType == EventType.KeyDown) {var shouldContinue = KeyPressed(m_ProcessingEvent);// 根据返回状态继续编辑或结束编辑 } }}上面省略的注释属于说明,不是伪装成源码的实现。退格、删除、方向键、选区替换和字符校验都从 KeyPressed 及其调用的方法进入。InputField 还实现了 ILayoutElement,会向布局系统提供 min/preferred 尺寸和 layoutPriority。
移动端非原地编辑走另一条路。LateUpdate 读取 TouchScreenKeyboard.text,校验后同步到 m_Text,变化时调用 SendOnValueChangedAndUpdateLabel()。TouchScreenKeyboard 是移动软键盘接口,不等同于桌面 IME。
桌面 IME 合成期间,compositionString 会参与显示文本和光标位置计算。未提交的拼音合成串不等于已经写进 m_Text;提交后回调可能按一个字符、一个词或一段文本到达,粒度受平台和输入法影响。业务代码既不要假定中文一定逐字回调,也不要假定整段上屏永远只回调一次。
光标闪烁用的是频率。源码先求周期,再减去闪烁起点:
float blinkPeriod = 1f / m_CaretBlinkRate;bool blinkState = (Time.unscaledTime - m_BlinkStartTime) % blinkPeriod < blinkPeriod / 2;它使用 unscaledTime,所以不受 timeScale 影响。
回调也要分开看。正常编辑或通过 text setter 改变文本时会触发 onValueChanged,SetTextWithoutNotify 则明确跳过回调。编辑完成时,InputField 会先 SendOnSubmit(),随后 DeactivateInputField(),后者再触发 onEndEdit。普通失焦则主要体现为结束编辑。需要“用户确认提交”的语义时优先监听 onSubmit;需要收拢整个编辑会话时使用 onEndEdit。网络请求若挂在 onValueChanged,记得自行做防抖和 IME 兼容。
InputField 补充:焦点怎么来、键盘怎么分、回调到底谁先响
焦点不是点一下就有,是协程里延迟激活的。OnSelect/OnPointerClick 只调 ActivateInputField(),它只是把 m_ShouldActivateNextUpdate = true;真正的激活发生在下一帧 LateUpdate 里的 ActivateInputFieldInternal:设 m_AllowInput = true(这就是 isFocused 的真身)、按需开 TouchScreenKeyboard、调 OnFocus()(=SelectAll)、SetCaretVisible()。所以 isFocused 永远等于 m_AllowInput,别再用“有没有选中物体”去猜焦点状态。
两条编辑路径一开始就分叉:
InPlaceEditing()= !TouchScreenKeyboard.isSupported || m_TouchKeyboardAllowsInPlaceEditing—— 桌面/有硬键盘走这条,原地编辑(前面讲过的OnUpdateSelected逐帧取键盘事件)。否则走 TouchScreenKeyboard(移动软键盘),由LateUpdate读m_Keyboard.text。TouchScreenKeyboardShouldBeUsed()按平台决定:Android 上只有“不允许原地编辑”时才起软键盘,Quest 设备另有特例。

ContentType 是个总开关:选 IntegerNumber / EmailAddress / Password / Pin 等,会自动连带设置 inputType、characterValidation、lineType、keyboardType(EnforceContentType)。注意 CharacterValidation 是逐字符校验(Integer/Decimal/Alphanumeric/Name/EmailAddress),它只决定“这一个字符能不能进字符串”,不保证整串是合法邮箱或整数——比如 EmailAddress 允许你打一堆基本字符,要整串校验得自己在 onValueChanged 里做。
光标闪烁是协程,不是单纯取模。 前面那段取模公式其实是CaretBlink协程里的:它每帧跑,仅在m_CaretVisible变了且“当前没有选区”时MarkGeometryAsDirty;SetCaretActive在m_CaretBlinkRate > 0时启协程、<= 0时直接常亮。m_BlinkStartTime在每次获得焦点/点亮时重置,保证刚点亮时光标立即可见。选区的高亮和光标是同一套网格(OnFillVBO→GenerateHighlight/GenerateCaret),画在一个名为"<原名> Input Caret" 的隐藏 GameObject 上。
回调的精确触发顺序(调提交逻辑必看):
桌面:回车/KeypadEnter(且非 MultiLineNewline)或 Esc → KeyPressed返回EditState.Finish。OnUpdateSelected里:若!wasCanceled先SendOnSubmit(),再DeactivateInputField()。Deactivate一定会SendOnEndEdit(),取消时还会把文本还原成m_OriginalText。移动:软键盘 Done→SendOnSubmit()→OnDeselect→Deactivate(发onEndEdit);Canceled→ 置m_WasCanceled→Deactivate(发onEndEdit,文本还原)。
所以结论不变但更精确:onSubmit 只在“确认提交”时响(回车/软键盘 Done),onEndEdit 是“编辑会话收尾”必响(含失焦、取消、禁用)。需要网络请求且怕重复提交,监听 onSubmit 比 onValueChanged 稳得多。
UpdateLabel:密码框把整串替换成 asteriskChar(* 默认);把 IME 合成串插到 m_CaretPosition 处显示但不算进m_Text;按 m_DrawStart/m_DrawEnd 维护一个“可视窗口”,单行横向滚、多行纵向滚,保证光标始终在框内可见(SetDrawRangeToContainCaretPosition)。SetText/text= 设值会先剥离 \0、单行时剥 \n\t、过一遍校验和 characterLimit,再决定是否 SendOnValueChanged;用 SetTextWithoutNotify 可跳过回调。
四个组件的特殊实现案例
案例一 · Slider 刻度磁吸
需求:Slider 的 value 是 0~1 连续滑,但很多 UI 要“吸”到固定刻度(比如 0、0.25、0.5、0.75、1,或任意一组自定义刻度)。wholeNumbers 只能取整,不够用。
做法:所有写值入口——拖拽 UpdateDrag → normalizedValue setter → value setter;键盘 OnMove;minValue/maxValue/wholeNumbers 变化——最终都汇到 protected virtual void Set(float input, bool sendCallback = true)。只要重写 Set,在 base.Set 之前先把输入吸附到最近刻度,拖拽、键盘、代码赋值就全被磁吸了,不用到处改:
using UnityEngine;using UnityEngine.UI;[RequireComponent(typeof(RectTransform))]publicclassSnapSlider : Slider{// 刻度位置,单位与 value 同区间(minValue~maxValue) [Tooltip("磁吸刻度,例如 0/0.25/0.5/0.75/1")]publicfloat[] ticks = { 0f, 0.25f, 0.5f, 0.75f, 1f };protectedoverridevoidSet(float input, bool sendCallback = true) {// 先吸附,再交给基类 clamp + 触发回调base.Set(SnapToNearest(input), sendCallback); }floatSnapToNearest(float v) {if (ticks == null || ticks.Length == 0)return v;float best = ticks[0];float bestDist = Mathf.Abs(v - best);for (int i = 1; i < ticks.Length; i++) {float d = Mathf.Abs(v - ticks[i]);if (d < bestDist) { bestDist = d; best = ticks[i]; } }return best; }}注意点:
吸附在 base.Set之前做,吸附后的值还会照常被ClampValue限到[minValue, maxValue],所以刻度数组最好本身就在区间内。想做“离刻度多远才吸”,可加 threshold:只有bestDist < threshold才吸附,否则保留原值,做出“半程才吸附”的手感。- 别在
onValueChanged里再value = 吸附值来回写——那会触发二次回调、抖动且难调;直接改 Set才是源头。
案例二 · Scrollbar 平滑滚动
需求:点击按钮让列表平滑滚到某处,而不是 value = xxx 直接跳变;或程序控制滚动时想要阻尼感。
注意点先说:Scrollbar 的 Set(float, bool) 是 private,不能像 Slider 那样重写。但它对外暴露了公开的 value 属性(value setter 会调内部 Set 并触发 onValueChanged)。所以不重写 Set,而在子类里加一个 targetValue,每帧把 value 朝目标缓动:
using UnityEngine;using UnityEngine.UI;publicclassSmoothScrollbar : Scrollbar{publicfloat smoothTime = 0.15f; // 越小越快privatefloat target = -1f;// 外部统一调这个,而不是直接改 valuepublicfloat targetValue {get { return target < 0 ? value : target; }set { target = Mathf.Clamp01(value); } }protectedoverridevoidOnEnable() {base.OnEnable(); target = value; }privatevoidUpdate() {if (target < 0f) return;float next = Mathf.Lerp(value, target, Time.deltaTime / smoothTime);if (Mathf.Abs(next - target) < 0.001f) { value = target; target = -1f; }elsevalue = next; }}注意点:
每帧写 value会触发onValueChanged,ScrollRect 照常跟着滚——这正是我们要的“视觉平滑跟随”。Time.deltaTime / smoothTime做系数,比裸 Lerp(value, t, k*dt)更稳,帧率波动时手感一致。若是 ScrollRect 在拖, value会被 ScrollRect 写回来;此时应让target = -1f把平滑让位,否则会和拖拽打架。
案例三 · Dropdown 对象池化(高频开关不重建)
前面提过“每次展开都 Instantiate、收起都 Destroy”,选项多又频繁开关时是实打实的开销。Dropdown 留了四个 protected virtual 钩子:CreateDropdownList / DestroyDropdownList / CreateItem / DestroyItem,正好一一接管创建与销毁。
思路:整个列表实例和每个 Item 都进池;收起时不真 Destroy,只 SetActive(false) 还回池;展开时优先取池,取不到再新建。
using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;publicclassPooledDropdown : Dropdown{privatereadonly Stack<GameObject> listPool = new Stack<GameObject>();privatereadonly Stack<DropdownItem> itemPool = new Stack<DropdownItem>();protectedoverride GameObject CreateDropdownList(GameObject template) {if (listPool.Count > 0) {var go = listPool.Pop(); go.SetActive(true);return go; }returnbase.CreateDropdownList(template); }protectedoverridevoidDestroyDropdownList(GameObject dropdownList) { dropdownList.SetActive(false); listPool.Push(dropdownList); // 进池,不真删 }protectedoverride DropdownItem CreateItem(DropdownItem itemTemplate) {if (itemPool.Count > 0) {var it = itemPool.Pop(); it.gameObject.SetActive(true);return it; }returnbase.CreateItem(itemTemplate); }protectedoverridevoidDestroyItem(DropdownItem item) { item.gameObject.SetActive(false); itemPool.Push(item); // 进池,不真删 }}注意点:
池里的对象永远挂在原来那棵树上(通常是 Template 的父级)。别 DontDestroyOnLoad或挪到别的 transform,否则AddItem按父子关系挂 item 会错位。池随最大并发选项数增长、不自动缩容;长期多档频繁切换时,可加个上限或定时 Destroy回收。选项内容是动态加载的(图标、网络文本)时,进池前要重置 item 状态,避免上一轮数据串味。
案例四 · InputField 字节上限与整串过滤
ContentType 的 CharacterValidation 是逐字符的,挡不住“整串超长”或“整串不合法”。InputField 给了一个公开委托 onValidateInput(签名 char OnValidateInput(string text, int charIndex, char addedChar)):它在每个字符真正插入前回调,返回 \0 表示拒绝该字符。这是做“字节上限 / 整串规则”最干净的钩子。
例子:限制总字节数(中文算 2 字节、英文算 1 字节),超过就拒绝新字符:
using System.Text;using UnityEngine;using UnityEngine.UI;publicclassByteLimitedInput : MonoBehaviour{public InputField input;publicint maxBytes = 20; // 中文约 10 个字voidAwake() { input.onValidateInput = ValidateByte; }charValidateByte(string text, int charIndex, char addedChar) {// 模拟把 addedChar 插进当前字符串后的字节数string probe = text.Insert(charIndex, addedChar.ToString());int bytes = Encoding.UTF8.GetByteCount(probe);return bytes <= maxBytes ? addedChar : '\0'; // 超了就拒绝 }}注意点:
返回 \0只拒绝“这一个字符”,已存在的文本不会被回退——这是“增量拦截”,不丢已有内容。它看不到整串语义(“不能是纯数字”“必须含 @”之类),整串校验要另外在 onValueChanged里做,并自己决定提示/纠正。移动端走另一套:软键盘是 LateUpdate读TouchScreenKeyboard.text,onValidateInput对软键盘逐字符也生效,但整段粘贴/自动补全的粒度由平台决定,别假设一定逐字回调。
四个案例的共同点:都只动了官方留的一个扩展点(一个虚方法或一个公开委托),没有重写整套交互。改 UGUI 组件时,先找它的 protected virtual 钩子和公开回调,比直接拆了重写稳得多,也更不容易被版本升级打断。
这些组件的注意点
Slider:Lua 设的目标值,回调里永远对不上
现象:你在 Lua 里 slider.value = 0.3,然后等着 onValueChanged 的回调参数等于 0.3 时触发某段逻辑;或者每帧把 value 朝 0.3 逼近、用 value == 0.3 判断“到没到”。结果要么永远到不了,要么一闪而过没命中。
根因是 C# 的 float 精度:
Lua 的 number 是 double;跨语言设进 slider.value时,0.3 被转成 32 位 float,内部存的是0.3000000119…。回调里拿到的m_Value也是这个 float,再回到 Lua 又变回 double0.3000000119。拖拽路径里 normalizedValue = InverseLerp(min, max, value)、value = Lerp(min, max, norm)全是 float 运算,结果不会是干净的 0.3。于是 C# 侧顺手写的 if (slider.value == target),或 Lua 侧if value == 0.3,这个==在浮点下基本必失手。
后果两种:要么“永远到不了”(你等它精确等于 0.3,它停在 0.29999/0.30001);要么“超过了”(你用 >= 等越界,但它先略过、再被下一帧的 clamp / wholeNumbers 取整拉回,你的逻辑没在那一帧执行)。无论哪种,你那句“命中”逻辑都跑不起来。
正确写法:
- 永远用 epsilon 判等,别用
==:Lua 里 if math.abs(value - target) <= 1e-4 then … end;C# 里Mathf.Approximately(value, target)或Mathf.Abs(value - target) < 1e-4f。 - 用“进入区间”而不是“精确相等”
:比如“到了目标附近(相差 < 阈值)就停”,比“精确等于”稳得多。 如果目标本来就是离散档位(音量、档位),直接开 wholeNumbers或上案例一的磁吸,让值天然落在干净刻度上,==才成立。注意 valuegetter 在wholeNumbers开启时会Mathf.Round(m_Value),你读出来的是四舍五入后的整数,和内部m_Value(回调回传的裸 float)不是一回事——判断前先确认你读的是哪一个。
Scrollbar:getter 会离散化,且 value 可能越出 [0,1]
valuegetter 在 m_NumberOfSteps > 1时返回Mathf.Round(val * (steps-1)) / (steps-1)。你Set(0.37)且设了 4 档,读出来会变成0.3333——别拿原始 set 值做精确比较。Scrollbar 的 Set不 Clamp01(源码注释明说 bugfix 802330 故意允许滚动惯性越界),拖 content 时value能被 ScrollRect 写到 >1 或 <0。Lua 侧若假定它永远落在 [0,1],边界判断会越界。绑定 ScrollRect 后, value每帧由 ScrollRect 写回;Lua 主动value = x可能被下一帧 ScrollRect 覆盖。谁主导滚动要分清,别和 ScrollRect 抢着写。size必须在 (0,1]: size = 1表示内容完全可见、无法滚动;设 0 或负数会出怪异结果。
Dropdown:回调失效,缓存会失效
valuesetter 在“值没变 / options 为空 / 正在 Show 过程中”时不发 onValueChanged。Lua 侧若只靠回调感知选中变化,会漏掉“设了同一个值”的情况。需要感知就自己比对旧值,或主动读value。每次展开都重建列表,Lua 缓存的 item / toggle 引用在收起后即失效。动态改 options要先在收起态改,再Show才会刷新;运行时改m_Options[i].text不刷新已展开的列表。options是 List<OptionData>,Lua 构造大量带图标 Sprite 的选项有 GC 压力;高频改选项要节流。
InputField:逐字符拦截看不到整串,设值会被静默改写
onValidateInput是逐字符、插前拦截,看不到整串语义(见案例四)。整串校验 / 限长得在 onValueChanged里做,但onValueChanged每次插入 / 删除都触发,Lua 侧做网络请求必须防抖。text/ SetText设值会剥离\0、单行模式剥\n\t。Lua 传带换行的文本给单行输入框,会被静默截断,别以为是没生效。characterLimit按字符数不是字节数:中文一个字算 1 个字符但占 2~3 字节。要做“最多 N 字节”得自己拦(案例四)。 IME 合成串(未提交的拼音)不进 m_Text,只参与显示。Lua 在合成中读text拿到的是不含拼音的,要“实时长度”得把compositionString也算上,或等onEndEdit。焦点是协程延迟激活:Lua 调 ActivateInputField后立刻读isFocused可能还是 false(下一帧才 true)。要等焦点就绪再操作。
简单总结
- Slider
:业务值 value映射到 0~1 的normalizedValue,拖拽只算比例写回;想磁吸就重写Set。 - Scrollbar
:天生 0~1,表达视口位置与占比,常和 ScrollRect 双向绑定; Set是 private、value 不 Clamp01,平滑滚动要包value。 - Dropdown
:每次展开才建、收起即毁,高频开关用对象池; valuesetter 在值不变 / 空 / Show 中不发回调。 - InputField
:分桌面原地编辑和移动软键盘两条路径,IME 合成不算进文本;整串校验与字节限制得自己拦。
—— 与君共勉~ ——















































































