当前位置: 首页 > news >正文

UGUI源码剖析(16):实战——从零构建一个RadialSlider

UGUI源码剖析(第十六章):实战——从零构建一个RadialSlider

在前面的章节中,我们已经深入解剖了UGUI的几乎所有核心组件。现在,是时候将所有这些知识融会贯通,进行一次最全面的实战了。我们将从一个空白的MonoBehaviour脚本开始,不继承Selectable或Button,而是通过直接实现EventSystem的事件接口,来亲手构建一个全新的、功能完备的自定义交互控件——RadialSlider(径向滑块)。

这个控件,将把我们之前学到的Image的Filled模式、EventSystem的事件处理、以及RectTransformUtility的坐标转换等所有知识点,全部串联起来。

1. 需求分析与基础框架

我们的RadialSlider需要具备以下功能:

  1. 视觉表现:使用一个Image组件,并将其Type设置为Filled,FillMethod设置为Radial360,来显示一个圆形的进度填充。
  2. 颜色渐变:填充区域的颜色,能根据填充比例,在起始色和结束色之间平滑渐变。
  3. 交互:玩家可以通过在控件上按下并拖动鼠标/触摸,来改变其填充角度。
  4. 数据接口:对外暴露Angle(0-360)和Value(0-1)属性,用于通过代码控制滑块,并提供onValueChanged事件,在值变化时通知外部。
  5. 平滑过渡:提供一个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自定义控件。这次实践,是对我们之前所有源码剖析知识的一次综合运用:
  1. 我们利用了对EventSystem事件接口的深刻理解,来精确地捕捉和响应用户的输入。
  2. 我们使用了RectTransformUtility来进行核心的坐标系转换。
  3. 我们掌握了如何通过三角函数,来处理复杂的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);}}
}
http://www.dtcms.com/a/536992.html

相关文章:

  • 做网站要分几部分完成南京做网站公司哪家好
  • 软件测试和DevOps的关系
  • 【vllm】源码解读:DeepSeekV2 DP Rank 专家加载与分配机制
  • YOLOv5 代码深度解析总结
  • 钓鱼网站的制作教程全球网站排行榜
  • 解决 Codex 在 WSL/SSH/VSCODE 登录时报 “Token exchange failed: 403 Forbidden” 问题
  • JS逆向——encrypt-labs实现爆破登录
  • 扬中网站建设流程如何运营好一个网站
  • 公司网站推广计划书wordpress页面新建不了
  • 防爆手机与普通的区别:应用场景、功能、未来发展
  • 阿里云可以做网站wordpress自动加载
  • IGS 转换为 3DXML 全流程:迪威模型网在线实操 + 本地方案指南
  • 【论文精读】VBench:视频生成模型的全方位评估基准套件
  • jsp网站开发模式wap自助建论坛网站
  • WEBSTORM前端 —— 第5章:Web APIs —— 第6节:正则阶段案例
  • 长春 万网 网站建设千年之恋网页设计代码
  • h5游戏免费下载:保护鸡蛋
  • node+pupeteer使用socks5作为代理协议
  • 光亚鸿道全资子公司科东软件通过2025专精特新 “小巨人” 企业认定
  • 北京做养生SPA的网站建设免费自创网站
  • 舟山网站建设有哪些网站建立分站
  • 【AI论文】大型语言模型(LLM)推理中连接内部概率与自洽性的理论研究
  • 数据结构——堆排序
  • 文档处理控件Aspose.Words教程:Python将Markdown转换为Word
  • 智能体综述:探索基于大型语言模型的智能体:定义、方法与前景
  • 第 16 天:安全、防火墙与系统强化
  • 厦门市建设区网站首页163企业邮箱下载
  • 用手机建立网站做网站植入广告赚钱
  • Spring Boot3零基础教程,HttpInterface,笔记75
  • 南宁网站建设服务加盟策划公司