尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity环形布局组件CircleLayout:从原理到实战实现

Unity环形布局组件CircleLayout:从原理到实战实现 1. 项目概述为什么我们需要一个环形布局组件在Unity UI开发中无论是制作游戏内的技能轮盘、角色选择界面还是应用中的圆形菜单、环形进度展示我们经常会遇到一个需求如何让一堆UI元素比如按钮、图标均匀地排列在一个圆环上Unity引擎自带的UI系统提供了强大的自动布局组件比如Horizontal Layout Group水平布局组、Vertical Layout Group垂直布局组和Grid Layout Group网格布局组。这些组件对于处理矩形、网格状的排列得心应手但当你打开它们的属性面板翻来覆去找不到“Radius”半径或者“Start Angle”起始角度这样的参数时你就该明白原生的UI系统并没有为我们准备一个现成的“环形布局”解决方案。这就是CircleLayout组件诞生的背景。它不是一个官方组件而是开发者社区中为了解决特定场景布局问题而广泛使用的一种自定义组件实现。它的核心目标非常明确接管一个GameObject下所有的子物体根据我们设定的半径、起始角度、间隔角度等参数自动将这些子物体计算并摆放到一个圆形轨迹上。想象一下如果你要手动摆放12个技能图标到一个圆环上你需要计算每个图标的位置涉及三角函数Mathf.Sin和Mathf.Cos调整它们的旋转角度使其朝向圆心这个工作量不仅繁琐而且一旦需要调整半径或增减图标数量所有工作都得推倒重来。CircleLayout组件就是把这个计算过程自动化、参数化让你通过拖拽滑块或输入数值就能实时看到布局效果极大提升了开发效率和迭代速度。2. CircleLayout组件的核心设计思路与参数解析一个功能完善的CircleLayout组件其设计思路本质上是将圆形布局的数学原理封装成直观的编辑器属性。我们不需要在每次使用时都去写计算位置的脚本而是通过配置几个关键参数来驱动整个布局。下面我们来拆解这些核心参数理解它们背后的逻辑。2.1 布局控制参数定义圆环的形态这些参数决定了子物体所排列的那个“圆”的基本属性。Radius半径这是最核心的参数定义了从圆心到每个子物体中心点的距离。半径越大圆环就越大子物体之间的视觉距离也会越远。在实现上我们通常以当前GameObject的RectTransform的中心点为圆心即Pivot点。需要注意的是这里的“中心点”是UI的锚点中心如果你的父物体锚点不在中心布局的圆心也会随之偏移。Start Angle起始角度定义了第一个子物体在圆环上的起始位置。在数学上我们通常以X轴正方向即3点钟方向为0度角。Start Angle参数允许我们旋转整个布局的起点。例如设置为90度那么第一个子物体就会出现在12点钟方向Y轴正方向。这个参数对于调整布局的视觉朝向非常有用比如你想让菜单的“默认选中项”朝上就可以通过调整起始角度来实现。Child Alignment子物体对齐方式这个参数决定了子物体自身如何相对于其布局位置进行对齐。它通常是一个枚举类型包含以下几种常见选项Center中心对齐子物体的中心点精确落在计算出的圆环坐标上。这是最常用的方式。Inner向内对齐子物体的“内侧”边缘通常指靠近圆心的一侧贴近圆环。这常用于让所有子物体的内边缘对齐到一个圆上形成更紧凑的环。Outer向外对齐与Inner相反让子物体的外边缘对齐。Angle Step角度步进控制每个子物体之间的角度间隔。如果子物体数量是childCount并且我们希望它们均匀分布那么一个常见的做法是设置Angle Step为360f / childCount。但组件也可以提供一个独立的Angle Step参数允许非均匀布局比如让某几个图标靠得特别近。2.2 子物体控制参数处理每个元素的状态布局不仅要摆位置还要处理好每个子物体自身的方向和顺序。Rotate Children旋转子物体这是一个布尔值参数极其重要。当开启时组件会自动计算每个子物体应有的旋转角度通常是其位置点与圆心连线的法线方向并应用到子物体的rotation上。这样每个子物体比如一个按钮上的文字或图标都会“朝向”圆心视觉效果更自然。对于不需要朝向圆心的物体比如始终朝上的图标则可以关闭此选项。Order排序顺序决定子物体按照什么顺序被排列到圆环上。常见选项有Ascending升序按照子物体在Hierarchy中的顺序从上到下顺时针排列。Descending降序按照Hierarchy中的顺序逆时针排列。Reverse反转先按升序计算位置但实际排列时顺序反转。这个选项可以灵活调整布局的起点和走向。Ignore Inactive忽略未激活物体这是一个实用性很强的参数。在UI界面中我们经常需要动态显示或隐藏部分元素比如未解锁的技能图标显示为灰色或隐藏。如果开启此选项布局计算将只考虑处于激活状态activeSelf true的子物体隐藏的物体不占位置这样布局会自动收紧不会为隐藏的物体留出空白。如果关闭则会为所有子物体包括未激活的预留位置这可能用于预先占位的情况。2.3 性能与更新控制参数Update Mode更新模式布局计算应该在何时触发通常有三种模式OnEnable / Start仅在组件启用或游戏开始时计算一次。适用于静态、不会变化的布局。OnRectTransformDimensionsChange当父物体RectTransform的尺寸改变时如屏幕分辨率变化、UI拉伸自动重新计算布局。这对于响应式UI至关重要。Manual手动不自动更新需要开发者通过调用public void UpdateLayout()或CalculateLayout()方法来手动触发。这能给予开发者最大的控制权避免不必要的性能开销尤其是在子物体动态增删频繁时可以在一次操作完成后手动调用一次更新。Layout Padding布局内边距虽然不是所有实现都有但一个健壮的组件可以考虑添加。它定义了在计算好的布局位置基础上为每个子物体额外添加的偏移量可以用来微调整体布局的紧凑或松散程度。3. 从零实现一个基础CircleLayout组件理解了核心参数后我们现在动手实现一个基础版本的CircleLayout组件。我们将创建一个继承自MonoBehaviour的脚本并为其添加核心的布局计算逻辑。3.1 创建脚本与定义属性首先在Unity中创建一个新的C#脚本命名为CircleLayout。using UnityEngine; using UnityEngine.UI; // 因为可能会用到RectTransform [ExecuteAlways] // 在编辑器模式下也执行方便预览 [RequireComponent(typeof(RectTransform))] // 确保挂载的物体有RectTransform public class CircleLayout : MonoBehaviour { [Header(Layout Settings)] [SerializeField] private float _radius 100f; [SerializeField] private float _startAngle 0f; // 0度指向X轴正方向右 [SerializeField] private bool _rotateChildren true; [SerializeField] private OrderType _order OrderType.Ascending; [SerializeField] private bool _ignoreInactive true; [Header(Update Settings)] [SerializeField] private UpdateMode _updateMode UpdateMode.OnRectTransformDimensionsChange; public float Radius { get _radius; set { _radius value; UpdateLayout(); } } public float StartAngle { get _startAngle; set { _startAngle value; UpdateLayout(); } } // ... 其他属性的getter/setter private RectTransform _rectTransform; private void Awake() { _rectTransform GetComponentRectTransform(); } private void Start() { if (_updateMode UpdateMode.OnStart) UpdateLayout(); } private void OnRectTransformDimensionsChange() { if (_updateMode UpdateMode.OnRectTransformDimensionsChange) UpdateLayout(); } private void OnTransformChildrenChanged() { // 当子物体数量变化时也考虑更新布局 if (_updateMode ! UpdateMode.Manual) UpdateLayout(); } public void UpdateLayout() { // 布局计算的核心逻辑将在这里实现 CalculateLayout(); } private void CalculateLayout() { // 具体计算实现 } // 枚举定义 public enum OrderType { Ascending, Descending, Reverse } public enum UpdateMode { Manual, OnStart, OnRectTransformDimensionsChange } }注意[ExecuteAlways]属性让脚本在编辑器模式下也能运行Update、Start等方法这对于在Scene视图实时预览布局变化至关重要。但要注意在编辑器脚本中频繁调用UpdateLayout可能影响性能更优的做法是使用EditorApplication.delayCall或在属性setter中标记脏标记在下一帧更新。3.2 实现核心布局算法现在我们来填充CalculateLayout方法。算法的核心是遍历所有子物体根据索引计算其在圆环上的角度然后通过三角函数转换为本地坐标位置。private void CalculateLayout() { if (_rectTransform null) return; // 1. 收集需要布局的子物体 var children new ListRectTransform(); for (int i 0; i _rectTransform.childCount; i) { var child _rectTransform.GetChild(i) as RectTransform; if (child null || (_ignoreInactive !child.gameObject.activeSelf)) continue; children.Add(child); } if (children.Count 0) return; // 2. 处理排序顺序 switch (_order) { case OrderType.Descending: children.Reverse(); // 直接反转列表 break; case OrderType.Reverse: // Reverse顺序通常指位置计算后反向赋值这里我们先按升序计算后面处理 break; // Ascending 保持原样 } int itemCount children.Count; float angleStep 360f / itemCount; // 均匀分布 // 3. 遍历并计算每个子物体的位置和旋转 for (int i 0; i itemCount; i) { var child children[i]; // 计算当前子物体的角度弧度制 float angle (_startAngle i * angleStep) * Mathf.Deg2Rad; // 计算圆上的坐标 (x cos(angle), y sin(angle)) float x Mathf.Cos(angle) * _radius; float y Mathf.Sin(angle) * _radius; // 设置子物体的本地位置 child.localPosition new Vector3(x, y, 0); // 4. 处理子物体旋转使其朝向圆心 if (_rotateChildren) { // 计算指向圆心的方向向量从子物体指向原点 Vector3 dirToCenter -child.localPosition.normalized; // 负号是因为方向从物体指向中心 // 计算该方向对应的角度以度为单位 float rotAngle Mathf.Atan2(dirToCenter.y, dirToCenter.x) * Mathf.Rad2Deg; // 应用旋转。注意UI的旋转通常绕Z轴。如果子物体本身有初始旋转可能需要调整。 child.localRotation Quaternion.Euler(0, 0, rotAngle); } else { child.localRotation Quaternion.identity; // 或者保持原有旋转 } } // 5. 处理Reverse顺序将计算好的位置反向赋值 if (_order OrderType.Reverse) { // 我们已经按升序计算并设置了children[i]的位置。 // 为了实现Reverse我们需要将位置数组反转后重新赋值。 // 更高效的做法是在计算角度时就用 (itemCount - 1 - i) 作为索引。 // 这里我们重构一下循环逻辑 // for (int i 0; i itemCount; i) { // int effectiveIndex (_order OrderType.Reverse) ? (itemCount - 1 - i) : i; // // 使用effectiveIndex计算角度... // } } }实操心得在计算旋转时Mathf.Atan2(y, x)函数非常关键它返回的是从X轴正方向到向量(x, y)的夹角弧度。因为我们的子物体位置(x, y)是从圆心指向物体的向量而要使其朝向圆心我们需要的是相反方向所以取反向量-child.localPosition来计算角度。另外UI是2D的所以旋转是绕Z轴Quaternion.Euler(0, 0, rotAngle)。3.3 在编辑器中实时预览与调试为了让组件在编辑器中更好用我们可以添加一个自定义的Editor脚本。这能让我们在Inspector窗口修改参数时实时在Scene视图看到布局变化而无需运行游戏。#if UNITY_EDITOR using UnityEditor; using UnityEngine; [CustomEditor(typeof(CircleLayout))] public class CircleLayoutEditor : Editor { private CircleLayout _target; private void OnEnable() { _target (CircleLayout)target; } public override void OnInspectorGUI() { base.OnInspectorGUI(); // 绘制默认的序列化字段 EditorGUI.BeginChangeCheck(); // 开始检查GUI变化 // 这里可以添加一些额外的自定义按钮或滑块 if (EditorGUI.EndChangeCheck()) { // 如果任何属性发生了变化手动触发布局更新 // 使用延迟调用避免在OnGUI循环中频繁计算 UnityEditor.EditorApplication.delayCall () { if (_target ! null) { _target.UpdateLayout(); } }; } // 添加一个手动更新的按钮 if (GUILayout.Button(Update Layout Now)) { _target.UpdateLayout(); } } } #endif这个编辑器脚本做了两件事1. 当我们在Inspector中修改任何序列化字段由base.OnInspectorGUI()绘制后通过EditorApplication.delayCall延迟一帧调用UpdateLayout实现实时预览。2. 提供了一个手动更新的按钮。4. 高级功能扩展与实战应用一个基础的环形布局组件已经能解决80%的问题但在实际项目中我们往往需要更精细的控制和更复杂的效果。下面我们来探讨几个常见的高级扩展方向。4.1 非均匀分布与角度偏移有时我们并不希望所有子物体均匀分布。例如一个时钟表盘小时刻度是均匀的但分钟刻度可能更密集。我们可以为每个子物体单独定义一个角度偏移量。实现思路可以在子物体上挂载一个额外的组件如CircleLayoutElement用来覆盖全局的Angle Step。在CircleLayout计算时优先读取子物体上的这个自定义角度。// CircleLayoutElement.cs public class CircleLayoutElement : MonoBehaviour { public float angleOffset 0f; // 相对于均匀分布位置的额外角度偏移 public bool overrideGlobalAngle false; public float customAngle; // 完全自定义的角度相对于StartAngle } // 在CircleLayout的CalculateLayout中修改角度计算 float childAngle _startAngle; if (child.TryGetComponentCircleLayoutElement(out var element)) { if (element.overrideGlobalAngle) childAngle element.customAngle; else childAngle i * angleStep element.angleOffset; } else { childAngle i * angleStep; }4.2 椭圆布局与径向缩放将圆形布局扩展为椭圆布局只需为X轴和Y轴分别定义不同的半径radiusX和radiusY。计算位置时分别乘以Mathf.Cos和Mathf.Sin即可。[SerializeField] private float _radiusX 100f; [SerializeField] private float _radiusY 100f; // 在CalculateLayout中 float x Mathf.Cos(angle) * _radiusX; float y Mathf.Sin(angle) * _radiusY;更进一步我们可以实现径向缩放让距离圆心越远的子物体尺寸越小或者透明度越低形成一种透视感。这需要在计算位置后根据其径向距离或角度动态修改子物体的localScale或CanvasGroup.alpha。4.3 动态交互拖拽旋转与自动对齐环形菜单的一个经典交互是玩家可以拖拽圆环旋转然后松开手时圆环会自动将某个特定项如最顶部的一项对齐到某个位置。实现思路拖拽旋转监听拖拽事件IBeginDragHandler,IDragHandler根据鼠标/触摸的位移增量转换为角度增量累加到_startAngle上并立即调用UpdateLayout。惯性滚动在拖拽结束时IEndDragHandler记录当前的角速度然后在Update中模拟减速运动持续修改_startAngle。自动对齐在滚动停止后计算当前哪个子物体最接近目标位置如0度角。然后通过一个插值动画Mathf.SmoothDamp或DOTween将_startAngle调整到使该子物体精确对齐目标位置的值。private float _dragAngularVelocity 0f; private float _lastDragAngle; public void OnDrag(PointerEventData eventData) { // 将屏幕位移转换为角度变化需要根据半径和屏幕DPI进行换算 Vector2 delta eventData.delta; float angleDelta delta.x * _dragSensitivity; // 一个灵敏度系数 _startAngle angleDelta; _lastDragAngle _startAngle; UpdateLayout(); } public void OnEndDrag(PointerEventData eventData) { // 这里可以计算一个粗略的角速度用于惯性 // 更精确的做法是记录时间戳和角度差 StartCoroutine(ApplyInertiaAndSnap()); } IEnumerator ApplyInertiaAndSnap() { // 模拟惯性减速... while (Mathf.Abs(_dragAngularVelocity) 0.01f) { _startAngle _dragAngularVelocity * Time.deltaTime; _dragAngularVelocity Mathf.Lerp(_dragAngularVelocity, 0, Time.deltaTime * _deceleration); UpdateLayout(); yield return null; } // 调用对齐函数 SnapToNearestItem(); } private void SnapToNearestItem() { // 找到当前角度下哪个子物体最接近0度顶部 // 计算需要调整的_startAngle偏移量 // 使用DOTween.To(() _startAngle, x { _startAngle x; UpdateLayout(); }, targetAngle, 0.3f); }4.4 与UI系统深度集成支持LayoutElementUnity的自动布局系统如HorizontalLayoutGroup会与子物体上的LayoutElement组件交互以获取preferredWidth/Height等信息。为了让CircleLayout更专业我们可以考虑兼容LayoutElement。例如我们可以根据LayoutElement.preferredWidth来动态调整某个子物体所在位置的半径实现“突出显示”选中项的效果。这需要更复杂的计算因为布局不再是简单的几何排列而需要根据子物体的“布局偏好”进行协商。一个简化版的思路是在CalculateLayout中先获取每个子物体的LayoutElement读取其preferredWidth作为一个权重然后根据权重在总权重中的比例来分配360度的角度范围而不是简单的均分。5. 性能优化与常见问题排查当子物体数量很多比如超过50个或者布局需要每帧更新时性能就可能成为问题。此外在实际使用中也会遇到一些“坑”。5.1 性能优化策略脏标记系统Dirty Flag不要在Update中每帧都调用CalculateLayout。而是设置一个私有布尔变量_isDirty。当任何影响布局的参数如Radius被修改时或者子物体列表变化时在OnTransformChildrenChanged中仅将_isDirty设为true。然后在LateUpdate或一个自定义的MonoBehaviour生命周期末尾检查这个标记如果为真再执行一次布局计算并重置标记。对象池与批量更新如果子物体是动态频繁创建和销毁的如一个无限滚动的环形列表务必使用对象池。在布局更新时只对活跃的池内对象进行计算和设置避免频繁的Instantiate和Destroy。减少GetComponent调用在CalculateLayout的循环中避免在每一帧都为每个子物体调用GetComponentRectTransform()或GetComponentLayoutElement()。可以在初始化时如Awake或OnEnable缓存这些引用到一个列表或字典中。分帧计算对于超多数量的子物体如几百个一次计算所有位置可能导致单帧卡顿。可以考虑使用Coroutine协程分帧处理每帧只计算和设置一部分子物体的位置直到全部完成。5.2 常见问题与解决方案实录问题1子物体位置正确但旋转方向不对没有朝向圆心。排查检查_rotateChildren是否已勾选。检查旋转计算代码中的方向向量。Mathf.Atan2的参数顺序是(y, x)。确保最终应用的旋转轴是Z轴Quaternion.Euler(0, 0, angle)。解决在计算旋转角度后可以添加一个额外的偏移量来微调例如rotAngle 90f如果子物体默认朝上但你需要它朝向圆心而圆心在右侧则需要旋转90度使其朝左。问题2在编辑器中调整参数Scene视图没有实时更新。排查确认脚本顶部有[ExecuteAlways]属性。确认编辑器脚本CircleLayoutEditor中的OnInspectorGUI方法正确调用了_target.UpdateLayout()并且是通过EditorApplication.delayCall调用的以避免即时模式GUI的递归问题。解决可以尝试在CircleLayout的OnValidate方法中也调用UpdateLayout因为OnValidate会在Inspector值更改后立即在编辑器中被调用。问题3布局在运行时正确但编辑器下预制件Prefab模式或未运行时不更新。排查[ExecuteAlways]在预制件模式下行为可能受限。OnValidate在预制件资产被修改时不会在实例上调用。解决更可靠的方法是使用EditorApplication.update事件订阅或者在编辑器脚本中监听PrefabStage.prefabStageClosing等事件来强制刷新。对于复杂的预览可以考虑实现一个PreviewRenderUtility来定制渲染。问题4当父物体的锚点Pivot或轴心点Anchor不在中心时布局圆心不对。排查我们的计算基于child.localPosition这是相对于父物体RectTransform的中心Pivot。如果父物体的矩形不是中心对称的或者锚点导致其中心不在我们视觉期望的位置布局就会偏移。解决在计算位置时可以考虑使用_rectTransform.rect.center作为参考点或者提供一个“圆心偏移”参数Vector2 _centerOffset让开发者可以手动调整圆心的本地坐标。问题5子物体有自定义的LayoutElement但CircleLayout忽略了它的Preferred Size。排查基础实现没有读取LayoutElement。解决如4.4节所述实现一个权重系统。在计算每个子物体的角度步进时不直接用360f / count而是根据每个子物体的preferredWidth或一个自定义的权重值占总权重的比例来分配角度区间。这会使布局计算从均匀分布变为加权分布。问题6在屏幕分辨率变化或Canvas缩放时布局错乱。排查UpdateMode是否设置为OnRectTransformDimensionsChange半径值_radius是使用像素单位还是归一化单位如果Canvas的CanvasScaler设置为Scale With Screen Size你的_radius可能也需要是动态计算的相对值。解决确保在OnRectTransformDimensionsChange中调用更新。考虑将_radius设计为相对于父物体宽度或高度的比例值例如[Range(0, 0.5f)] private float _radiusRelative 0.3f;然后在计算时动态获取_rectTransform.rect.width * _radiusRelative作为实际半径。
返回列表