Unity UGUI可拖拽圆环进度条:从原理到实现的完整指南

发布时间:2026/8/10 3:19:19
Unity UGUI可拖拽圆环进度条:从原理到实现的完整指南 1. 项目概述与核心价值看到这个标题很多Unity开发者尤其是刚接触UI系统的朋友可能会觉得有点意外进度条不是直接用UGUI自带的Slider组件拖一下就行了吗为什么还要大费周章地自己实现一个圆环的而且还是可拖拽的这正是我想分享这个项目的初衷。在绝大多数教程和日常开发中Slider组件确实是我们实现线性进度或数值调节的首选它简单、高效、开箱即用。但当你需要更贴合游戏风格、更具表现力的UI控件时比如一个科幻主题的能量充能环、一个角色技能冷却的环形指示器或者一个需要用户通过拖拽环形手柄来调节数值的创意设置界面标准的矩形Slider就显得格格不入了。这个项目就是要打破这种“Slider万能”的思维定式。我将手把手带你从零开始完全基于UGUI的原生组件和C#脚本构建一个功能完整、视觉效果专业的可拖拽圆环进度条。它不仅仅是一个静态的显示控件更是一个交互组件用户可以通过拖拽圆环上的“手柄”图标沿着环形轨迹滑动从而实时改变进度值。这种交互方式比拖动一个直线滑块要酷炫和直观得多尤其适合360度循环、角度选择等场景。为什么不用现成的插件首先理解其实现原理是提升你UGUI和Unity底层交互处理能力的最佳实践。其次完全自研意味着你有100%的控制权可以随心所欲地定制外观、交互反馈、动画效果而不用担心插件兼容性或授权问题。最后附带的完整源码将是一个极佳的学习模板和项目素材库你可以直接复用或基于它衍生出更复杂的环形UI控件。2. 核心原理与设计思路拆解在动手写代码之前我们必须把整个圆环进度条的工作原理和设计思路理清楚。一个可拖拽的圆环进度条可以拆解为三个核心部分视觉表现、进度计算和交互处理。2.1 视觉表现如何绘制一个圆环UGUI本身没有提供直接的“圆环”Image类型。我们通常有三种实现方式使用Sprite让美术同学制作一张圆环状的Sprite图片作为UI Image的源。这是最直接、效果最好支持复杂渐变、纹理的方法也是本项目推荐的方式。我们需要两张图一张作为背景静态的圆环轮廓另一张作为填充Filled Image用于显示进度。使用Mask用一个圆形的Sprite作为Image再在其上层叠一个作为进度的矩形Sprite并为矩形Sprite添加一个圆形的Mask组件。通过控制矩形Sprite的宽度或Image的Fill Amount来模拟进度。这种方法对美术资源要求低但锯齿和边缘效果通常不如方式1。使用Shader编写一个自定义的UI Shader通过参数动态绘制圆环。这是最灵活、性能开销可控如果写得好的方法但技术门槛较高。对于大多数项目尤其是追求快速开发和良好视觉效果的情况方式1Sprite Image Fill是最佳平衡点。我们将采用一个Image组件将其Image Type设置为FilledFill Method为Radial 360Fill Origin设为Top从12点钟方向开始填充。这样我们只需要通过脚本控制这个Image的fillAmount属性0到1就能轻松实现环形填充效果。2.2 进度计算角度与数值的映射这是整个组件的数学核心。一个圆环是360度而我们的进度值比如0到100需要与这360度建立映射关系。基础映射很简单当前角度 进度值 * 360度。例如进度50%对应180度。拖拽映射逆向计算当用户拖动手柄时我们需要根据手柄的当前位置一个二维坐标反算出对应的角度再根据角度计算出当前的进度值。这里的关键在于坐标到角度的转换。我们需要知道圆环的中心点在UI的局部坐标系中。计算手柄当前位置相对于圆环中心点的向量。使用Mathf.Atan2(deltaY, deltaX)函数计算该向量与X轴正方向的弧度值。注意Atan2返回的范围是-π到π即-180度到180度且0度指向X轴正方向3点钟方向。我们需要将这个弧度值转换到我们定义的填充起始点例如Top即12点钟方向对应-90度或270度并规整到0到360度的范围。最后将角度值除以360度就得到了归一化的进度值0到1。2.3 交互处理如何实现环形轨迹拖拽这是区别于普通Slider拖拽的关键。我们不能让手柄被随意拖到屏幕任意位置必须将其位置约束在圆环的轨迹上。事件监听为手柄GameObject添加EventTrigger组件或者让手柄上的脚本实现IBeginDragHandler,IDragHandler,IEndDragHandler接口。我们选择接口方式因为它更清晰、性能稍好。坐标转换与约束在OnDrag事件中获取鼠标/触摸的屏幕坐标。使用RectTransformUtility.ScreenPointToLocalPointInRectangle函数将屏幕坐标转换为圆环进度条所在RectTransform下的局部坐标。这一步至关重要它确保了计算基于正确的坐标系。得到手柄目标位置相对于圆环中心的向量后我们不直接设置手柄位置为该向量。而是先通过上述Atan2计算目标角度。然后根据圆环的半径用三角函数重新计算该角度下在圆环轨迹上的准确坐标x centerX radius * Mathf.Cos(angleRad),y centerY radius * Mathf.Sin(angleRad)。将手柄的局部位置设置为这个计算出的坐标。这样无论用户如何拖动手柄都像被“磁吸”在圆环上一样。2.4 整体架构设计我们将创建两个主要的脚本CircularProgressBar.cs挂在圆环填充Image所在的GameObject上。负责管理进度值Value控制填充Image的fillAmount以及对外提供值改变的事件回调例如OnValueChanged。CircularDragHandle.cs挂在与手柄关联的GameObject上。实现拖拽接口在拖拽过程中计算角度和进度并调用CircularProgressBar来更新进度值。同时它负责根据进度值更新自己的位置。两者通过引用进行通信。手柄脚本持有进度条脚本的引用并在拖拽时修改其Value进度条脚本在Value被设置时同时更新填充图的显示和手柄的位置防止通过代码直接修改Value时手柄位置不同步。3. 完整实现步骤与核心代码解析接下来我们进入实战环节。我会一步步带你创建UI元素、编写脚本并解释每一段关键代码。3.1 第一步搭建UI场景结构在Unity中创建一个新的Canvas。在Canvas下创建一个空GameObject命名为CircularProgressBar。为其添加RectTransform组件调整好你期望的大小和位置。这个将作为我们进度条的根节点。在CircularProgressBar下创建两个UI Image子对象BG作为背景圆环。设置其Source Image为你的圆环背景Sprite。将Image Type设置为Simple。Fill作为填充圆环。设置其Source Image为你的填充Sprite通常是一个实心圆环或扇形图。关键设置将Image Type设置为FilledFill Method设置为Radial 360Fill Origin设置为TopFill Amount初始为0。确保填充图的Pivot设置在中心(0.5, 0.5)这样旋转和填充才会正确。在CircularProgressBar下再创建一个UI Image子对象命名为Handle。这是一个小图标比如一个圆形点、一个箭头用于拖拽。将其放置在圆环顶部12点钟方向。确保它的锚点Anchor和轴心点Pivot都设置在中心。现在的层级结构应该是Canvas-CircularProgressBar- (BG,Fill,Handle)。3.2 第二步编写CircularProgressBar核心脚本创建一个C#脚本命名为CircularProgressBar.cs挂载到CircularProgressBar根节点上。using UnityEngine; using UnityEngine.UI; using UnityEngine.Events; [System.Serializable] public class FloatEvent : UnityEventfloat {} public class CircularProgressBar : MonoBehaviour { [Header(UI References)] [SerializeField] private Image fillImage; // 填充图像 [SerializeField] private RectTransform handleRect; // 手柄的RectTransform [Header(Settings)] [Range(0f, 1f)] [SerializeField] private float value 0.5f; // 当前进度值归一化0-1 [SerializeField] private float radius 100f; // 圆环半径以像素为单位基于RectTransform的尺寸 [Header(Events)] public FloatEvent onValueChanged; // 值改变时触发的事件 // 公共属性用于外部获取和设置进度 public float Value { get value; set { if (Mathf.Approximately(this.value, value)) return; // 值无变化则返回 this.value Mathf.Clamp01(value); // 限制在0-1 UpdateVisuals(); // 更新视觉表现 onValueChanged?.Invoke(this.value); // 触发事件 } } public float Radius radius; private void OnValidate() { // 在Inspector中修改值时实时更新预览仅在编辑器下 if (fillImage ! null handleRect ! null) { UpdateVisuals(); } } private void Awake() { // 初始化时更新一次视觉 UpdateVisuals(); } // 核心方法根据当前value更新填充图手柄位置 public void UpdateVisuals() { // 1. 更新填充图的填充量 if (fillImage ! null) { fillImage.fillAmount value; } // 2. 更新手柄位置 if (handleRect ! null) { // 将归一化的value转换为角度从Top开始顺时针 // Fill Origin为Top时0度对应-90度或270度 float angle value * 360f - 90f; // 转换为Unity的数学角度0度在X轴正方向 float angleRad angle * Mathf.Deg2Rad; // 转换为弧度 // 计算手柄在父节点即本脚本挂载的物体局部坐标系中的位置 Vector2 handlePos new Vector2( Mathf.Cos(angleRad) * radius, Mathf.Sin(angleRad) * radius ); handleRect.anchoredPosition handlePos; // 设置手柄位置 } } // 提供一个方法用于从外部如手柄拖拽基于角度直接设置值 public void SetValueFromAngle(float angleInDegrees) { // 将角度规整到0-360范围 float normalizedAngle (angleInDegrees % 360f 360f) % 360f; // 将角度映射到0-1的值。注意我们的填充从Top-90度开始。 // 所以角度-90度对应value0角度270度对应value1。 // 计算相对于-90度的偏移角度并归一化。 float offsetAngle normalizedAngle 90f; // 现在0度对应value0.25 (90/360) Value offsetAngle / 360f; } }代码解析与注意事项FloatEvent自定义的UnityEvent用于方便地在Inspector中绑定值改变时的回调函数。OnValidate这个方法在Inspector中修改任何值后调用。它让我们在编辑模式下就能实时看到进度条和手柄位置的变化非常方便调试和设置。UpdateVisuals这是视觉同步的核心。它确保value、fillAmount和handle位置三者一致。角度计算float angle value * 360f - 90f;这一步是关键。因为我们将填充起点Fill Origin设为Top在Unity的Fill组件逻辑里fillAmount为0时填充的起始边是上方。而在我们以X轴正方向为0度的数学坐标系里上方是90度。所以需要减去90度来对齐。你可以通过修改这里的计算来改变填充的起始方向。SetValueFromAngle这个方法是为拖拽手柄脚本准备的。它接收一个世界通用的角度值0-360度并将其转换为我们内部的value。3.3 第三步编写CircularDragHandle拖拽脚本创建另一个C#脚本命名为CircularDragHandle.cs挂载到Handle游戏对象上。using UnityEngine; using UnityEngine.EventSystems; public class CircularDragHandle : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { [SerializeField] private CircularProgressBar progressBar; // 关联的进度条组件 [SerializeField] private RectTransform baseRect; // 进度条根节点的RectTransform用于坐标转换 private void Awake() { // 如果未手动指定尝试自动查找 if (progressBar null) progressBar GetComponentInParentCircularProgressBar(); if (baseRect null progressBar ! null) baseRect progressBar.GetComponentRectTransform(); } public void OnBeginDrag(PointerEventData eventData) { // 拖拽开始可以在这里添加一些反馈比如放大手柄 // Debug.Log(开始拖拽圆环手柄); } public void OnDrag(PointerEventData eventData) { if (progressBar null || baseRect null) { Debug.LogWarning(CircularDragHandle: ProgressBar 或 BaseRect 未赋值); return; } // 1. 将屏幕坐标转换到进度条(BaseRect)的局部坐标空间 Vector2 localCursor; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( baseRect, eventData.position, eventData.pressEventCamera, out localCursor)) { return; // 转换失败可能点击在区域外 } // 2. 计算局部坐标相对于圆心的向量 // 假设圆心就在BaseRect的中心 (0,0)因为我们的填充图和手柄都以中心为轴心。 Vector2 direction localCursor; // 因为圆心在(0,0)所以向量就是坐标本身 // 3. 计算向量的角度弧度 float angleRad Mathf.Atan2(direction.y, direction.x); float angleDeg angleRad * Mathf.Rad2Deg; // 转换为角度 // 4. 将角度传递给进度条让其更新值 progressBar.SetValueFromAngle(angleDeg); } public void OnEndDrag(PointerEventData eventData) { // 拖拽结束可以在这里恢复手柄的反馈效果 // Debug.Log(结束拖拽圆环手柄); } }代码解析与避坑指南坐标转换RectTransformUtility.ScreenPointToLocalPointInRectangle是整个交互正确性的基石。它把鼠标的屏幕坐标转换到指定的baseRect即我们的进度条根节点的局部坐标系中。这意味着转换后的localCursor的(0,0)点是baseRect的中心如果其轴心在中心。这正是我们需要的因为我们把圆环视觉中心也放在了baseRect的中心。圆心假设代码中我们假设圆心在baseRect的局部坐标(0,0)点。这要求CircularProgressBar根节点、FillImage、Handle的RectTransform的轴心点(Pivot)都设置为(0.5, 0.5)。务必在Unity编辑器中检查并确认这一点否则计算会完全错误。角度计算Mathf.Atan2(y, x)返回的是从X轴正方向逆时针旋转到向量(x, y)的弧度。这个角度范围是-π到π-180到180度。这个角度体系是标准的数学体系我们直接将其传递给progressBar.SetValueFromAngle由进度条脚本负责将其转换到自己的填充起始坐标系。自动查找Awake方法中尝试自动查找关联的组件提升了脚本的易用性。但最佳实践还是在Inspector中手动拖拽赋值更稳定明确。3.4 第四步场景组装与配置回到Unity编辑器选中CircularProgressBar根节点。将CircularProgressBar脚本中Fill Image字段拖拽赋值给Fill对象将Handle Rect字段拖拽赋值给Handle对象。调整Radius值。这个半径应该与你的Fill图像的实际显示半径匹配单位是像素相对于父节点RectTransform。你可以先大致设置一个值然后运行游戏拖拽手柄看手柄是否精确贴在圆环边缘再微调这个半径值。选中Handle对象将其CircularDragHandle脚本中的Progress Bar字段拖拽赋值给CircularProgressBar根节点将Base Rect字段也拖拽赋值给CircularProgressBar根节点。关键步骤确保所有相关RectTransform的轴心点(Pivot)都是(0.5, 0.5)。检查对象CircularProgressBar、Fill、Handle。为CircularProgressBar脚本的On Value Changed事件添加一个测试监听。点击事件面板的拖拽一个对象比如一个Text组件然后选择函数例如Text.SetFloat需要自定义一个包装方法或监听事件在脚本中打印日志以测试值变化是否触发。现在运行游戏你应该可以拖动圆环上的手柄圆环填充部分会随之变化并且Value值会在0到1之间变动。4. 功能增强与实战优化基础功能已经实现但一个健壮、好用的控件还需要更多细节。下面分享几个我在实际项目中总结的增强点和优化技巧。4.1 添加步进与数值格式化很多时候我们不需要连续的值而是需要像音量调节那样每10%一个档位。或者我们需要显示具体的数值如“75%”。// 在CircularProgressBar类中添加 [Header(Advanced Settings)] [SerializeField] private bool useSteps false; [SerializeField] private float step 0.1f; // 每一步的增量 [SerializeField] private bool showValueText false; [SerializeField] private Text valueText; // 用于显示数值的UI Text [SerializeField] private string valueFormat P0; // 格式字符串如“P0”表示百分比无小数“N1”表示一位小数 // 修改Value属性的setter public float Value { get value; set { float newValue Mathf.Clamp01(value); if (useSteps step 0) { // 计算最接近的步进步长 float steppedValue Mathf.Round(newValue / step) * step; // 防止浮点数精度问题导致超出1 newValue Mathf.Clamp01(steppedValue); } if (Mathf.Approximately(this.value, newValue)) return; this.value newValue; UpdateVisuals(); UpdateValueText(); // 更新文本显示 onValueChanged?.Invoke(this.value); } } private void UpdateValueText() { if (showValueText valueText ! null) { valueText.text this.value.ToString(valueFormat); // 例如value0.75, formatP0 - 75 % // format0.00 - 0.75 } }4.2 拖拽灵敏度与边界处理直接计算角度有时在圆心附近会过于敏感或者当鼠标快速划过圆心时角度会发生180度跳变。我们可以添加一个“死区”半径。// 在CircularDragHandle类中添加 [SerializeField] private float deadZoneRadius 10f; // 像素单位圆心附近该区域内拖拽不生效 public void OnDrag(PointerEventData eventData) { // ... 坐标转换代码同上 ... Vector2 direction localCursor; // 检查死区如果拖拽点太靠近圆心忽略本次操作保持上一次角度 if (direction.magnitude deadZoneRadius) { return; } float angleRad Mathf.Atan2(direction.y, direction.x); float angleDeg angleRad * Mathf.Rad2Deg; progressBar.SetValueFromAngle(angleDeg); }4.3 平滑动画与视觉反馈让进度填充和手柄移动带有缓动动画能极大提升用户体验。// 在CircularProgressBar类中添加 [SerializeField] private bool useSmoothing false; [SerializeField] private float smoothTime 0.1f; // 平滑时间 private float currentSmoothVelocity; // 当前平滑速度 private void Update() { if (useSmoothing !Mathf.Approximately(fillImage.fillAmount, value)) { // 使用Mathf.SmoothDamp平滑过渡fillAmount float smoothedFill Mathf.SmoothDamp(fillImage.fillAmount, value, ref currentSmoothVelocity, smoothTime); fillImage.fillAmount smoothedFill; // 手柄位置也需要平滑更新可以基于平滑后的fillAmount重新计算角度和位置 // 注意为了性能可以不必每帧都计算三角函数或者将平滑逻辑也应用到手柄位置计算上。 // 一种简单但效果不错的方法是直接根据平滑后的fillAmount更新手柄位置。 if (handleRect ! null) { float angle smoothedFill * 360f - 90f; float angleRad angle * Mathf.Deg2Rad; Vector2 handlePos new Vector2( Mathf.Cos(angleRad) * radius, Mathf.Sin(angleRad) * radius ); handleRect.anchoredPosition handlePos; } } } // 同时需要修改UpdateVisuals在启用平滑时不要立即更新fillAmount和手柄位置。 public void UpdateVisuals() { if (!useSmoothing) { // 原有的立即更新逻辑 if (fillImage ! null) fillImage.fillAmount value; UpdateHandlePositionImmediate(); } // 如果启用平滑Update函数会负责渐变动画 UpdateValueText(); } private void UpdateHandlePositionImmediate() { if (handleRect ! null) { float angle value * 360f - 90f; float angleRad angle * Mathf.Deg2Rad; Vector2 handlePos new Vector2(Mathf.Cos(angleRad) * radius, Mathf.Sin(angleRad) * radius); handleRect.anchoredPosition handlePos; } }4.4 完整源码结构与使用示例一个完整的、优化过的控件可能包含以下脚本和结构CircularProgressBar.cs主控制器管理数据、视觉和事件。CircularDragHandle.cs处理拖拽交互。CircularProgressBarEditor.cs可选一个自定义Editor脚本用于在Inspector中绘制更友好的预览。使用示例场景音量控制将圆环进度条的OnValueChanged事件绑定到AudioListener.volume或某个AudioSource的volume属性上。技能冷却指示将Value与技能冷却时间关联fillImage的颜色可以随着冷却变化从灰色到亮色。注意此时可能需要禁用拖拽手柄或将其隐藏。角色创建-角度选择比如调整角色面部朝向。将圆环周围放置方向图标拖拽手柄选择。5. 常见问题排查与性能优化在实际使用中你可能会遇到一些问题。这里列出一个速查表问题现象可能原因解决方案手柄拖拽时跳动或位置不对1.Radius值设置不正确。2. 相关UI元素的轴心点(Pivot)未设置在中心(0.5,0.5)。3.CircularDragHandle中Base Rect赋值错误。1. 微调Radius使其等于填充图可见环形的半径。2. 检查CircularProgressBar、Fill、Handle的RectTransform Pivot。3. 确保Base Rect指向进度条根节点。填充方向不对或起点错误CircularProgressBar.UpdateVisuals()中角度计算公式有误。检查float angle value * 360f - 90f;这行。根据你Fill Image设置的Fill Origin来调整这个偏移量-90对应Top。拖拽时值变化不连续或卡顿1. 事件系统被其他UI元素阻塞。2. 在OnDrag中进行了过于耗时的计算。1. 确保手柄Image的Raycast Target为true且没有被其他全屏透明图片遮挡。2. 确保计算逻辑简洁避免在OnDrag每帧进行复杂查找或IO操作。手柄在圆环外侧或内侧Radius值大于或小于实际视觉半径。调整Radius值。可以在OnValidate中绘制一个Gizmos圆环来辅助调试仅Editor。脚本赋值时报空引用未在Inspector中拖拽赋值且自动查找失败。始终在Inspector中手动拖拽赋值所有公共序列化字段。这是Unity开发的最佳实践。在UI Canvas缩放模式非Constant Pixel Size时位置错乱坐标计算未考虑Canvas的缩放因子。在坐标计算中可能需要将radius乘以Canvas的scaleFactor。更稳健的方法是所有计算都基于归一化的锚定位置或使用RectTransform的尺寸。对于简单的用例保持Canvas缩放模式为Constant Pixel Size最省事。性能优化建议避免每帧计算在CircularProgressBar的Update中做平滑动画时确保只在值实际变化时进行计算。可以使用一个脏标志bool needsUpdate。减少事件触发频率如果OnValueChanged事件绑定了非常耗时的操作可以考虑使用一个协程进行节流Throttling比如每0.1秒最多触发一次或者只在拖拽结束时触发一次。合并绘制调用确保BG、Fill、Handle使用的Sprite都在同一张图集Atlas中这样UGUI可以自动将它们合并为一个绘制调用提升渲染性能。对象池如果你的界面会动态创建和销毁大量此类进度条考虑使用对象池来复用GameObject避免频繁的实例化和垃圾回收。实现这个可拖拽圆环进度条的过程本质上是对UGUI坐标系统、事件系统和简单向量数学的一次深入应用。它比使用标准Slider多了不少步骤但带来的视觉自由度和交互独特性是Slider无法比拟的。希望这份详细的指南和源码能帮你顺利实现它并激发你创建更多独特UI控件的灵感。

相关新闻