前言
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 ; 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_IsOn
的 Set
操作被指定为 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 ){ if (m_Group != null ) m_Group.UnregisterToggle(this ); if (setMemberValue) m_Group = newGroup; if (newGroup != null && IsActive()) newGroup.RegisterToggle(this ); if (newGroup != null && isOn && IsActive()) newGroup.NotifyToggleOn(this ); }
SetToggleGroup()
函数用于设置开关所属的开关组,同时会调用 ToggleGroup
的 RegisterToggle()
函数,将自己注册到开关组中。
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
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 ]); } } 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); 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
,则至少有一个开关处于打开状态。
Slider
Slider
组件由多个部分组成,包括背景、滑块、填充等。相应的配置项也有很多,如滑动条的最小值、最大值、填充方向等。
Slider 的填充原理
Slider
会根据 Fill Area
或 Handle Slide Area
的大小来设置 Fill
以及 Handle
在滑动条上的位置,设置二者位置的方式为改变他们的 anchorMin
和 anchorMax
。
将 Fill Area
以及 Handle Slide Area
的宽度减小,并且拖动 Handle
,注意观察 Fill
以及 Handle
的锚点变化:
可以看到,Fill
的填充方式为根据 RectTransform
的大小进行填充,填充方式为固定 anchorMin
,同时根据滑动条的值来设置 anchorMax
,这就形成了一种填充的效果。
Handle
的填充方式则是同时改变 anchorMin
和 anchorMax
,使得 Handle
能够在滑动条上移动,而不是像 Fill
一样发生形变。
Slider 的交互过程
Slider
中主要实现了三个接口,分别是IPointerDownHandler
、IInitializePotentialDragHandler
、IDragHandler
以及 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{ LeftToRight, RightToLeft, BottomToTop, 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 { UpdateDrag(eventData, eventData.pressEventCamera); } }
默认条件下 m_Offset
为 Vector2.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()
函数中,会将二者分别设置为 FillRect
和 HandleRect
的父级 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()
函数中,会分别设置 Fill
和 Handle
的 anchorMin
和 anchorMax
,从而实现滑动条的显示,和上文中演示的效果一致。
这样就完成了一次完整的更新过程。
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。