UGUI源码剖析(13):交互的基石——Selectable状态机与Button事件
UGUI源码剖析(第十三章):交互的基石——Selectable状态机与Button事件
在UGUI中,几乎所有可交互的组件——Button, Toggle, Slider, InputField, Dropdown, Scrollbar——都并非从零开始构建。它们都共同继承自一个强大的、管理着交互状态和视觉表现的基类:Selectable。而Selectable与EventSystem之间的沟通,则依赖于一套定义清晰的事件接口(I…Handler)。这一章,我们将深入这套交互的基础,理解一个Button是如何工作的。
1. “契约”的语言:IEventSystemHandler事件接口
EventSystem在确定了一个交互目标后,它并不关心这个目标具体是什么类型的组件。它只通过一套标准的事件接口来与其沟通。
namespace UnityEngine.EventSystems
{public interface IPointerClickHandler : IEventSystemHandler{void OnPointerClick(PointerEventData eventData);}public interface IPointerDownHandler : IEventSystemHandler { ... }public interface IDragHandler : IEventSystemHandler { ... }
}
- 观察者模式:这套接口是典型的观察者模式的实现。任何一个MonoBehaviour,只要实现了例如IPointerClickHandler接口,就等于向EventSystem“订阅”了“指针点击”这个事件。
- 事件派发:当EventSystem(通过InputModule)决定要派发一个“点击”事件时,它会使用ExecuteEvents.Execute(target, …)。这个方法会在target及其父级上,查找第一个实现了IPointerClickHandler接口的组件,并调用其OnPointerClick方法。
- 解耦:这套机制将事件的产生(由InputModule负责)与事件的处理(由实现了接口的组件负责)彻底解耦,是整个事件系统灵活性的基础。
2. Selectable:所有可交互组件的“超级父类”
Selectable是一个极其复杂的UIBehaviour,它几乎是一个自成体系的微型框架。它为所有子类提供了状态管理、视觉过渡、和导航三大核心功能。
2.1 核心机制:一个强大的内部状态机
Selectable的核心,是一个用于管理其交互状态的内部状态机。
-
输入信号:Selectable通过实现IPointerDownHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler, ISelectHandler, IDeselectHandler等多个事件接口,来接收来自EventSystem的最原始的输入信号。
public virtual void OnPointerDown(PointerEventData eventData) {// ...isPointerDown = true; // 记录内部状态EvaluateAndTransitionToSelectionState(); // 触发状态评估与过渡 } public virtual void OnPointerEnter(PointerEventData eventData) {isPointerInside = true; // 记录内部状态EvaluateAndTransitionToSelectionState(); }
-
内部状态变量:它通过三个核心的bool变量来跟踪当前状态:isPointerInside(指针是否悬浮在内部),isPointerDown(指针是否已按下),hasSelection(是否被EventSystem设为当前选中对象)。
-
状态评估 (currentSelectionState): 这是一个protected属性,它根据上述三个bool变量和IsInteractable()的返回值,来计算出当前应该处于的最终状态(Normal, Highlighted, Pressed, Selected, Disabled)。
protected SelectionState currentSelectionState {get{if (!IsInteractable()) return SelectionState.Disabled;if (isPointerDown) return SelectionState.Pressed;if (hasSelection) return SelectionState.Selected;if (isPointerInside) return SelectionState.Highlighted;return SelectionState.Normal;} }
-
触发过渡 (EvaluateAndTransitionToSelectionState): 当任何一个输入信号改变了内部状态变量后,都会调用此方法,该方法内部会调用DoStateTransition,来执行最终的视觉状态过渡。
2.2 视觉表现的核心:DoStateTransition
这个方法是Selectable的处理过渡核心代码。它根据currentSelectionState计算出的最终状态,来执行用户在Inspector中配置的视觉过渡(Transition)。
protected virtual void DoStateTransition(SelectionState state, bool instant)
{// ... 根据state,获取目标颜色、Sprite和动画触发器名 ...switch (m_Transition){case Transition.ColorTint:StartColorTween(tintColor * m_Colors.colorMultiplier, instant);break;case Transition.SpriteSwap:DoSpriteSwap(transitionSprite);break;case Transition.Animation:TriggerAnimation(triggerName);break;}
}
- ColorTint: 调用m_TargetGraphic.CrossFadeColor,启动一个内置的、基于CoroutineTween的颜色渐变动画。
- SpriteSwap: 直接修改m_TargetGraphic(通常是一个Image)的overrideSprite属性,实现图片的瞬间切换。
- Animation: 通过animator.SetTrigger(triggername),触发附加在Animator组件上的、预先定义好的动画状态。
2.3 导航系统 (Navigation)
Selectable不仅处理指针(鼠标/触摸)输入,它还内置了一套完整的导航系统,专门用于处理来自**键盘(方向键)、手柄(摇杆/十字键)**的、基于“焦点”转移的交互。这是确保UI能够在PC、主机等多种平台上拥有良好体验的核心。
2.3.1 导航模式与触发
导航模式 (Navigation.Mode): Selectable提供了多种导航模式,其中最核心的是:
- Explicit (显式):最简单、最可控的模式。开发者可以在Inspector中,为“上/下/左/右”四个方向,手动拖拽并指定下一个被选中的Selectable对象。
- Automatic (自动):这是最复杂的模式。当此模式开启时,Selectable会尝试在场景中自动地、几何地寻找到最合适的下一个目标。
触发机制 (OnMove): Selectable通过实现IMoveHandler接口来接收来自EventSystem的“移动”事件。当玩家按下方向键或推动摇杆时,StandaloneInputModule会派发一个Move事件,最终调用当前选中Selectable的OnMove方法。
// Selectable.cs
public virtual void OnMove(AxisEventData eventData)
{switch (eventData.moveDir){case MoveDirection.Right:Navigate(eventData, FindSelectableOnRight());break;// ... cases for Up, Left, Down ...}
}
OnMove内部会调用FindSelectableOn…()系列方法,而这些方法在Automatic模式下,最终都会调用核心的寻路算法——FindSelectable(Vector3 dir)。
2.3.2 核心算法剖析:FindSelectable(Vector3 dir)
这个方法是UGUI自动导航的“大脑”。它的目标是:从当前Selectable的位置出发,沿着给定的方向dir,在场景中所有其他可交互的Selectable中,找到一个“最佳”的下一个目标。
// Selectable.cs -> FindSelectable
public Selectable FindSelectable(Vector3 dir)
{dir = dir.normalized;// ...Vector3 pos = transform.TransformPoint(GetPointOnRectEdge(...)); // 1. 计算出发点float maxScore = Mathf.NegativeInfinity;Selectable bestPick = null;// 2. 遍历场景中所有可交互的Selectablefor (int i = 0; i < s_SelectableCount; ++i){Selectable sel = s_Selectables[i];if (sel == this || !sel.IsInteractable() || ...) continue;// 3. 计算目标向量Vector3 myVector = sel.transform.position - pos;// 4. 计算方向的点积float dot = Vector3.Dot(dir, myVector);// 5. 过滤掉方向错误的if (dot <= 0) continue;// 6. 核心评分公式// score = dot / myVector.sqrMagnitude;// 展开后等价于: (点积 / 距离的平方)score = Vector3.Dot(dir, myVector.normalized) / myVector.magnitude;// 7. 寻找得分最高者if (score > maxScore){maxScore = score;bestPick = sel;}}return bestPick;
}
- 计算出发点 (pos): 它并非从当前Selectable的中心点出发,而是通过GetPointOnRectEdge,从其矩形边框上、最贴近目标方向的那个点出发。这能更好地处理大小不一的UI元素间的导航。
- 遍历候选目标: 它会遍历一个静态数组s_Selectables,这个数组由所有Selectable在OnEnable时自动注册填充,包含了场景中所有激活的Selectable。
- 方向过滤 (点积): Vector3.Dot(dir, myVector)计算了目标向量在导航方向上的投影长度。如果dot <= 0,意味着目标位于导航方向的反方向或侧方90度以外,会被直接过滤掉。这是第一层筛选。
- 核心评分公式 (score): 这是整个算法的精华。这个看似简单的公式dot / myVector.sqrMagnitude,巧妙地融合了两个核心的评判标准:
- 角度偏差: dot值越大,意味着目标与导航方向的夹角越小,即方向越“正”。
- 距离远近: myVector.sqrMagnitude(距离的平方)作为分母,意味着距离越近的目标,得分越高。
- 可视化理解:官方文档的注释提供了一个绝佳的比喻:“从出发点pos,沿着dir方向,吹起一个圆形的‘气球’。第一个被这个气球触碰到的Selectable,就是最佳选择。”这个评分公式,正是对这个“吹气球”过程的数学模拟。它优先选择那些方向最正、且距离最近的目标。
2.3.3 平台适用性与设计考量
- 核心适用平台: Selectable的整套导航系统,其设计的核心目标,就是为了服务于PC(键盘)、游戏主机(手柄)等依赖非指针、离散式输入的平台。在这些平台上,提供一套流畅、可预测的焦点导航体验,是UI是否“可用”的根本。
- 移动平台的“兼容”: 在移动平台上,这套系统通常处于“休眠”状态,因为主要的交互由IPointer…Handler等触摸事件来处理。但它并非完全无用。例如,如果你的移动游戏外接了蓝牙手柄,这套导航系统会立刻被激活,提供与主机一致的交互体验。
- 为什么需要Explicit模式?: 尽管Automatic模式很智能,但在一些复杂的、非网格对齐的UI布局中,其算法有时会找到一些不符合设计师预期的“奇怪”目标。Explicit模式,则为设计师提供了100%可控的“最终解释权”。它允许设计师像连接“电路图”一样,精确地定义每一个UI元素的导航路径,确保在任何情况下,导航行为都与设计意图完全一致。在追求极致用户体验的商业项目中,Explicit模式往往是比Automatic更常用、更可靠的选择。
3. Button:Selectable的“专精”子类
Button组件的源码非常简洁,因为它将几乎所有的状态和视觉管理工作,都委托给了其父类Selectable。它只做了两件额外的事情:
public class Button : Selectable, IPointerClickHandler, ISubmitHandler
{[SerializeField]private ButtonClickedEvent m_OnClick = new ButtonClickedEvent();public ButtonClickedEvent onClick { get { return m_OnClick; } set { m_OnClick = value; } }public virtual void OnPointerClick(PointerEventData eventData){if (eventData.button != PointerEventData.InputButton.Left) return;Press();}public virtual void OnSubmit(BaseEventData eventData){Press();// ... Start a coroutine for visual effect ...}private void Press(){if (!IsActive() || !IsInteractable()) return;m_OnClick.Invoke();}
}
- 定义onClick事件: 它定义了一个public ButtonClickedEvent(继承自UnityEvent)类型的onClick事件。这使得我们可以在Inspector中,或在代码中,为按钮的点击行为注册回调。
- 实现IPointerClickHandler和ISubmitHandler: Button实现了这两个核心的事件接口。
- OnPointerClick: 当EventSystem派发“指针点击”事件时(通常在鼠标左键抬起时),此方法被调用。它会调用私有的Press()方法。
- OnSubmit: 当EventSystem派发“提交”事件时(例如,当按钮被选中时,玩家按下了回车键),此方法也会被调用,同样执行Press()。
- Press(): 这个方法是最终的执行者。它会先检查IsInteractable()(这个方法继承自Selectable,并且会考虑CanvasGroup的影响),如果可交互,则调用m_OnClick.Invoke(),从而触发所有已注册的回调。
总结:
UGUI的交互系统,是一套基于事件接口、以Selectable为核心状态机的、分工明确的优雅架构。
- I…Handler接口 定义了组件与EventSystem之间的通信“契约”。
- Selectable 作为所有可交互组件的基类,通过实现这些接口,构建了一个强大的内部状态机来管理Normal, Highlighted, Pressed, Selected, Disabled五种状态,并负责驱动三种不同的视觉过渡(颜色、精灵、动画)和复杂的导航逻辑。
- Button 等具体的组件,则作为Selectable的专精子类,只负责实现其最核心的业务事件(如IPointerClickHandler),并将所有通用的状态和视觉管理,都委托给父类处理。
理解了这套“基类-子类”的委托模型和“事件驱动”的通信机制,我们就能更好地去使用和扩展UGUI的交互组件,甚至可以继承Selectable,来创建属于我们自己的、功能丰富的自定义控件。