前言

Toggle (开关)与 ToggleGroup (开关组)经常被用于 UI 界面的各种选项操作中,前者多用于开启或关闭某项功能,而后者则用于从多个选项中选择一项。而 Slider (滑动条)则多用于连续的数值调节中。

本文将分析这些组件的实现。

Toggle

事件的触发

Button 类似,Toggle 也支持在点击时触发事件,但开关具有两个状态,因此 Toggle 绑定的也是 UnityEvent<bool> 类型的事件。

1
2
3
public class ToggleEvent : UnityEvent<bool> {}

public ToggleEvent onValueChanged = new ToggleEvent();

Set() 函数中,会根据开关的状态以及配置来触发事件。此外,Set() 中还调用了 PlayEffect() 函数,用于设置 CheckMark 的显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Set(bool value, bool sendCallback = true)
{
if (m_IsOn == value)
return;

// if we are in a group and set to true, do group logic
m_IsOn = value;
if (m_Group != null && IsActive())
{
if (m_IsOn || (!m_Group.AnyTogglesOn() && !m_Group.allowSwitchOff))
{
m_IsOn = true;
m_Group.NotifyToggleOn(this, sendCallback);
}
}

PlayEffect(toggleTransition == ToggleTransition.None);
if (sendCallback)
{
UISystemProfilerApi.AddMarker("Toggle.value", this);
onValueChanged.Invoke(m_IsOn);
}
}

m_IsOnSet 操作被指定为 Set() 函数,这样在设置该值的时候就能触发对应的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public bool isOn
{
get { return m_IsOn; }

set
{
Set(value);
}
}

private void InternalToggle()
{
if (!IsActive() || !IsInteractable())
return;

isOn = !isOn;
}

IClickHandler 以及 ISubmitHandler 接口的函数都会调用 InternalToggle() 函数,从而触发相应的事件。

1
2
3
4
5
6
7
8
9
10
11
12
public virtual void OnPointerClick(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

InternalToggle();
}

public virtual void OnSubmit(BaseEventData eventData)
{
InternalToggle();
}

与 ToggleGroup 的交互

Toggle 中维护了一个 ToggleGroup 类型的变量 m_Group,用于标识当前的开关是否属于某个开关组。在上述的 Set() 函数中我们也可以看到,如果开关属于一个开关组则会额外调用 m_Group.NotifyToggleOn() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void SetToggleGroup(ToggleGroup newGroup, bool setMemberValue)
{
// Sometimes IsActive returns false in OnDisable so don't check for it.
// Rather remove the toggle too often than too little.
if (m_Group != null)
m_Group.UnregisterToggle(this);

// At runtime the group variable should be set but not when calling this method from OnEnable or OnDisable.
// That's why we use the setMemberValue parameter.
if (setMemberValue)
m_Group = newGroup;

// Only register to the new group if this Toggle is active.
if (newGroup != null && IsActive())
newGroup.RegisterToggle(this);

// If we are in a new group, and this toggle is on, notify group.
// Note: Don't refer to m_Group here as it's not guaranteed to have been set.
if (newGroup != null && isOn && IsActive())
newGroup.NotifyToggleOn(this);
}

SetToggleGroup() 函数用于设置开关所属的开关组,同时会调用 ToggleGroupRegisterToggle() 函数,将自己注册到开关组中。

1
2
3
4
5
6
7
8
9
10
11
12
protected override void OnEnable()
{
base.OnEnable();
SetToggleGroup(m_Group, false);
PlayEffect(true);
}

protected override void OnDisable()
{
SetToggleGroup(null, false);
base.OnDisable();
}

OnEnable()OnDisable() 函数中,会调用 SetToggleGroup() 函数,将自己注册到开关组或从中移除。

ToggleGroup

toggle.gif

ToggleGroup 用于管理一组开关,确保在一组开关中只有一个开关处于开启状态。ToggleGroup 中维护了一个 List<Toggle> 类型的变量 m_Toggles,用于存储当前组中的所有开关。

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
private List<Toggle> m_Toggles = new List<Toggle>();

public void UnregisterToggle(Toggle toggle)
{
if (m_Toggles.Contains(toggle))
m_Toggles.Remove(toggle);

if (!allowSwitchOff && !AnyTogglesOn() && m_Toggles.Count != 0)
{
m_Toggles[0].isOn = true;
NotifyToggleOn(m_Toggles[0]);
}
}

/// <param name="toggle">The toggle to register with the group.</param>
public void RegisterToggle(Toggle toggle)
{
if (!m_Toggles.Contains(toggle))
m_Toggles.Add(toggle);

if (!allowSwitchOff && !AnyTogglesOn())
{
toggle.isOn = true;
NotifyToggleOn(toggle);
}
}

RegisterToggle()UnregisterToggle() 函数分别用于注册及移除开关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void NotifyToggleOn(Toggle toggle, bool sendCallback = true)
{
ValidateToggleIsInGroup(toggle);
// disable all toggles in the group
for (var i = 0; i < m_Toggles.Count; i++)
{
if (m_Toggles[i] == toggle)
continue;

if (sendCallback)
m_Toggles[i].isOn = false;
else
m_Toggles[i].SetIsOnWithoutNotify(false);
}
}

NotifyToggleOn() 函数用于通知开关组中的其他开关,当前开关已经被打开,需要关闭其他开关。

1
[SerializeField] private bool m_AllowSwitchOff = false;

该属性用于控制是否允许关闭所有开关,如果设置为 false,则至少有一个开关处于打开状态。

togglegroup.gif

Slider

Slider 组件由多个部分组成,包括背景、滑块、填充等。相应的配置项也有很多,如滑动条的最小值、最大值、填充方向等。

Slider 的填充原理

Slider 会根据 Fill AreaHandle Slide Area 的大小来设置 Fill 以及 Handle 在滑动条上的位置,设置二者位置的方式为改变他们的 anchorMinanchorMax

Fill Area 以及 Handle Slide Area 的宽度减小,并且拖动 Handle,注意观察 Fill 以及 Handle 的锚点变化:

slider2.gif

可以看到,Fill 的填充方式为根据 RectTransform 的大小进行填充,填充方式为固定 anchorMin ,同时根据滑动条的值来设置 anchorMax,这就形成了一种填充的效果。

silder1.gif

Handle 的填充方式则是同时改变 anchorMinanchorMax,使得 Handle 能够在滑动条上移动,而不是像 Fill 一样发生形变。

Slider 的交互过程

Slider 中主要实现了三个接口,分别是IPointerDownHandlerIInitializePotentialDragHandlerIDragHandler 以及 IMoveHandler。其中,前三个接口用于鼠标拖动时的处理,而后一个接口用于键盘或手柄的处理。

针对填充方向的处理

Slider 支持四种填充方向,定义在 Direction 枚举中,根据 m_Direction 的值会计算得到 axis 以及 reverseValue,根据这二者便可以方便地设置填充比例以及方向。

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
public enum Direction
{
/// <summary>
/// From the left to the right
/// </summary>
LeftToRight,

/// <summary>
/// From the right to the left
/// </summary>
RightToLeft,

/// <summary>
/// From the bottom to the top.
/// </summary>
BottomToTop,

/// <summary>
/// From the top to the bottom.
/// </summary>
TopToBottom,
}

Axis axis { get { return (m_Direction == Direction.LeftToRight || m_Direction == Direction.RightToLeft) ? Axis.Horizontal : Axis.Vertical; } }
bool reverseValue { get { return m_Direction == Direction.RightToLeft || m_Direction == Direction.TopToBottom; } }

OnPointerDown()

OnPointerDown() 函数用于处理鼠标点击时的操作,主要是计算点击位置与滑动条的位置关系,以及设置滑动条的值。

对于点击操作,Silder 有两种处理:如果点击的是 Handle,则会直接设置 m_Offset 为点击位置与 Handle 位置的差值;如果点击区域在 Handle 之外,则会直接将滑动条设置为对应位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public override void OnPointerDown(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

base.OnPointerDown(eventData);

m_Offset = Vector2.zero;
if (m_HandleContainerRect != null && RectTransformUtility.RectangleContainsScreenPoint(m_HandleRect, eventData.position, eventData.enterEventCamera))
{
Vector2 localMousePos;
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(m_HandleRect, eventData.position, eventData.pressEventCamera, out localMousePos))
m_Offset = localMousePos;
}
else
{
// Outside the slider handle - jump to this point instead
UpdateDrag(eventData, eventData.pressEventCamera);
}
}

默认条件下 m_OffsetVector2.zero,如果点击的是 Handle,则会计算点击位置与 Handle 位置的差值,这样在拖动时就能保持 Handle 与鼠标的相对位置不变。

OnDrag()

OnDrag() 函数用于处理拖动时的操作,主要是计算拖动的距离,并根据距离设置滑动条的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public virtual void OnDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;
UpdateDrag(eventData, eventData.pressEventCamera);
}

void UpdateDrag(PointerEventData eventData, Camera cam)
{
RectTransform clickRect = m_HandleContainerRect ?? m_FillContainerRect;
if (clickRect != null && clickRect.rect.size[(int)axis] > 0)
{
Vector2 localCursor;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(clickRect, eventData.position, cam, out localCursor))
return;
localCursor -= clickRect.rect.position;

float val = Mathf.Clamp01((localCursor - m_Offset)[(int)axis] / clickRect.rect.size[(int)axis]);
normalizedValue = (reverseValue ? 1f - val : val);
}
}

UpdateDrag() 函数会根据点击位置与滑动条的位置关系,计算出滑动条的值,并设置 normalizedValue。可以看到 m_Offset 就是在这里发挥作用,用于修正真实的拖动位置。

值得注意的是 clickRect 的选择,如果 m_HandleContainerRect 不为空,则会选择 m_HandleContainerRect 作为点击区域,否则选择 m_FillContainerRect。来看看这两个区域的赋值:

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 UpdateCachedReferences()
{
if (m_FillRect && m_FillRect != (RectTransform)transform)
{
m_FillTransform = m_FillRect.transform;
m_FillImage = m_FillRect.GetComponent<Image>();
if (m_FillTransform.parent != null)
m_FillContainerRect = m_FillTransform.parent.GetComponent<RectTransform>();
}
else
{
m_FillRect = null;
m_FillContainerRect = null;
m_FillImage = null;
}

if (m_HandleRect && m_HandleRect != (RectTransform)transform)
{
m_HandleTransform = m_HandleRect.transform;
if (m_HandleTransform.parent != null)
m_HandleContainerRect = m_HandleTransform.parent.GetComponent<RectTransform>();
}
else
{
m_HandleRect = null;
m_HandleContainerRect = null;
}
}

UpdateCachedReferences() 函数中,会将二者分别设置为 FillRectHandleRect 的父级 RectTransform。在 OnEnable()OnDisable() 等函数中会调用该函数,来更新这两个区域的引用。

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
public float normalizedValue
{
get
{
if (Mathf.Approximately(minValue, maxValue))
return 0;
return Mathf.InverseLerp(minValue, maxValue, value);
}
set
{
this.value = Mathf.Lerp(minValue, maxValue, value);
}
}


public virtual float value
{
get
{
if (wholeNumbers)
return Mathf.Round(m_Value);
return m_Value;
}
set
{
Set(value);
}
}

修改了 normalizedValue 的值会引起 value 的变化,从而调用 Set() 函数,在显示上更新滑动条的位置。Set() 中主要起作用的是 UpdateVisuals() 函数。

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
private void UpdateVisuals()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
UpdateCachedReferences();
#endif

m_Tracker.Clear();

if (m_FillContainerRect != null)
{
m_Tracker.Add(this, m_FillRect, DrivenTransformProperties.Anchors);
Vector2 anchorMin = Vector2.zero;
Vector2 anchorMax = Vector2.one;

if (m_FillImage != null && m_FillImage.type == Image.Type.Filled)
{
m_FillImage.fillAmount = normalizedValue;
}
else
{
if (reverseValue)
anchorMin[(int)axis] = 1 - normalizedValue;
else
anchorMax[(int)axis] = normalizedValue;
}

m_FillRect.anchorMin = anchorMin;
m_FillRect.anchorMax = anchorMax;
}

if (m_HandleContainerRect != null)
{
m_Tracker.Add(this, m_HandleRect, DrivenTransformProperties.Anchors);
Vector2 anchorMin = Vector2.zero;
Vector2 anchorMax = Vector2.one;
anchorMin[(int)axis] = anchorMax[(int)axis] = (reverseValue ? (1 - normalizedValue) : normalizedValue);
m_HandleRect.anchorMin = anchorMin;
m_HandleRect.anchorMax = anchorMax;
}
}

UpdateVisuals() 函数中,会分别设置 FillHandleanchorMinanchorMax,从而实现滑动条的显示,和上文中演示的效果一致。

这样就完成了一次完整的更新过程。

OnMove()

OnMove() 函数用于处理键盘或手柄的滑动操作,这些操作没有鼠标拖动这么连续与精确,只能以一个固定的最小步长来移动滑动条。相比之下鼠标的操作是直接通过坐标来计算比例,更加精确。

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
float stepSize { get { return wholeNumbers ? 1 : (maxValue - minValue) * 0.1f; } }

public override void OnMove(AxisEventData eventData)
{
if (!IsActive() || !IsInteractable())
{
base.OnMove(eventData);
return;
}

switch (eventData.moveDir)
{
case MoveDirection.Left:
if (axis == Axis.Horizontal && FindSelectableOnLeft() == null)
Set(reverseValue ? value + stepSize : value - stepSize);
else
base.OnMove(eventData);
break;
case MoveDirection.Right:
if (axis == Axis.Horizontal && FindSelectableOnRight() == null)
Set(reverseValue ? value - stepSize : value + stepSize);
else
base.OnMove(eventData);
break;
case MoveDirection.Up:
if (axis == Axis.Vertical && FindSelectableOnUp() == null)
Set(reverseValue ? value - stepSize : value + stepSize);
else
base.OnMove(eventData);
break;
case MoveDirection.Down:
if (axis == Axis.Vertical && FindSelectableOnDown() == null)
Set(reverseValue ? value + stepSize : value - stepSize);
else
base.OnMove(eventData);
break;
}
}

OnMove() 函数中,会根据 eventData.moveDir 的值来判断移动的方向,然后调用 Set() 函数来设置滑动条的值。默认情况下步长被设置为 0.1 倍的滑动条的范围,如果开启了 wholeNumbers 则步长为 1。