夜雨聆风学习资料网

ARTICLE · 1196251

UGUI源码学习笔记_第六篇_Selectable篇

UGUI源码学习笔记_第六篇_Selectable篇

Button、Toggle、Slider 等控件都建立在 Selectable 上,但并非所有输入都先经过 Selectable。它统一处理导航、选中和指针状态;Click、Submit 这类控件业务事件由派生组件自己实现。按 com.unity.ugui@1.0.0 把这两层分开,很多"状态对了但事件没来"的问题才容易定位。

Selectable 是什么:UGUI 可交互控件的共同底座

Selectable是所有可交互 UI 控件的共同基类。注意它不是抽象类,就是一个能直接挂到 GameObject 上的普通组件类,只是你基本不会裸挂它:

// 逻辑示意:Selectable 的"身份证"publicclassSelectable : UIBehaviour,IMoveHandler,                                  // 手柄/方向键导航IPointerDownHandler, IPointerUpHandler,         // 鼠标按下/抬起IPointerEnterHandler, IPointerExitHandler,      // 鼠标进入/离开ISelectHandler, IDeselectHandler// 选中/取消选中{// ...}

unity中基本每一个"能点的东西"——Button、Toggle、Slider、Scrollbar、Dropdown、InputField——底层全都是它的子类。换句话说,Selectable 是这套交互控件的地基。

那它到底管什么、不管什么?Selectable 只负责"这个控件当前处于什么交互状态"以及"状态怎么切换",它不负责"状态变了之后要干嘛"。, 比如五种视觉状态(Normal / Highlighted / Pressed / Selected / Disabled)构成的状态机、状态切换时的 Transition 过渡(颜色淡变 / 换 Sprite / 触发动画)、指针进出追踪、选中/取消选中的钩子、以及导航关系(navigation + FindSelectable)。

所以常碰到的"状态对了但事件没来",往往就在这两层分家:比如 interactable = false 把状态机掐到 Disabled,派生类的 onClick 自然不会触发——这不是 Button 的 bug,是 Selectable 这层先把它拦截了。

Selectable 管哪些事件

Selectable 实现:

// 逻辑示意:摘录 Selectable 实现的事件接口IMoveHandler,IPointerDownHandler, IPointerUpHandler,IPointerEnterHandler, IPointerExitHandler,ISelectHandler, IDeselectHandler

它内部维护五种视觉状态。枚举的真实可见性是 protected,派生类可以使用,外部代码不能直接访问:

// 逻辑示意:枚举名、可见性与成员对应真实源码protectedenum SelectionState{    Normal, Highlighted, Pressed, Selected, Disabled}

状态判断不是简单映射某一个事件:

  • Pressed
    :当前有有效的 PointerDown;
  • Highlighted
    :控件可交互,且没有处于 Pressed 或 Selected,指针位于控件内;
  • Selected
    :该 GameObject 当前被 EventSystem 选中;鼠标、键盘和手柄都可能建立选中状态,不只来自 Tab;
  • Disabled
    :IsInteractable() 为 false;原因可能是自身 interactable=false,也可能是父级 CanvasGroup 禁止交互;
  • 其他情况为 Normal。

状态变化后进入 DoStateTransition(currentSelectionState, instant)。Transition 有 None、ColorTint、SpriteSwap、Animation 四种,分别为不处理、淡变颜色、替换 Sprite、设置 Animator Trigger。

interactable 和 enabled 不是同一件事

通过 selectable.interactable = false 修改时,setter 会调用 OnSetProperty();运行时它立即重新计算 currentSelectionState 并调用 DoStateTransition(..., false)。正常走公开属性时,不存在“状态机默认没有重新驱动”这一步缺失。

这里的 false 也不代表所有过渡都非瞬时。instant 只被 ColorTint 用来决定淡变时间;SpriteSwap 和 Animation 分支不使用该参数。禁用交互时 ColorTint 仍可按 ColorBlock.fadeDuration 过渡。

把组件 enabled=false 是另一条路径。OnDisable() 会从全局 Selectable 列表移除对象、执行 InstantClearState(),然后清理指针和选中标记。ColorTint 恢复白色,SpriteSwap 清除 overrideSprite,Animation 触发 Normal;它不是切到 Disabled 视觉态。

因此排查视觉残留时要先区分:

  • 要保留控件显示但禁止操作,用 interactable=false,检查 Disabled 配置及父 CanvasGroup;
  • 要停用 Selectable 组件,用 enabled=false,预期是清除交互视觉;
  • 状态计算正确但画面不对,再检查 targetGraphic、Animator、Trigger、材质和 CanvasGroup。

Transition 是怎么实现的:只有 ColorTint 是真·渐变

前面好几处提到 DoStateTransition(currentSelectionState, instant) 和四种 Transition,但没讲清楚"渐变"到底是不是 tween、怎么渐的。这里一次讲透。

先给结论:只有 ColorTint 是真的用 tween 做颜色插值渐变;SpriteSwap 是瞬时换图,Animation 是把皮球踢给 Animator,None 什么都不做。 在 Selectable 这里九成其实是 ColorTint 这一种。

DoStateTransition 先按状态从 m_Colors / m_SpriteState / m_AnimationTriggers 取出目标色、目标图、目标 trigger,再按 m_Transition 分流:

// 逻辑示意:DoStateTransition 的分流switch (m_Transition){case Transition.ColorTint:        StartColorTween(tintColor * m_Colors.colorMultiplier, instant); break;case Transition.SpriteSwap:        DoSpriteSwap(transitionSprite); break;                  // 直接换 overrideSpritecase Transition.Animation:        TriggerAnimation(triggerName); break;                   // 只发 Animator Trigger// None:没有 case,啥也不做}

ColorTint:UGUI 自带的一套轻量 tween

渐变的重头戏在这。1.0.0 里 Selectable 持有 TweenRunner<ColorTween> m_ColorTweenRunner,StartColorTween 这么干:

// 逻辑示意:1.0.0 的 StartColorTween(main 分支已改为走 Graphic.CrossFadeColor,底层同一套 tween 机制)voidStartColorTween(Color targetColor, bool instant){if (m_TargetGraphic == null) return;if (instant || m_Colors.fadeDuration == 0f || !Application.isPlaying)        m_TargetGraphic.canvasRenderer.SetColor(targetColor);   // 即时:直接落色else        m_ColorTweenRunner.StartTween(new ColorTween {           // 渐变:起一个颜色 tween            duration   = m_Colors.fadeDuration,            startColor = m_TargetGraphic.canvasRenderer.GetColor(),            targetColor = targetColor        });}

这套"从状态切换到颜色逐帧落渲染层"的流程流水线如下:

关键点:

  • 这个 tween 不是 DOTween,也不是老 Unity 那个 Tween 组件。它是 UGUI 自己写的 TweenRunner<T> + ColorTween,由一个协程逐帧 Lerp 颜色、每帧调 canvasRenderer.SetColor 落到渲染层。所以你项目里压根没引 DOTween,UI 颜色淡变照样能跑——是 UGUI 自带的轮子。
  • 它插值的是 canvasRenderer 的颜色,作用在 targetGraphic 上(通常是按钮上的 Image / Text)。colorMultiplier 会先把目标色乘一下再渐变,alpha 也参与插值(所以淡变能同时带透明度变化)。
  • instant 把这扇门掐死
    :传 true 时(比如 OnEnable / OnDisable 里)直接 SetColor 落色,不走 tween——这就是前面说的"禁用 / 启用时视觉不渐变"的原因。同理 fadeDuration == 0 或非运行态也即时落色。

简言之:fadeDuration 只负责 ColorTint。SpriteSwap 换图从来不渐变(一帧切),Animation 的过渡时长完全看你 Animator 里 clip 怎么排。指望"换图也淡入淡出"得自己上 Shader 或 DOTween,Selectable 无能为力。另外 DoStateTransition 开头有 if (!gameObject.activeInHierarchy) return;——对象不活跃时整个过渡静默 no-op,给隐藏中的对象切状态是不会出画面的。

实战坑:点击即禁用 + 切走界面,Tint 会卡在"按下色"吗?

特别常见的一个担心:按钮开了 ColorTint 渐变,玩家一点,你 onClick 里"啪"地开新界面、顺手把旧按钮 selectable.interactable = false。于是有人怕:这一下把"正往按下色渐变"的过程打断了,等关掉新界面回来,旧按钮会不会停在一个"按到一半"的颜色、渐变僵死?

先把结论拍出来,并先认个错:上一版我写"基本不会发生",那是把话说满了,漏了最关键的现实前提——OnDisable→InstantClearState 这层自愈,只有在 GameObject 真的被反激活时才触发。而现实里"开新界面"大多是叠一个模态弹窗盖在上面,旧界面 GameObject 根本没 SetActive(false)。在这种"叠层不反激活 + 卡顿设备"的组合下,你遇到的"卡在按下色 / 半路色"不仅会发生,而且就是源码机制决定的。下面先还原事件,再讲为什么卡顿设备上你确实会中招。

先还原点击这一连串事件(同一帧或相邻几帧内发生):

// 逻辑示意:一次"点击→禁用→切走"的真实事件序列OnPointerDown  → DoStateTransition(Pressed, false)   // 起一个"往按下色"的 tweenOnPointerUp    → DoStateTransition(Normal,  false)   // 打断上面,起"往普通色"的 tweenOnPointerClick → Press() → m_OnClick.Invoke()        // 你的回调里:                   打开新界面;                   oldButton.interactable = false;     // → OnSetProperty()

很多人以为 interactable=false 落地时按钮还"停在按下态"。其实 interactable 这个 setter 走的是 OnSetProperty() → DoStateTransition(currentSelectionState, false),而 currentSelectionState 是个 getter,第一句就判 IsInteractable():

// 逻辑示意:currentSelectionState getter(main 与 1.0.0 行为一致)SelectionState currentSelectionState {get {if (!IsInteractable()) return SelectionState.Disabled;  // ← interactable=false 立刻命中if (isPointerDown)    return SelectionState.Pressed;        ...    }}

所以 interactable=false的瞬间,状态立刻被判定成Disabled,DoStateTransition(Disabled, false)会再起一个"往禁用色"的 tween——它先StopTween()把上一个(往普通色)协程打断,再起新的。Selectable 的 tween 是"后发制人":同一时刻永远最多只有一个颜色 tween 在跑,新目标色会 supersede 旧的,俩不会叠加打架,但"不叠加"绝不等于"禁用色一定跑到、也绝不会卡住":它只保证了颜色 tween 不打架,既没保证这个(唯一的)禁用 tween 能跑完,也没保证isPointerDown这类状态标记被清掉。我就遇到这样一个问题:某个按钮button设置了ColorTint,点击时立马打开新界面,将就界面设置selectable.interactable = false,是不是有概率导致Tint渐变过程中断,导致关闭新界面返回旧界面时按钮可能为一个按下过程中的颜色且渐变过程中断的状态。卡顿设备上这个禁用 tween 本身会冻在半路,而isPointerDown泄漏又会让你重启用后直奔Pressed。所以"后发制人"只是第一层机制(颜色不叠加),不是保险箱——真正决定卡不卡的,是下面三个真凶。每次StartColorTween都先StopTween()杀掉上一个协程,所以"按下→普通→禁用"三段里前两段必然被打断。如果此时旧界面被SetActive(false)藏起来,Unity 会顺手把那个还没跑完的协程也停了,canvasRenderer 上的颜色就冻结在它当时跑到的值。

那为什么又说"通常不会永久卡住"?因为标准 SetActive(false) 会触发 OnDisable,里面 InstantClearState() 先把颜色清成白、再把 isPointerDown / isPointerInside / hasSelection 全清掉;等你 SetActive(true) 回来,OnEnable 又 DoStateTransition(currentSelectionState, true)——instant=true 直接 SetColor 把当前(禁用)色瞬间落上去。成对一清一落,回来就是正确禁用色,渐变僵死被自愈了。下面就是卡顿设备上"真的会卡"的三个真凶:

为什么在卡顿设备上容易中招

坑点一:指针抬起被模态吃掉了,isPointerDown 永久泄漏。OnPointerUp只在"指针释放时落在的那个对象"上触发。你点下按钮→弹窗立刻盖上来→手指还按着;等你松手,手指已经在弹窗上了,按钮的 OnPointerUp永远不触发,isPointerDown 一直为 true。结合 currentSelectionState getter 的顺序——!IsInteractable() 先判,其次才是 isPointerDown——只要按钮还 interactable=false,它暂时显示 Disabled(没露馅);可一旦你关掉弹窗、把按钮 interactable 重新设回 true,getter 立刻命中 isPointerDown → 直接 Pressed,且再没有 OnPointerUp 来清它 → 永久按下态 / 按下色。这一步和 tween 无关,是状态标记漏清,比颜色 tween 更隐蔽,也是"回来一看按钮像卡在按下"的最常见真凶。

坑点二:颜色 tween 是协程按 yield return null 逐帧跑的,卡顿一冻就停。StartColorTween → StartTween → StartCoroutine 起的是协程,每帧 yield return null 推进一步。弹窗打开常伴随大卡顿(资源实例化 / GC / layout 重建 / 着色器预热),卡顿期间协程不前进,canvasRenderer 的颜色冻结在最后一次 SetColor 写的值——正是"按下色 ↔ 禁用色之间"的某个中间值。卡顿时被弹窗盖着看不见;等你关掉弹窗、旧界面重新露出来,如果这段时间没有一次新的状态切换去驱动它,那个"半残色"就原样显示,看起来就是"渐变僵死、卡在按下过程里"。它不一定是永久的(之后再 hover/点一下会接着跑完),但在卡顿窗口里它就是错的、而且一回来就露馅。

坑点三:OnDisable 自愈没触发,因为旧界面没被反激活。OnDisable→ InstantClearState() 清 isPointerDown + 钉白;OnApplicationFocus(false)也会清。但叠层弹窗不反激活旧界面,OnDisable 不跑、InstantClearState 不跑——既不清 isPointerDown,也不强制把颜色钉成禁用色。

简言之:原生 UGUI 的"点击即禁用 + SetActive 标准反激活切走再回来"确实能自愈、不留按下色——OnDisable 清白 + OnEnable 瞬落正确色是双保险。但"开新界面"如果是叠层模态(旧界面不反激活),这套自愈根本不触发,于是卡顿设备上你看到的现象就是真的:根因不是 tween 本身,而是三个坑叠一起——① 指针抬起被弹窗吃掉,isPointerDown 泄漏,重新启用后直接 Pressed;② 协程在卡顿中被冻结,半残色原样显示;③ 旧界面没反激活,OnDisable 自愈没跑。所以"看起来卡住"分两层:颜色 tween 的半残色(上面真凶二),和更隐蔽的状态标记泄漏(上面真凶一,且对象池 / 叠层不反激活时必中)。

顺带接上一条 activeInHierarchy 铁律:如果你是先藏好界面、再在隐藏状态下设 interactable=false,DoStateTransition 直接 return,禁用色根本没机会上——不过这无伤大雅,因为回来 OnEnable 会补上。

实操建议,想彻底踏实:

  • 开弹窗时顺手把旧界面 SetActive(false)
    (或至少反激活旧按钮所在面板),把自愈交给框架。别只靠"盖一层 Canvas / 调 alpha"当显隐——那不会触发 OnDisable。
  • 关弹窗、旧界面重新可交互那一刻,主动清一次状态
    。最省事也最可靠的是对旧按钮做一次"禁用再启用":button.enabled = false; button.enabled = true;,让它自然走 OnDisable → InstantClearState → OnEnable,把 isPointerDown 和颜色一次性清干净、瞬落正确态。注意:光写 interactable = interactable; 没用,值没变 setter 不会进 OnSetProperty;而 interactable = false; interactable = true; 虽然能各进一次 OnSetProperty,但它清不了 isPointerDown——所以还是 enabled 翻转最稳。
  • 防指针泄漏
    :开弹窗前先 EventSystem.current.SetSelectedGameObject(null) 把选中 / 指针焦点让出来;如果弹窗是独立输入层,确保它接管了指针抬起事件,别让旧按钮"以为还按着"。
  • 关键状态别完全依赖 tween
    :fadeDuration 别设太大;对"禁用 / 恢复"这类关键视觉,必要时直接用 instant=true 的 DoStateTransition 瞬落,避免在卡顿窗口露半残色。

一句话兜底:与其赌"卡顿时不中招",不如在弹窗关闭回调里对旧界面所有按钮做一次 enabled 翻转——成本极低,却能把上面三个真凶一次性清零。

手柄 / 键盘 Submit 走的是定时器,不是事件

上面整节都在讲"鼠标点击"那条路。但按钮还有第二条完全独立的"按下 → 释放"通道——手柄 / 键盘的 Submit(默认回车 / 手柄 A 键,由 ISubmitHandler 接收)。它和鼠标的根本区别就一句话:鼠标靠 OnPointerUp 事件释放,Submit 靠一个 fadeDuration 延时的定时器协程释放。这条路径有自己独立的坑,正好和前面三个坑点对照着看。

先看 Button.cs 里两种入口的真实代码:

// 鼠标:OnPointerClick 只管调 onClick,Pressed / Normal 视觉全由 Selectable 的// OnPointerDown / OnPointerUp(事件驱动)负责,Button 自己不碰状态publicvirtualvoidOnPointerClick(PointerEventData eventData) {if (eventData.button != PointerEventData.InputButton.Left) return;    Press();   // Press() 里只 m_OnClick.Invoke(),没有 DoStateTransition}// 手柄 / 键盘:OnSubmit 自己发 Pressed 视觉,并起一个延时协程来"松手"publicvirtualvoidOnSubmit(BaseEventData eventData) {    Press();                                       // ① 先调 onClick(你的开弹窗 / 禁用逻辑在这)if (!IsActive() || !IsInteractable())          // ② 注意:disabled 判断在 Press 之后return;                                    //    若 onClick 里设了 interactable=false,这里直接 return    DoStateTransition(SelectionState.Pressed, false);   // ③ 显示按下    StartCoroutine(OnFinishSubmit());              // ④ 起定时器协程}private IEnumerator OnFinishSubmit() {var fadeTime = colors.fadeDuration;var elapsedTime = 0f;while (elapsedTime < fadeTime)        { elapsedTime += Time.unscaledDeltaTime; yieldreturnnull; }  // ⑤ 按"真实时间"倒计时    DoStateTransition(currentSelectionState, false);                  // ⑥ 到点才释放}

两条路的关键差异,画成图:

用"前面三个坑点"的透镜对照:

  • 坑点一对 Submit 路径基本无效
    :Submit 压根不走 Selectable.OnPointerDown,不碰 isPointerDown,没有"指针抬起被弹窗吃掉"这回事,所以它的释放是纯定时器,不存在鼠标那种"松手事件被吞 → isPointerDown 泄漏 → 重启用直奔 Pressed"的坑。这是 Submit 比鼠标"抗卡"的一点。
  • 坑点二对 Submit 一样中招
    :OnFinishSubmit 也是 yield return null 的协程,卡顿期间照样冻结,按钮会"按住不放"地冻在 Pressed 视觉上,直到帧恢复、unscaledDeltaTime 把剩余时间一次性补完才释放。所以卡顿设备上手柄确认开的弹窗,按钮也可能短暂 / 冻住地显示成按下态。
  • Submit 独有的坑:重复提交堆协程
    :OnFinishSubmit 每次 OnSubmit 都 StartCoroutine 一把,不会先 StopCoroutine 上一次。你按住回车做键重复(key repeat),或者连点手柄 A,每一次都再发一次 onClick(→ 再开一次弹窗,没去抖就叠加)+ 再起一个释放协程。所以 Submit 路径下"开弹窗"必须自己防重入 / 去抖。
  • 一个不对称的小坑
    :鼠标如果在 onClick 里设 interactable=false,其实 OnPointerUp 早一步已经把视觉释放成 Normal、isPointerDown 也清了,影响不大;但 Submit 路径里,OnSubmit 是在 Press()(即 onClick)之后才判 !IsInteractable() 决定起不起 Pressed 视觉——所以"在 onClick 里 disable"对 Submit 的效果是:按钮压根不显示按下、定时器协程也不起。同一句 interactable=false,鼠标和键盘的视觉表现会不一致,排查时别用鼠标的经验套手柄。

简言之:鼠标释放是事件驱动(丢事件 → isPointerDown 泄漏 → 可能卡 Pressed);Submit 释放是定时器驱动(unscaledDeltaTime 倒计时,丢的是帧 → 冻在 Pressed,且重复提交会堆协程)。防卡的核心动作不变——弹窗关掉时对旧按钮做一次 enabled 翻转清状态;而 Submit 额外要自己对 onClick 去抖 / 防重入。

SpriteSwap:瞬时换图,没有过渡

DoSpriteSwap 就一句话:image.overrideSprite = newSprite;。直接把 Image 的 overrideSprite 顶掉,瞬间生效,零动画。它本质是给 Image 临时覆盖一张图,状态回退时再设回 null 恢复默认 sprite。要做"两张图交叉淡入",SpriteSwap 无能为力,得上别的手段。

Animation:只发 Trigger,时长交给 Animator

TriggerAnimation(triggerName) 做的事是先把五个 trigger 全 Reset,再 Set 当前这个 trigger 到 Animator 上。它不控制任何时长——你的视觉过渡快慢、有没有渐变,全看你 Animator Controller 里对应 clip 的曲线和时长。Selectable 只是个"扳机手",扣完就走,连参数都不会帮你建(参数名空、Animator 没活动、没 Controller 时直接 return)。

所以ColorTint 用 UGUI 自带颜色 tween 按 fadeDuration 插值;其余三种模式要么瞬时、要么外包。排查"为什么没渐变"时,先确认 Transition 是不是 ColorTint、targetGraphic 有没有挂对、fadeDuration 是不是 0。

AnimationTriggers 的真实行为

Animation 模式使用 AnimationTriggers 中五个字符串:Normal、Highlighted、Pressed、Selected、Disabled。切换时 Selectable.TriggerAnimation 会先 Reset 这五个 Trigger,再 Set 当前 Trigger。

Animator Controller 必须存在同名 Trigger 参数,并配置可到达的过渡。Selectable 只负责发 Trigger,不会创建参数、状态或连线。若参数名为空、Animator 不活动、没有 Controller,源码会直接返回。

Toggle:公开 API 与私有 Set

Toggle 公开的状态入口是 isOn 属性;无通知赋值使用 SetIsOnWithoutNotify(bool)。真正的状态逻辑位于私有方法:

// 逻辑示意:真实方法为 private void Set(bool, bool)voidSet(boolvalue, bool sendCallback = true){if (m_IsOn == value)return;    m_IsOn = value;if (m_Group != null && m_Group.isActiveAndEnabled && IsActive())    {if (m_IsOn || (!m_Group.AnyTogglesOn() && !m_Group.allowSwitchOff))        {            m_IsOn = true;            m_Group.NotifyToggleOn(this, sendCallback);        }    }    PlayEffect(toggleTransition == ToggleTransition.None);if (sendCallback)        onValueChanged.Invoke(m_IsOn);}

直接把同一个值再次赋给 isOn,会在开头返回,不刷新 Graphic,也不发送回调。源码没有公开的 SetIsOn(value, sendCallback) 可用于“强制同值通知”;SetIsOnWithoutNotify 恰好是不发事件。初始化代码若需要执行业务逻辑,应直接调用初始化方法;确实需要广播当前值时,可以明确调用 onValueChanged.Invoke(toggle.isOn)。先切成反值再切回来会触发两轮状态、互斥和回调,不适合作为刷新手段。

用户点击 Toggle 时走 InternalToggle(),请求 isOn = !isOn。这个路径和“直接重复赋同值”要分开看:当前已开启项在 allowSwitchOff=false 的 Group 中被点击时,它先请求关闭;若这会导致组内没有开启项,私有 Set 会把它恢复为 true,随后仍可能发送 onValueChanged(true)。源码特意保留了这一行为,Dropdown 等控件依赖它。

onValueChanged 只喂新值,推荐ToggleExtension 该补的"状态取反"回调

上面 Set 末尾那句 onValueChanged.Invoke(m_IsOn)是个很"薄"的出口——它只把新的 isOn 扔出来,不携带旧值、不告诉你"从哪来"。你拿到手的就是一个 bool,至于它刚才是 true→false 还是 false→true,UGUI 不负责,源码里压根没有"上一态"这个概念。

实际上这是个高频痛点:一大堆自定义逻辑都想知道"这次是开还是关、之前是什么"。常见的补位做法两种——

  • A. 手动缓存旧值
    :每次回调里先读 m_LastIsOn,比对完再更新。二十个 Toggle 二十份缓存字段,还容易漏赋值、漏重置。
  • B. 推荐写一层 ToggleExtension
    :把 isOn 的取值、onValueChanged 的"状态取反"信息、上次状态、安全的 set、lambda 快捷绑定、批量注册之类封装起来,可以过滤掉大量在MonoBehaivor上定义自定义回调的需求,直接通过inspector配置即可。

手柄的 focus:选中到底是怎么流动的

很多人把"选中态"当成 EventSystem 自动维护的全局状态,以为鼠标点一下、手柄推一下,EventSystem.currentSelectedGameObject 就自己变了。错。手柄/键盘方向键建立的选中,和鼠标点击是两条完全独立的链路,而且它比你想的更挑条件。

Selectable 自己永远不会主动切焦点。它只在 OnSelect / OnDeselect 里改自己的视觉态(hasSelection),真正改 EventSystem.currentSelectedGameObject 的只有 SetSelectedGameObject。手柄这条链路的终点就是它——文章开头那句"Selected 来自 EventSystem 选中"的闭环,要从这里才接得上。

触发前提

手柄方向键能驱动导航,要同时满足:

  1. EventSystem.sendNavigationEvents == true
    (默认就是 true,但你可以关掉);
  2. 当前有一个"已选中对象",或者场景里配了 firstSelectedGameObject;
  3. 输入轴在 Project Settings / Input Manager 里叫 Horizontal / Vertical,且真有设备喂数据。

第二条最阴。模块激活时(StandaloneInputModule.ActivateModule)会这样兜底:

// 逻辑示意:ActivateModule 选第一个对象var toSelect = eventSystem.currentSelectedGameObject;if (toSelect == null)    toSelect = eventSystem.firstSelectedGameObject;   // 没起点就用面板默认项eventSystem.SetSelectedGameObject(toSelect, GetBaseEventData());

也就是说,你没给 EventSystem 设 firstSelectedGameObject,且当前没有任何对象被选,那推摇杆时 currentSelectedGameObject 是 null,SendMoveEventToSelectedObject 拿 null 去 Execute,啥也不发生——你以为手柄坏了,其实是没有"起点"。鼠标点一下任意 Selectable 就能破局,因为 Selectable.OnPointerDown 也会调 SetSelectedGameObject(gameObject) 建起点。

铁律:做手柄/键盘导航的界面,一定在 EventSystem 上挂 firstSelectedGameObject,或在打开面板时手动 SetSelectedGameObject 一个默认项。否则第一下方向键必然静默。

每帧 Process 的顺序:导航不是"优先于"鼠标

EventSystem.Update 每帧选出当前 InputModule 调它的 Process()。StandaloneInputModule.Process 里的顺序是:

// 逻辑示意:Process 的导航段落bool usedEvent = SendUpdateEventToSelectedObject();   // 给当前选中发 OnUpdateSelected(每帧)if (eventSystem.sendNavigationEvents){if (!usedEvent) usedEvent |= SendMoveEventToSelectedObject();   // 方向键 → OnMoveif (!usedEvent)       SendSubmitEventToSelectedObject();       // Submit/Cancel → OnSubmit/OnCancel}// 上面之后,无论如何都继续处理 touch / 鼠标if (!ProcessTouchEvents() && input.mousePresent)    ProcessMouseEvent();

usedEvent 只在前三桩(Update / Move / Submit)之间做互斥:当前选中对象的 OnUpdateSelected 调了 eventData.Use() 把事件吃掉,Move / Submit 就不会再发。但你别误读成"导航优先于鼠标"——touch / 鼠标每帧都会跑,跟导航不互斥。这条互斥的真实用途是避免一个输入帧里同时触发移动和提交。

读轴与节流:为什么第一次推摇杆有"迟滞感"

SendMoveEventToSelectedObject 先调 GetRawMoveVector(),从 Input Manager 拿 Horizontal / Vertical 的原始值(手柄摇杆/方向键,落在 -1~1)。然后两道闸门:

  • 方向判定:GetAxisEventData(x, y, 0.6f) 用 0.6 的死区把向量归一成 MoveDirection.Right/Left/Up/Down/None。向量平方模小于 0.36 直接 None,不导航;归化规则很简单——|x| > |y| 取水平,否则取垂直。
  • 节流:这才是坑。同方向且是"连续第一下",要等 m_RepeatDelay(默认 0.5s)才放行;方向变了或已过延迟,才按 m_InputActionsPerSecond(默认 10,即每 0.1s 一次)重复。
// 逻辑示意:节流核心bool similarDir = (Vector2.Dot(movement, m_LastMoveVector) > 0);if (similarDir && m_ConsecutiveMoveCount == 1){if (time <= m_PrevActionTime + m_RepeatDelay)   // 前 0.5s 按住不动,第一次重复被压住returnfalse;}elseif (time <= m_PrevActionTime + 1f / m_InputActionsPerSecond)returnfalse;

所以手柄长按方向键,第一次移动有约 0.5s 的"按下不跳"窗口,之后才以 10 次/秒的节奏跳。做"手柄菜单要跟手"时,这两个值在 Inspector 上就是 Repeat Delay 和 Input Actions Per Second,调小它。

派发 OnMove,再到 FindSelectable 的"吹气球"评分

方向定了之后,UGUI 把 move 派给当前选中的 Selectable:

// 逻辑示意:当前选中收到 OnMove,分发收口时读取 selectedObjectExecuteEvents.Execute(eventSystem.currentSelectedGameObject, axisEventData, ExecuteEvents.moveHandler);// 分发结束后,导航链路用 axisEventData.selectedObject 调 EventSystem.SetSelectedGameObject(...) 收口

Selectable.OnMove 是个 switch,按 moveDir 调对应的 FindSelectableOnX(),里面先看 navigation 模式:

// 逻辑示意:OnMove → NavigatepublicvirtualvoidOnMove(AxisEventData eventData){switch (eventData.moveDir)    {case MoveDirection.Right: Navigate(eventData, FindSelectableOnRight()); break;case MoveDirection.Up:    Navigate(eventData, FindSelectableOnUp());    break;case MoveDirection.Left:  Navigate(eventData, FindSelectableOnLeft());  break;case MoveDirection.Down:  Navigate(eventData, FindSelectableOnDown());  break;    }}voidNavigate(AxisEventData eventData, Selectable sel){if (sel != null && sel.IsActive())        eventData.selectedObject = sel.gameObject;   // 把候选写回事件,交给上层收口}

FindSelectableOnX 的行为由 navigation.mode 决定:

  • Explicit
    :直接返回你手填的 selectOnLeft/Right/Up/Down,不走自动计算;
  • 含 Horizontal / Vertical 位:调 FindSelectable(rotation * dir) 全局自动找;
  • 否则(比如 None):返回 null,这个方向推了也没用。

自动找的算法是整篇最容易被低估的。FindSelectable 遍历全局静态列表 s_Selectables(所有 active 的 Selectable 都在里面),跳过自己、不可交互的、以及 navigation.mode == None 的,对剩下的每个候选算:

// 逻辑示意:FindSelectable 的评分Vector3 pos = transform.TransformPoint(GetPointOnRectEdge(myRect, localDir)); // 当前控件矩形边缘点,转到世界坐标Vector3 myVector = candidateCenterWorld - pos;float dot = Vector3.Dot(dir, myVector);   // 候选在不在 dir 方向上if (dot <= 0) continue;                   // 反向或正交,丢弃float score = dot / myVector.sqrMagnitude;// 方向越正、距离越近,分越高if (score > maxScore) { maxScore = score; bestPick = sel; }

score = dot / 距离² 有个很直观的几何含义:从当前控件的矩形边缘点 pos 出发,沿 dir 方向"吹一个圆气球",气球膨胀时第一个碰到的 Selectable 中心,就是被选中的那个。距离近且方向正的,气球更早碰到,分更高。

铁律:FindSelectable 只看屏幕/世界几何,不看你的视觉层级和父子关系。两个按钮在屏幕上离得近、方向正,就会被自动串起来,哪怕它们在 Hierarchy 里八竿子打不着。布局改动后导航跳错目标,先怀疑几何位置,不是代码。

navigation.wrapAround 为真时,同方向一个都没找到,会回退到"反向最远"的那个(bestFurthestPick),实现首尾循环。

落地:selectedObject → SetSelectedGameObject → OnDeselect + OnSelect

Navigate 把候选写进 axisEventData.selectedObject,导航分发收口时用它调 EventSystem.SetSelectedGameObject。这一步才是焦点真正切换的地方,而且它做了三件事,顺序不能乱:

// 逻辑示意:EventSystem.SetSelectedGameObject(m_SelectionGuard 防重入)publicvoidSetSelectedGameObject(GameObject selected, BaseEventData pointer){if (m_SelectionGuard) { Debug.LogError("selection loop!"); return; }    m_SelectionGuard = true;if (selected == m_CurrentSelected) { m_SelectionGuard = false; return; } // 同一个对象直接返回    ExecuteEvents.Execute(m_CurrentSelected, pointer, ExecuteEvents.deselectHandler); // 旧对象 OnDeselect    m_CurrentSelected = selected;                                                // 换全局选中    ExecuteEvents.Execute(m_CurrentSelected, pointer, ExecuteEvents.selectHandler); // 新对象 OnSelect    m_SelectionGuard = false;}

旧的 Selectable 收到 OnDeselect → hasSelection = false → 视觉态退出 Selected;新的收到 OnSelect → hasSelection = true → 进入 Selected。文章开头说的"Selected 来自 EventSystem 选中",到这里闭合成环。

m_SelectionGuard 这条记住:它防的是"在 OnSelect / OnDeselect 回调里又去调 SetSelectedGameObject 触发递归"。你在选中回调里手贱调它,会直接 Debug.LogError 然后 return,焦点不会变。

坑点

  • 失焦即停摆:OnApplicationFocus(false) 把 EventSystem.isFocused 置 false,Process() 开头 if (!eventSystem.isFocused && ShouldIgnoreEventsOnNoFocus()) return;。Alt-Tab 切走再切回来,手柄导航会有片刻不响应,是这里。
  • 新 Input System 不认老轴:上面这套读的是 Input Manager 的 Horizontal / Vertical / Submit。你用了 Unity 新 Input System 却没挂 InputSystemUIInputModule,手柄推了 StandaloneInputModule 根本读不到轴,导航全死。换模块,别只改 Input Settings。
  • 起点为王:回到最前面那条,没有 firstSelectedGameObject 也没有预选中对象,推摇杆就是 null → 静默。先有起点,才有"下一个"。

currentSelectedGameObject:全局只有一个被选中的真相源

前面手柄那一节把"导航怎么把焦点推到下一个"讲透了,但 currentSelectedGameObject 本身还值得单独拎出来说——因为它是整个 UI 交互里唯一的"当前选中"真相源,而且它的"选择逻辑"比"谁调了 SetSelectedGameObject"要窄一点。

先说最反直觉的一点:currentSelectedGameObject 背后就是 EventSystem 里的一个字段 m_CurrentSelected,外面通过只读属性拿:

// 逻辑示意:EventSystem 里的真相源GameObject m_CurrentSelected;                 // 全 EventSystem 只有一个public GameObject currentSelectedGameObject{get { return m_CurrentSelected; }         // 只读,没有 setter}

注意没有 setter。你没法直接 EventSystem.currentSelectedGameObject = xxx 去改它。全工程唯一能改它的方法就是 SetSelectedGameObject(selected, pointer)——这条在手柄那一节末尾已经拆过了(guard 防递归、同对象短路、OnDeselect → 换槽 → OnSelect)。所有"选中"最终都收口到这一个漏斗,区别只在于谁去调它。

四条入口

  1. 面板默认项(ActivateModule)
    :模块激活时若 currentSelectedGameObject 为空,用 firstSelectedGameObject 兜底调一次 SetSelectedGameObject。这是开局起点。
  2. 鼠标按下(OnPointerDown)
    :这是最多人用、却最少人注意的一条。
// 逻辑示意:Selectable.OnPointerDown 里建立鼠标选中publicvirtualvoidOnPointerDown(PointerEventData eventData){if (eventData.button != PointerEventData.InputButton.Left)return;if (IsInteractable() && navigation.mode != Navigation.Mode.None && EventSystem.current != null)        EventSystem.current.SetSelectedGameObject(gameObject, eventData);  // 左键按下即选中    isPointerDown = true;    EvaluateAndTransitionToSelectionState();}

这里有两个坑要记:

  • 它认的是 PointerDown(按下),不是 PointerEnter(悬停)。鼠标在按钮上划过、悬停,OnPointerEnter 只改 isPointerInside 视觉高亮,绝对不会调 SetSelectedGameObject。所以"鼠标悬停就高亮"和"鼠标选中"是两件事:悬停只换 Highlighted,按下才进 Selected。很多人把 hover 当成选中,调试时一脸懵。
  • 条件是 navigation.mode != Navigation.Mode.None。你把这个 Selectable 的导航模式设成 None,左键按下也不会建立选中——因为 UGUI 认为"不参与导航的控件不该被 EventSystem 当作选中目标"。要纯点按钮不抢焦点,设 None 反而能达到目的,代价是它再也进不了 Selected 态。
  1. 键盘/手柄导航(OnMove → selectedObject)
    :手柄那一节讲的,方向键派发 OnMove,Navigate 把候选写回 axisEventData.selectedObject,由导航收口调 SetSelectedGameObject。这是"从一个选中切到下一个"。
  2. 公开 API(Selectable.Select())
    :程序里想主动选中某个控件,调它:
// 逻辑示意:Selectable.Select 是程序化选中的正道publicvirtualvoidSelect(){if (EventSystem.current == null || EventSystem.current.alreadySelecting)return;    EventSystem.current.SetSelectedGameObject(gameObject);  // 一样走同一个漏斗}

注意它也有 alreadySelecting 守卫(就是 SetSelectedGameObject 里的 m_SelectionGuard),在选中回调里递归调 Select() 会被掐掉。别自己手搓 SetSelectedGameObject,用 Select() 语义更清楚,而且它帮你判了空和重入。

清空:只有一条路,但有两个自动触发点

要把焦点放掉,本质只有 SetSelectedGameObject(null) 这一招——传 null 进去,旧的 OnDeselect 照跑,槽清空。

UGUI 还会在"当前选中的对象变得不可交互"时自动帮你调它(OnCanvasGroupChanged / OnValidate 路径):

// 逻辑示意:选中对象变不可交互时,主动释放焦点if (!interactable && EventSystem.current != null    && EventSystem.current.currentSelectedGameObject == gameObject)    EventSystem.current.SetSelectedGameObject(null);

也就是说,你把一个正在被选中的按钮 interactable=false,或者它的父 CanvasGroup 被禁掉,它会被自动踢出选中态。这是"不可交互"这条状态变化触发的,不是 OnDisable 的职责。

简言之:别假设"禁用/销毁一个被选中的对象"就一定帮你清掉 currentSelectedGameObject——版本之间、是否被交互系统扫到,都不保证一致。需要释放焦点时,显式 SetSelectedGameObject(null) 或 Selectable.Select() 换一个,最稳。尤其对象被 Destroy 后,残留的 currentSelectedGameObject 指向一个已死 GameObject,后续导航 Execute 在死对象上静默失败,是典型的"焦点卡死"。

总结

  1. Selectable 是地基,不是业务。 它统一管"控件现在处在什么交互态"(五态状态机、Transition 过渡、指针追踪、选中钩子、导航关系),但"状态变了之后要干嘛"(Click、Submit……)全是 Button/Toggle 这些派生类自己的事。看到"状态对了但事件没来",先想清是哪一层的问题。

  2. interactable 和 enabled 是两码事。interactable=false 是状态机自己掐到 Disabled,派生类的 onClick 自然就不触发;enabled=false 才是组件/对象彻底停摆。两者别混。

  3. UGUI 的交互没有"魔法全局状态",每一条都有明确入口和出口。 选中靠 EventSystem.SetSelectedGameObject 这一个漏斗(鼠标 OnPointerDown、手柄导航 OnMove→selectedObject、公开 Select() 四条入口);渐变只有 ColorTint 是真·tween(而且自带、不用引插件);Toggle 的 onValueChanged 永远只扔新值。

最后把几个最容易踩的坑再点一遍:

  • 卡在按下色
    :叠层模态弹窗吃掉 OnPointerUp → isPointerDown 泄漏,加上卡顿把颜色 tween 协程冻在半路 —— 真凶是状态标记没清 + 协程冻结,不是 tween 叠加。推荐关弹窗时对旧按钮做一次 enabled 翻转。
  • 鼠标释放是事件驱动,手柄/键盘 Submit 是定时器驱动
    ,两条路的坑不通用(Submit 还不防重入,连点会堆协程)。
  • ToggleExtension 别只转一手 isOn
    :补一个携带旧值的回调,能通杀标签页、一次性引导、仅关闭时做事这些自定义需求。

—— 与君共勉~ ——

相关学习资料