UGUI源码剖析(16):实战——从零构建一个RadialSlider
UGUI源码剖析(第十六章):实战——从零构建一个RadialSlider
在前面的章节中,我们已经深入解剖了UGUI的几乎所有核心组件。现在,是时候将所有这些知识融会贯通,进行一次最全面的实战了。我们将从一个空白的MonoBehaviour脚本开始,不继承Selectable或Button,而是通过直接实现EventSystem的事件接口,来亲手构建一个全新的、功能完备的自定义交互控件——RadialSlider(径向滑块)。
这个控件,将把我们之前学到的Image的Filled模式、EventSystem的事件处理、以及RectTransformUtility的坐标转换等所有知识点,全部串联起来。
1. 需求分析与基础框架
我们的RadialSlider需要具备以下功能:
- 视觉表现:使用一个Image组件,并将其Type设置为Filled,FillMethod设置为Radial360,来显示一个圆形的进度填充。
- 颜色渐变:填充区域的颜色,能根据填充比例,在起始色和结束色之间平滑渐变。
- 交互:玩家可以通过在控件上按下并拖动鼠标/触摸,来改变其填充角度。
- 数据接口:对外暴露Angle(0-360)和Value(0-1)属性,用于通过代码控制滑块,并提供onValueChanged事件,在值变化时通知外部。
- 平滑过渡:提供一个Lerp模式,使得通过代码设置值时,滑块能平滑地动画到目标角度。
1.1 创建基础脚本
我们创建一个名为RadialSlider.cs的脚本。这次,我们只继承MonoBehaviour,并手动实现所有需要的事件接口。
// RadialSlider.cs
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using System;// 我们将直接实现所有需要的指针事件接口
[RequireComponent(typeof(Image))]
public class RadialSlider : MonoBehaviour, IPointerEnterHandler, IPointerDownHandler, IPointerUpHandler, IDragHandler
{// --- 核心引用与状态 ---private Image m_Image; // 对Image组件的引用private bool isPointerDown = false;private Vector2 m_LocalPointerPos;private Camera m_EventCamera;// --- 公共属性与事件 ---[SerializeField] private Color m_StartColor = Color.green;[SerializeField] private Color m_EndColor = Color.red;[Serializable]public class RadialSliderValueChangedEvent : UnityEvent<float> {}[SerializeField]private RadialSliderValueChangedEvent m_OnValueChanged = new RadialSliderValueChangedEvent();public RadialSliderValueChangedEvent onValueChanged { get { return m_OnValueChanged; } set { m_OnValueChanged = value; } }// Awake中进行初始化private void Awake(){m_Image = GetComponent<Image>();// 强制Image组件设置为我们需要的模式if (m_Image.type != Image.Type.Filled || m_Image.fillMethod != Image.FillMethod.Radial360){m_Image.type = Image.Type.Filled;m_Image.fillMethod = Image.FillMethod.Radial360;Debug.LogWarning("RadialSlider: Image component's type and fillMethod have been automatically set.", this);}}// ... 后续将在这里实现核心逻辑 ...
}
2. 核心算法:从指针位置到角度的转换
RadialSlider最核心的算法,在于如何将一个在RectTransform矩形内的2D笛卡尔坐标(x, y),转换为一个0到1之间的归一化角度值。这需要借助三角函数Mathf.Atan2。
// RadialSlider.cs
private float GetValueFromPointerPosition(Vector2 screenPosition)
{// 1. 将屏幕坐标,转换为RectTransform的本地坐标// RectTransformUtility是我们之前分析过的核心工具类if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(transform as RectTransform, screenPosition, m_EventCamera, out m_LocalPointerPos)){return Value; // 如果转换失败,则返回当前值}// 2. 将本地坐标的原点,从轴心(pivot)移到矩形中心// Atan2需要以(0,0)为中心点来计算角度,但localPos的原点是pivotRectTransform rect = transform as RectTransform;Vector2 pivotOffset = new Vector2(rect.rect.width * (rect.pivot.x - 0.5f), rect.rect.height * (rect.pivot.y - 0.5f));Vector2 centerRelativePos = m_LocalPointerPos - pivotOffset;// 3. 使用Atan2计算角度// Atan2返回的是弧度,范围是 -PI 到 PI// 我们需要将其转换为0-360度的角度,再归一化到0-1// Mathf.Atan2(y, x)float angleRad = Mathf.Atan2(centerRelativePos.y, centerRelativePos.x);// 4. 转换与归一化// 将弧度转为度,范围 -180 到 180float angleDeg = angleRad * Mathf.Rad2Deg;// 将角度范围调整到 0 到 360 (UGUI的Radial360默认以右侧为0度,顺时针增加)if (angleDeg < 0) angleDeg += 360f;// 在UGUI的Radial360中,fillOrigin=Right是0度,Top是90度// Atan2中,右侧是0度,上方是90度,这与UGUI的默认行为不完全一致// 为了简单,我们先假设fillOrigin=Bottom,然后进行旋转// 假设从底部开始,顺时针旋转angleDeg = (angleDeg + 90f) % 360f;// 最终归一化到0-1return angleDeg / 360f;
}
- 坐标转换是基础:RectTransformUtility.ScreenPointToLocalPointInRectangle是所有UI交互计算的第一步。
- Atan2是核心:Mathf.Atan2(y, x)函数接收一个点的Y和X坐标,并返回该点与原点连线,相对于X轴正方向的角度(以弧度为单位)。这是从2D坐标计算角度的标准数学方法。
- 坐标系对齐:Atan2的计算是围绕(0,0)点的,而ScreenPointToLocalPointInRectangle返回的localPos是以pivot为原点的。因此,我们必须先进行一次坐标系平移,将坐标转换为以矩形中心为原点。
- 角度与归一化:Atan2返回的弧度需要通过Mathf.Rad2Deg转换为度,并经过一系列的偏移和取模运算,来与Image.fillMethod所期望的0-360度范围和起始方向对齐。
3. 视觉与数据的联动:UpdateVisuals
我们需要一个方法,来根据一个0-1的Value,更新Image的fillAmount和颜色。
// RadialSlider.cs
private void UpdateVisuals(float value)
{// 约束value在0-1之间value = Mathf.Clamp01(value);// 更新Image的填充量m_Image.fillAmount = value;// 根据value,在起始色和结束色之间插值m_Image.color = Color.Lerp(m_StartColor, m_EndColor, value);
}
4. 事件处理:将一切串联起来
现在,我们来实现EventSystem的事件接口,将算法、视觉更新和用户输入串联起来。
// RadialSlider.cs
public float Value
{get { return m_Image.fillAmount; }set { SetValue(value); }
}public float Angle
{get { return Value * 360f; }set { Value = value / 360f; }
}// 统一的值设置入口
private void SetValue(float value, bool sendCallback = true)
{value = Mathf.Clamp01(value);if (Mathf.Approximately(Value, value)) return;UpdateVisuals(value);if (sendCallback){m_OnValueChanged.Invoke(Value);}
}public void OnPointerEnter(PointerEventData eventData)
{m_EventCamera = eventData.pressEventCamera; // 缓存相机
}public void OnPointerDown(PointerEventData eventData)
{isPointerDown = true;m_EventCamera = eventData.pressEventCamera;OnDrag(eventData); // 按下时,也应该立即更新一次值
}public void OnPointerUp(PointerEventData eventData)
{isPointerDown = false;
}public void OnDrag(PointerEventData eventData)
{if (isPointerDown){float newValue = GetValueFromPointerPosition(eventData.position);SetValue(newValue);}
}
- IPointerEnterHandler: 我们在OnPointerEnter中,缓存了eventCamera。这是因为在OnDrag事件中,eventData.enterEventCamera可能为空,而pressEventCamera才是可靠的。
- IPointerDownHandler: 当指针按下时,我们将isPointerDown设为true,并立即调用一次OnDrag的逻辑,让滑块能够“指哪打哪”,而不是必须拖动后才有响应。
- IDragHandler: 这是核心的交互逻辑。只要指针处于按下状态,OnDrag就会在每一帧被调用。我们在这里,持续地调用GetValueFromPointerPosition来获取最新的角度值,并通过SetValue来更新滑块。
- 统一的SetValue入口: 无论是外部代码通过Value属性设置,还是用户通过拖拽,所有值的变更,最终都会汇集到SetValue这个方法。这确保了值的约束、视觉的更新和事件的回调,永远是以一个统一、正确的流程来执行的。

加入组件
总结:

运行拖拽显示效果
通过这次从零到一的实战,我们成功地构建了一个功能完备的RadialSlider自定义控件。这次实践,是对我们之前所有源码剖析知识的一次综合运用:
- 我们利用了对EventSystem事件接口的深刻理解,来精确地捕捉和响应用户的输入。
- 我们使用了RectTransformUtility来进行核心的坐标系转换。
- 我们掌握了如何通过三角函数,来处理复杂的2D几何与角度计算。
这个过程,展示了UGUI基于组件和接口的设计,所带来的强大可扩展性。它告诉我们,掌握了它的底层原理,我们就能突破其内置组件的限制,创造出任何我们想要的、富有想象力的交互体验。
附完整代码:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using System;[RequireComponent(typeof(Image))]
public class RadialSlider : MonoBehaviour, IPointerEnterHandler, IPointerDownHandler, IPointerUpHandler, IDragHandler
{[Header("视觉表现 (Visuals)")][Tooltip("填充区域从0%到100%的颜色渐变")][SerializeField] private Color m_StartColor = Color.green;[SerializeField] private Color m_EndColor = Color.red;/// <summary>/// 当滑块的值发生变化时触发的事件。/// 参数为0到1之间的归一化值。/// </summary>[Serializable]public class RadialSliderValueChangedEvent : UnityEvent<float> { }[Header("事件回调 (Events)")][SerializeField]private RadialSliderValueChangedEvent m_OnValueChanged = new RadialSliderValueChangedEvent();public RadialSliderValueChangedEvent onValueChanged { get { return m_OnValueChanged; } set { m_OnValueChanged = value; } }/// <summary>/// 获取或设置滑块的当前值(范围 0.0f 到 1.0f)。/// </summary>public float Value{get{// 确保Image组件有效if (m_Image == null) return 0f;return m_Image.fillAmount;}set{SetValue(value);}}/// <summary>/// 获取或设置滑块的当前角度(范围 0 到 360)。/// </summary>public float Angle{get { return Value * 360f; }set { Value = value / 360f; }}private Image m_Image;private RectTransform m_RectTransform;private bool m_IsPointerDown = false;private Camera m_EventCamera;private void Awake(){m_Image = GetComponent<Image>();m_RectTransform = transform as RectTransform;// 在初始化时,强制将Image组件设置为我们需要的模式,以提供更好的用户体验if (m_Image.type != Image.Type.Filled || m_Image.fillMethod != Image.FillMethod.Radial360){m_Image.type = Image.Type.Filled;m_Image.fillMethod = Image.FillMethod.Radial360;Debug.LogWarning("RadialSlider: Image component's type and fillMethod have been automatically set to Filled and Radial360.", this);}// 通常,径向滑块的填充起点应该是固定的,例如顶部m_Image.fillOrigin = (int)Image.Origin360.Top;m_Image.fillClockwise = true; // 通常是顺时针填充}/// <summary>/// 统一的值设置入口,负责约束值、更新视觉和触发回调。/// </summary>/// <param name="value">要设置的0-1之间的值</param>/// <param name="sendCallback">是否触发onValueChanged事件</param>private void SetValue(float value, bool sendCallback = true){value = Mathf.Clamp01(value);// 如果值没有发生足够大的变化,则不进行任何操作,以优化性能if (Mathf.Approximately(Value, value))return;UpdateVisuals(value);if (sendCallback){m_OnValueChanged.Invoke(Value);}}/// <summary>/// 根据给定的0-1值,更新Image的fillAmount和颜色。/// </summary>private void UpdateVisuals(float value){if (m_Image == null) return;m_Image.fillAmount = value;m_Image.color = Color.Lerp(m_StartColor, m_EndColor, value);}/// <summary>/// 核心算法:将屏幕坐标转换为0-1的归一化角度值。/// </summary>private float GetValueFromPointerPosition(Vector2 screenPosition){Vector2 localPointerPos;// 使用RectTransformUtility将屏幕坐标转换为RectTransform的本地坐标if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(m_RectTransform, screenPosition, m_EventCamera, out localPointerPos)){// 如果转换失败(例如指针在矩形外),则返回当前值以避免跳变return Value;}// Atan2函数需要以(0,0)为中心点来计算角度,但localPointerPos的原点是pivot。// 我们需要先进行一次坐标系平移,将坐标转换为以矩形几何中心为原点。Vector2 pivotOffset = m_RectTransform.rect.size * (m_RectTransform.pivot - new Vector2(0.5f, 0.5f));Vector2 centerRelativePos = localPointerPos + pivotOffset;// 使用Atan2计算角度。它返回的是弧度,范围是 -PI 到 PI。// Y轴在前,X轴在后,这是标准的数学库用法。float angleRad = Mathf.Atan2(centerRelativePos.y, centerRelativePos.x);// 将弧度转换为度,范围 -180 到 180。float angleDeg = angleRad * Mathf.Rad2Deg;// 将角度范围调整到 0 到 360。if (angleDeg < 0){angleDeg += 360f;}// UGUI Image.FillMethod.Radial360的0度位置,取决于fillOrigin的设置。// 为了与我们Awake中设置的fillOrigin = Top (90度) 对应,我们需要进行一次旋转校正。// Atan2的0度在右侧,我们需要将90度(顶部)作为新的0点。angleDeg = (angleDeg - 90f + 360f) % 360f;// 如果是顺时针填充,我们需要反转角度值。if (m_Image.fillClockwise){angleDeg = 360f - angleDeg;}// 最终归一化到0-1return angleDeg / 360f;}/// <summary>/// 当指针进入UI元素区域时调用。/// </summary>public void OnPointerEnter(PointerEventData eventData){// 缓存用于坐标转换的相机。使用pressEventCamera比enterEventCamera更可靠。m_EventCamera = eventData.pressEventCamera;}/// <summary>/// 当指针在UI元素上按下时调用。/// </summary>public void OnPointerDown(PointerEventData eventData){m_IsPointerDown = true;m_EventCamera = eventData.pressEventCamera;// 在按下时,也应该立即响应一次,让滑块能“指哪打哪”。OnDrag(eventData);}/// <summary>/// 当指针抬起时调用。/// </summary>public void OnPointerUp(PointerEventData eventData){m_IsPointerDown = false;}/// <summary>/// 当指针在UI元素上拖动时,每一帧都会调用。/// </summary>public void OnDrag(PointerEventData eventData){if (m_IsPointerDown){float newValue = GetValueFromPointerPosition(eventData.position);SetValue(newValue);}}
}
