Unity3D UGUI 源码学习 Selectable

Selectable承担的逻辑是对玩家的输入产生反馈。它是交互的控件的基类,能够响应点击(或鼠标)的事件而产生动画效果,同时有导航功能(Navigation)。事实上,最简单的交互控件按钮Button,也仅仅是在Selectable之上多了点击事件回调的处理。

概览

属性

1
2
3
4
[AddComponentMenu("UI/Selectable", 70)]
[ExecuteInEditMode]
[SelectionBase]
[DisallowMultipleComponent]

有三个都是很常见到的属性,SelectionBase不太容易见到,它表示当在编辑器的场景视图中点击了某个GameObject时,如果点击的是某个prefab的一部分,那么会选中这个prefab的根节点,因为根节点被认为是SelectionBase。如果给脚本增加此属性,则挂载该脚本的对象会同样被认为是SelectionBase

基类与接口

1
2
3
4
5
6
7
8
public class Selectable
:
UIBehaviour,
IMoveHandler,
IPointerDownHandler, IPointerUpHandler,
IPointerEnterHandler, IPointerExitHandler,
ISelectHandler, IDeselectHandler
{

继承自UIBehaviour,并且实现了若干个EventInterfaces中定义的接口。

衍生类

以下是Selectable的衍生类,各种可交互的UI控件:

  • Button
  • Dropdown
  • InputField
  • Scrollbar
  • Slider
  • Toggle

涉及到的类、枚举、结构

Selectable中有好多涉及到的很小的类、枚举或结构,用于表示当前的状态等,以协助实现交互的功能。

SelectionState

表示当前Selectable组件的状态,其定义如下:

1
2
3
4
5
6
7
protected enum SelectionState
{
Normal,
Highlighted,
Pressed,
Disabled
}

Transition

Selectable组件使用的是哪种形式的转变动画,其定义如下:

1
2
3
4
5
6
7
public enum Transition
{
None,
ColorTint,
SpriteSwap,
Animation
}

依次是:不使用动画、使用颜色变化、使用变换sprite、使用Animation。三种动画形式对应会用到后边三个结构(或类):ColorBlockSpriteStateAnimationTriggers

ColorBlock

1
public struct ColorBlock : IEquatable<ColorBlock>

实现了接口IEquatable,内含Equals方法,用于判断两个对象是否相等。结构里边包含了6个私有成员变量及它们对应的公有的调用接口(属性形式),如下:

1
2
3
4
5
6
public Color normalColor       { get { return m_NormalColor; } set { m_NormalColor = value; } }
public Color highlightedColor { get { return m_HighlightedColor; } set { m_HighlightedColor = value; } }
public Color pressedColor { get { return m_PressedColor; } set { m_PressedColor = value; } }
public Color disabledColor { get { return m_DisabledColor; } set { m_DisabledColor = value; } }
public float colorMultiplier { get { return m_ColorMultiplier; } set { m_ColorMultiplier = value; } }
public float fadeDuration { get { return m_FadeDuration; } set { m_FadeDuration = value; } }

最后关于相等的判断,定义如下,并且重载了==!=运算符,均使用Equals来判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public override bool Equals(object obj)
{
if (!(obj is ColorBlock))
return false;

return Equals((ColorBlock)obj);
}

public bool Equals(ColorBlock other)
{
return normalColor == other.normalColor &&
highlightedColor == other.highlightedColor &&
pressedColor == other.pressedColor &&
disabledColor == other.disabledColor &&
colorMultiplier == other.colorMultiplier &&
fadeDuration == other.fadeDuration;
}

SpriteState

1
public struct SpriteState : IEquatable<SpriteState>

ColorBlock套路相似,3个私有成员和公有属性:

1
2
3
public Sprite highlightedSprite    { get { return m_HighlightedSprite; } set { m_HighlightedSprite = value; } }
public Sprite pressedSprite { get { return m_PressedSprite; } set { m_PressedSprite = value; } }
public Sprite disabledSprite { get { return m_DisabledSprite; } set { m_DisabledSprite = value; } }

相等的比较:

1
2
3
4
5
6
public bool Equals(SpriteState other)
{
return highlightedSprite == other.highlightedSprite &&
pressedSprite == other.pressedSprite &&
disabledSprite == other.disabledSprite;
}

AnimationTriggers

1
public class AnimationTriggers

与前边两个相似,直接用的类而不是实现接口IEquatable的结构,而且也没有覆写EqualsGetHashCode方法。有4个私有成员和公有属性:

1
2
3
4
public string normalTrigger      { get { return m_NormalTrigger; } set { m_NormalTrigger = value; } }
public string highlightedTrigger { get { return m_HighlightedTrigger; } set { m_HighlightedTrigger = value; } }
public string pressedTrigger { get { return m_PressedTrigger; } set { m_PressedTrigger = value; } }
public string disabledTrigger { get { return m_DisabledTrigger; } set { m_DisabledTrigger = value; } }

还有一些是和导航相关的,导航即是对于当前选中的Selectable,以何种方式获取它的上边、下边、左边或右边的下一个(或上一个)SelectableNavigation结构中存储的是当前导航的模式及相关的Selectable

1
public struct Navigation : IEquatable<Navigation>

同样是私有成员和它们对应的共有属性:

1
2
3
4
5
public Mode       mode           { get { return m_Mode; } set { m_Mode = value; } }
public Selectable selectOnUp { get { return m_SelectOnUp; } set { m_SelectOnUp = value; } }
public Selectable selectOnDown { get { return m_SelectOnDown; } set { m_SelectOnDown = value; } }
public Selectable selectOnLeft { get { return m_SelectOnLeft; } set { m_SelectOnLeft = value; } }
public Selectable selectOnRight { get { return m_SelectOnRight; } set { m_SelectOnRight = value; } }

实现了Equals方法:

1
2
3
4
5
6
7
8
public bool Equals(Navigation other)
{
return mode == other.mode &&
selectOnUp == other.selectOnUp &&
selectOnDown == other.selectOnDown &&
selectOnLeft == other.selectOnLeft &&
selectOnRight == other.selectOnRight;
}

主要的方法

现在开始捋Selectable中的一些重要的方法:

继承自UIBehaviour/MonoBehaviour

OnEnable

Selectable中有一个静态的列表s_List,存储的是当前激活状态的Selectable实例。m_CurrentSelectionStateSelectionState的值,表示当前该Selectable的状态。hasSelection是布尔值表示当前Selectable是否被选中。在回调方法OnSelectOnDeselect等中会改变此值,后边会讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected override void OnEnable()
{
base.OnEnable();

s_List.Add(this);
var state = SelectionState.Normal;

// The button will be highlighted even in some cases where it shouldn't.
// For example: We only want to set the State as Highlighted if the StandaloneInputModule.m_CurrentInputMode == InputMode.Buttons
// But we dont have access to this, and it might not apply to other InputModules.
// TODO: figure out how to solve this. Case 617348.
if (hasSelection)
state = SelectionState.Highlighted;

m_CurrentSelectionState = state;
InternalEvaluateAndTransitionToSelectionState(true);
}

OnEnable时,Selectable会把自己加入到s_List,最后调用InternalEvaluateAndTransitionToSelectionState来计算并更新该Selectable的状态及动画。

OnDisable

1
2
3
4
5
6
protected override void OnDisable()
{
s_List.Remove(this);
InstantClearState();
base.OnDisable();
}

调用OnDisable时,从s_List中移除自己,立即清除状态及动画InstantClearState

OnCanvasGroupChanged

覆写的UIBehaviour的方法,当该对象及向上的父级节点对象有挂载CanvasGroup时,需要根据这些CanvasGroup的可交互状态来更新m_GroupsAllowInteraction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private readonly List<CanvasGroup> m_CanvasGroupCache = new List<CanvasGroup>();
protected override void OnCanvasGroupChanged()
{
// Figure out if parent groups allow interaction
// If no interaction is alowed... then we need
// to not do that :)
var groupAllowInteraction = true;
Transform t = transform;
while (t != null)
{
t.GetComponents(m_CanvasGroupCache);
bool shouldBreak = false;
for (var i = 0; i < m_CanvasGroupCache.Count; i++)
{
// if the parent group does not allow interaction
// we need to break
if (!m_CanvasGroupCache[i].interactable)
{
groupAllowInteraction = false;
shouldBreak = true;
}
// if this is a 'fresh' group, then break
// as we should not consider parents
if (m_CanvasGroupCache[i].ignoreParentGroups)
shouldBreak = true;
}
if (shouldBreak)
break;

t = t.parent;
}

if (groupAllowInteraction != m_GroupsAllowInteraction)
{
m_GroupsAllowInteraction = groupAllowInteraction;
OnSetProperty();
}
}

响应事件的回调

Selectable实现了事件系统的事件接口,因此必须实现接口定义的方法。我们以OnPointerEnter事件为例:

OnPointerEnter

1
2
3
4
5
public virtual void OnPointerEnter(PointerEventData eventData)
{
isPointerInside = true;
EvaluateAndTransitionToSelectionState(eventData);
}

EvaluateAndTransitionToSelectionState会根据传入的事件来计算和更新转换动画的状态,在此之前,OnPointerEnter还会改变状态值isPointerInside为true,对应在OnPointerExit时会将此值设为false。与此相似的还有OnSelectOnDeselect,对应状态值hasSelectionOnPointerDownOnPointerUp,对应状态值isPointerDown。这些方法的共同之处是先改变对应状态值,然后调用EvaluateAndTransitionToSelectionState

除了点击相关的事件之外,还有导航相关的事件,文章最后一起讨论。

其它方法

接下来是属于Selectable自己的一些方法,大多是和改变(或更新)转变动画的状态有关。

EvaluateAndTransitionToSelectionState

首先是之前遇到过的EvaluateAndTransitionToSelectionState,其定义如下:

1
2
3
4
5
6
7
8
9
// Change the button to the correct state
private void EvaluateAndTransitionToSelectionState(BaseEventData eventData)
{
if (!IsActive() || !IsInteractable())
return;

UpdateSelectionState(eventData);
InternalEvaluateAndTransitionToSelectionState(false);
}

首先是排除无效调用,未激活或者不可交互。根据eventData更新选择状态,UpdateSelectionState,然后调用InternalEvaluateAndTransitionToSelectionState,参数instance为false。

UpdateSelectionState

根据参数eventData来更新m_CurrentSelectionState的值,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The current visual state of the control.
protected void UpdateSelectionState(BaseEventData eventData)
{
if (IsPressed())
{
m_CurrentSelectionState = SelectionState.Pressed;
return;
}

if (IsHighlighted(eventData))
{
m_CurrentSelectionState = SelectionState.Highlighted;
return;
}

m_CurrentSelectionState = SelectionState.Normal;
}

此处的IsHighlighted(eventData)有较多的判断逻辑,highlight状态对应的几种情况,此处的eventData其实就是OnXxx调用时传入的EventData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Whether the control should be 'selected'.
protected bool IsHighlighted(BaseEventData eventData)
{
if (!IsActive())
return false;

if (IsPressed())
return false;

bool selected = hasSelection;
if (eventData is PointerEventData)
{
var pointerData = eventData as PointerEventData;
selected |=
(isPointerDown && !isPointerInside && pointerData.pointerPress == gameObject) // This object pressed, but pointer moved off
|| (!isPointerDown && isPointerInside && pointerData.pointerPress == gameObject) // This object pressed, but pointer released over (PointerUp event)
|| (!isPointerDown && isPointerInside && pointerData.pointerPress == null); // Nothing pressed, but pointer is over
}
else
{
selected |= isPointerInside;
}
return selected;
}

更新SelectionState之后就是根据更新后的选择状态来计算和调整转换动画:

InternalEvaluateAndTransitionToSelectionState

1
2
3
4
5
6
7
private void InternalEvaluateAndTransitionToSelectionState(bool instant)
{
var transitionState = m_CurrentSelectionState;
if (IsActive() && !IsInteractable())
transitionState = SelectionState.Disabled;
DoStateTransition(transitionState, instant);
}

获取正确的transitionState(可能会是Disabled),然后执行状态转换DoStateTransition

DoStateTransition

根据多个选择状态转变的状态(传入的state)及三种转换动画的类型m_Transition来执行具体的动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
protected virtual void DoStateTransition(SelectionState state, bool instant)
{
Color tintColor;
Sprite transitionSprite;
string triggerName;

switch (state)
{
case SelectionState.Normal:
tintColor = m_Colors.normalColor;
transitionSprite = null;
triggerName = m_AnimationTriggers.normalTrigger;
break;
case SelectionState.Highlighted:
tintColor = m_Colors.highlightedColor;
transitionSprite = m_SpriteState.highlightedSprite;
triggerName = m_AnimationTriggers.highlightedTrigger;
break;
case SelectionState.Pressed:
tintColor = m_Colors.pressedColor;
transitionSprite = m_SpriteState.pressedSprite;
triggerName = m_AnimationTriggers.pressedTrigger;
break;
case SelectionState.Disabled:
tintColor = m_Colors.disabledColor;
transitionSprite = m_SpriteState.disabledSprite;
triggerName = m_AnimationTriggers.disabledTrigger;
break;
default:
tintColor = Color.black;
transitionSprite = null;
triggerName = string.Empty;
break;
}

if (gameObject.activeInHierarchy)
{
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;
}
}
}

依据三种不同的转换动画的类型,有三种具体的状态转变函数,StartColorTweenDoSpriteSwapTriggerAnimation,根据不同的选择状态有不同的参数。DoStateTransition实际上就是调用不同的状态转变函数并传入对应的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void StartColorTween(Color targetColor, bool instant)
{
if (m_TargetGraphic == null)
return;

m_TargetGraphic.CrossFadeColor(targetColor, instant ? 0f : m_Colors.fadeDuration, true, true);
}

void DoSpriteSwap(Sprite newSprite)
{
if (image == null)
return;

image.overrideSprite = newSprite;
}

void TriggerAnimation(string triggername)
{
if (transition != Transition.Animation || animator == null || !animator.isActiveAndEnabled || animator.runtimeAnimatorController == null || string.IsNullOrEmpty(triggername))
return;

animator.ResetTrigger(m_AnimationTriggers.normalTrigger);
animator.ResetTrigger(m_AnimationTriggers.pressedTrigger);
animator.ResetTrigger(m_AnimationTriggers.highlightedTrigger);
animator.ResetTrigger(m_AnimationTriggers.disabledTrigger);

animator.SetTrigger(triggername);
}

StartColorTween会调用Graphic的方法,传入目标颜色值和变换的时间,后边细说。m_TargetGraphicimage获取到的都是该Selectable上的Graphic组件,animator得到的是Animator组件。

ImageoverrideSprite属性,如果不为null,则绘制时会覆盖掉sprite属性中指定的精灵图片(后边会有Image的章节单说)。animatorResetTrigger会停掉对应的动画,然后用SetTrigger可以开启对应的动画。接下来是关于GraphicCrossFadeColor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public virtual void CrossFadeColor(Color targetColor, float duration, bool ignoreTimeScale, bool useAlpha)
{
CrossFadeColor(targetColor, duration, ignoreTimeScale, useAlpha, true);
}

public virtual void CrossFadeColor(Color targetColor, float duration, bool ignoreTimeScale, bool useAlpha, bool useRGB)
{
if (canvasRenderer == null || (!useRGB && !useAlpha))
return;

Color currentColor = canvasRenderer.GetColor();
if (currentColor.Equals(targetColor))
{
m_ColorTweenRunner.StopTween();
return;
}

ColorTween.ColorTweenMode mode = (useRGB && useAlpha ?
ColorTween.ColorTweenMode.All :
(useRGB ? ColorTween.ColorTweenMode.RGB : ColorTween.ColorTweenMode.Alpha));

var colorTween = new ColorTween {duration = duration, startColor = canvasRenderer.GetColor(), targetColor = targetColor};
colorTween.AddOnChangedCallback(canvasRenderer.SetColor);
colorTween.ignoreTimeScale = ignoreTimeScale;
colorTween.tweenMode = mode;
m_ColorTweenRunner.StartTween(colorTween);
}

指定了目标颜色和用时等参数,在指定的时间内变化到目标颜色。内部使用到一个ColorTween的类,它是用协程实现的一个逐帧更新的颜色动画,指定初始和目标颜色,变化时间,是否忽略TimeScale,变化模式(RGB变化/Alpha变化)。

InstantClearState

立即清除动画状态,先是重置各状态,然后与DoStateTransition有点相似,根据不同的转换动画类型,将其重置,并且StartColorTween传入参数instant为true,即颜色补间动画的时长是0,立即变化到目标颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected virtual void InstantClearState()
{
string triggerName = m_AnimationTriggers.normalTrigger;

isPointerInside = false;
isPointerDown = false;
hasSelection = false;

switch (m_Transition)
{
case Transition.ColorTint:
StartColorTween(Color.white, true);
break;
case Transition.SpriteSwap:
DoSpriteSwap(null);
break;
case Transition.Animation:
TriggerAnimation(triggerName);
break;
}
}

OnSetProperty

还有一个被很多地方调用的方法OnSetProperty

1
2
3
4
5
6
7
8
9
private void OnSetProperty()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
InternalEvaluateAndTransitionToSelectionState(true);
else
#endif
InternalEvaluateAndTransitionToSelectionState(false);
}

当属性(状态)发生变化时,调用以更新变换动画,在编辑器模式下且游戏未在运行中时,立即执行,其它的状态会播放变换的动画。

导航相关

其实前边提到的方法基本上实现了Selectable的主要的功能。Unity中的各交互组件还有导航相关功能,也是由Selectable来实现的。

OnMove

前边说到Selectable实现的事件接口,其实还有一个漏掉的OnMove

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public virtual void OnMove(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;
}
}

根据事件中给出的移动方向(上下左右),向对应方向的Selectable对象导航。

1
2
3
4
5
6
// Convenience function -- change the selection to the specified object if it's not null and happens to be active.
void Navigate(AxisEventData eventData, Selectable sel)
{
if (sel != null && sel.IsActive())
eventData.selectedObject = sel.gameObject;
}

Navigate的内容很简单,更新Selectable对象。

FindSelectableOnRight

重点在于如果获取目标Selectable对象,即FindSelectableOnXxx这些方法。我们以FindSelectableOnRight为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Find the selectable object to the right of this one.
public virtual Selectable FindSelectableOnRight()
{
if (m_Navigation.mode == Navigation.Mode.Explicit)
{
return m_Navigation.selectOnRight;
}
if ((m_Navigation.mode & Navigation.Mode.Horizontal) != 0)
{
return FindSelectable(transform.rotation * Vector3.right);
}
return null;
}

首先m_Navigation有各种模式:

1
2
3
4
5
6
7
8
9
[Flags]
public enum Mode
{
None = 0, // No navigation
Horizontal = 1, // Automatic horizontal navigation
Vertical = 2, // Automatic vertical navigation
Automatic = 3, // Automatic navigation in both dimensions
Explicit = 4, // Explicitly specified only
}

如果是显式指定的导航模式,则会选择m_Navigation.selectOnRight为导航目标。否则会继续判断当前导航模式是否支持水平方向导航,这里用的判断条件是(m_Navigation.mode & Navigation.Mode.Horizontal) != 0。由于前边有[flag]属性,因此该枚举值可以按位与运算。Horizontal = 1Automatic = 3均满足此条件。如果支持水平方向导航,则会调用FindSelectable方法。

FindSelectable

根据传入的方向dir,寻找最合适的目标,作为导航目标对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Find the next selectable object in the specified world-space direction.
public Selectable FindSelectable(Vector3 dir)
{
dir = dir.normalized;
Vector3 localDir = Quaternion.Inverse(transform.rotation) * dir;
Vector3 pos = transform.TransformPoint(GetPointOnRectEdge(transform as RectTransform, localDir));
float maxScore = Mathf.NegativeInfinity;
Selectable bestPick = null;
for (int i = 0; i < s_List.Count; ++i)
{
Selectable sel = s_List[i];

if (sel == this || sel == null)
continue;

if (!sel.IsInteractable() || sel.navigation.mode == Navigation.Mode.None)
continue;

var selRect = sel.transform as RectTransform;
Vector3 selCenter = selRect != null ? (Vector3)selRect.rect.center : Vector3.zero;
Vector3 myVector = sel.transform.TransformPoint(selCenter) - pos;

// Value that is the distance out along the direction.
float dot = Vector3.Dot(dir, myVector);

// Skip elements that are in the wrong direction or which have zero distance.
// This also ensures that the scoring formula below will not have a division by zero error.
if (dot <= 0)
continue;

// This scoring function has two priorities:
// - Score higher for positions that are closer.
// - Score higher for positions that are located in the right direction.
// This scoring function combines both of these criteria.
// It can be seen as this:
// Dot (dir, myVector.normalized) / myVector.magnitude
// The first part equals 1 if the direction of myVector is the same as dir, and 0 if it's orthogonal.
// The second part scores lower the greater the distance is by dividing by the distance.
// The formula below is equivalent but more optimized.
//
// If a given score is chosen, the positions that evaluate to that score will form a circle
// that touches pos and whose center is located along dir. A way to visualize the resulting functionality is this:
// From the position pos, blow up a circular balloon so it grows in the direction of dir.
// The first Selectable whose center the circular balloon touches is the one that's chosen.
float score = dot / myVector.sqrMagnitude;

if (score > maxScore)
{
maxScore = score;
bestPick = sel;
}
}
return bestPick;
}

会遍历s_List,剔除不合要求的,剩余部分中找出得分最高的Selectable。根据代码和注释,看到得分的依据是距离近&夹角小,使用位置矢量的点乘积除以距离的平方值dot / myVector.sqrMagnitude

给定一个矩形和一个矢量,获取从矩形中心沿着矢量方向投射出射线与矩形相交的一点。

GetPointOnRectEdge

1
2
3
4
5
6
7
8
9
private static Vector3 GetPointOnRectEdge(RectTransform rect, Vector2 dir)
{
if (rect == null)
return Vector3.zero;
if (dir != Vector2.zero)
dir /= Mathf.Max(Mathf.Abs(dir.x), Mathf.Abs(dir.y));
dir = rect.rect.center + Vector2.Scale(rect.rect.size, dir * 0.5f);
return dir;
}

本系列其它文章详见Unity3D UGUI 源码学习

REFERENCE

https://docs.unity3d.com/ScriptReference/UI.Selectable.html

https://docs.unity3d.com/ScriptReference/SelectionBaseAttribute.html