UGUI源码剖析(13):交互的基石——Selectable状态机与Button事件

发布于:2025-08-29 ⋅ 阅读:(20) ⋅ 点赞:(0)

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. 遍历场景中所有可交互的Selectable
    for (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;
}
  1. 计算出发点 (pos): 它并非从当前Selectable的中心点出发,而是通过GetPointOnRectEdge,从其矩形边框上、最贴近目标方向的那个点出发。这能更好地处理大小不一的UI元素间的导航。
  2. 遍历候选目标: 它会遍历一个静态数组s_Selectables,这个数组由所有Selectable在OnEnable时自动注册填充,包含了场景中所有激活的Selectable。
  3. 方向过滤 (点积): Vector3.Dot(dir, myVector)计算了目标向量在导航方向上的投影长度。如果dot <= 0,意味着目标位于导航方向的反方向或侧方90度以外,会被直接过滤掉。这是第一层筛选。
  4. 核心评分公式 (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();
    }
}
  1. 定义onClick事件: 它定义了一个public ButtonClickedEvent(继承自UnityEvent)类型的onClick事件。这使得我们可以在Inspector中,或在代码中,为按钮的点击行为注册回调。
  2. 实现IPointerClickHandler和ISubmitHandler: Button实现了这两个核心的事件接口。
    • OnPointerClick: 当EventSystem派发“指针点击”事件时(通常在鼠标左键抬起时),此方法被调用。它会调用私有的Press()方法。
    • OnSubmit: 当EventSystem派发“提交”事件时(例如,当按钮被选中时,玩家按下了回车键),此方法也会被调用,同样执行Press()。
  3. Press(): 这个方法是最终的执行者。它会先检查IsInteractable()(这个方法继承自Selectable,并且会考虑CanvasGroup的影响),如果可交互,则调用m_OnClick.Invoke(),从而触发所有已注册的回调。

总结:

UGUI的交互系统,是一套基于事件接口、以Selectable为核心状态机的、分工明确的优雅架构。

  1. I…Handler接口 定义了组件与EventSystem之间的通信“契约”
  2. Selectable 作为所有可交互组件的基类,通过实现这些接口,构建了一个强大的内部状态机来管理Normal, Highlighted, Pressed, Selected, Disabled五种状态,并负责驱动三种不同的视觉过渡(颜色、精灵、动画)和复杂的导航逻辑
  3. Button 等具体的组件,则作为Selectable的专精子类,只负责实现其最核心的业务事件(如IPointerClickHandler),并将所有通用的状态和视觉管理,都委托给父类处理。

理解了这套“基类-子类”的委托模型和“事件驱动”的通信机制,我们就能更好地去使用和扩展UGUI的交互组件,甚至可以继承Selectable,来创建属于我们自己的、功能丰富的自定义控件。


网站公告

今日签到

点亮在社区的每一天
去签到