夜雨聆风学习资料网

ARTICLE · 1230635

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

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()。这就是为什么你点列表外区域就能收起下拉。

几个易踩的坑:

  • value
     setter 在“值没变”或“选项为空”时直接 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 又变回 double 0.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 或上案例一的磁吸,让值天然落在干净刻度上,== 才成立。
  • 注意 value getter 在 wholeNumbers 开启时会 Mathf.Round(m_Value),你读出来的是四舍五入后的整数,和内部 m_Value(回调回传的裸 float)不是一回事——判断前先确认你读的是哪一个。

Scrollbar:getter 会离散化,且 value 可能越出 [0,1]

  • value
     getter 在 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:回调失效,缓存会失效

  • value
     setter 在“值没变 / 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
    :每次展开才建、收起即毁,高频开关用对象池;value setter 在值不变 / 空 / Show 中不发回调。
  • InputField
    :分桌面原地编辑和移动软键盘两条路径,IME 合成不算进文本;整串校验与字节限制得自己拦。

—— 与君共勉~ ——

相关学习资料