修复气泡布局存在的一些问题

This commit is contained in:
2026-06-12 09:12:06 +08:00
parent ff40db5b48
commit b4cb3f0976
21 changed files with 2035 additions and 505 deletions
+330
View File
@@ -0,0 +1,330 @@
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 边界限制模式
/// </summary>
public enum BoundaryShapeMode
{
/// <summary>矩形范围:尺寸的 x/y 代表矩形的宽度和高度</summary>
Rectangle,
/// <summary>椭圆范围:尺寸的 x/y 代表椭圆的横轴半径和纵轴半径</summary>
Ellipse,
}
/// <summary>
/// 边界限制约束插件 —— 将气泡元素限制在指定范围内。
/// 支持矩形/椭圆两种形状,并记录超出范围的元素。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Boundary Constraint", 56)]
public class BoundaryConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
[Header("限制范围")]
[Tooltip("限制形状模式:矩形或椭圆")]
[SerializeField] private BoundaryShapeMode m_ShapeMode = BoundaryShapeMode.Rectangle;
[Tooltip("范围尺寸。矩形模式 = 宽x高;椭圆模式 = 横轴半径x纵轴半径")]
[SerializeField] private Vector2 m_BoundarySize = new Vector2(400f, 300f);
[Header("行为")]
[Tooltip("是否在元素超出边界时将其瞬移到边界上(关闭则仅记录不修正)")]
[SerializeField] private bool m_ClampToBoundary = true;
#region 越界追踪
[System.NonSerialized]
private HashSet<int> m_OutOfBoundsObjects = new HashSet<int>();
/// <summary>
/// 记录越界元素:Key=GameObject实例ID,Value=true表示当前越界
/// </summary>
[System.NonSerialized]
private Dictionary<int, bool> m_OutOfBoundsCache = new Dictionary<int, bool>();
/// <summary>
/// 检查指定 GameObject 是否超出了边界范围
/// </summary>
public bool IsOutOfBounds(GameObject target)
{
if (target == null)
return false;
return m_OutOfBoundsCache.TryGetValue(target.GetInstanceID(), out bool value) && value;
}
/// <summary>
/// 获取当前所有越界 GameObject 的实例 ID 集合(只读)
/// </summary>
public IReadOnlyCollection<int> GetOutOfBoundsInstanceIDs()
{
return m_OutOfBoundsObjects;
}
/// <summary>
/// 获取当前越界的元素数量
/// </summary>
public int GetOutOfBoundsCount() => m_OutOfBoundsObjects.Count;
/// <summary>
/// 清除越界记录
/// </summary>
public void ClearOutOfBoundsHistory()
{
m_OutOfBoundsObjects.Clear();
m_OutOfBoundsCache.Clear();
}
#endregion
#region IBubbleConstraintPlugin
#if UNITY_EDITOR
protected override int EditorDefaultOrder => 500;
#endif
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
// 本轮重置越界缓存
m_OutOfBoundsObjects.Clear();
m_OutOfBoundsCache.Clear();
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
if (item.rectTransform == null)
continue;
int instanceId = item.rectTransform.GetInstanceID();
bool outOfBounds = false;
// 根据形状模式检测 item 矩形是否完全或部分越界
switch (m_ShapeMode)
{
case BoundaryShapeMode.Rectangle:
outOfBounds = !IsFullyInsideRect(
item.position, item.size, layoutCenter, m_BoundarySize);
break;
case BoundaryShapeMode.Ellipse:
outOfBounds = !IsFullyInsideEllipse(
item.position, item.size, layoutCenter, m_BoundarySize);
break;
}
m_OutOfBoundsCache[instanceId] = outOfBounds;
if (outOfBounds)
{
m_OutOfBoundsObjects.Add(instanceId);
if (m_ClampToBoundary)
{
// 将元素中心钳制到边界内
item.position = ClampToBoundary(item.position, item.size, layoutCenter, m_BoundarySize, m_ShapeMode);
}
}
}
}
#endregion
#region 碰撞检测
/// <summary>
/// 检测矩形元素是否完全在矩形边界内
/// </summary>
private static bool IsFullyInsideRect(Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundarySize)
{
float halfBW = boundarySize.x * 0.5f;
float halfBH = boundarySize.y * 0.5f;
float halfW = size.x * 0.5f;
float halfH = size.y * 0.5f;
return center.x - halfW >= boundaryCenter.x - halfBW
&& center.x + halfW <= boundaryCenter.x + halfBW
&& center.y - halfH >= boundaryCenter.y - halfBH
&& center.y + halfH <= boundaryCenter.y + halfBH;
}
/// <summary>
/// 检测矩形元素是否完全在椭圆边界内
/// 椭圆方程:dx²/rx² + dy²/ry² ≤ 1 时需要元素所有角点均在椭圆内。
/// 近似检测:元素中心 + 对角线半长构成的「外接圆」半径 vs 椭圆最大轴向半径。
/// 精确检测:依次检查四个角点。
/// </summary>
private static bool IsFullyInsideEllipse(Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundaryRadius)
{
float rx = boundaryRadius.x;
float ry = boundaryRadius.y;
if (rx <= 0f || ry <= 0f)
return false;
// 取矩形的四个角偏移量
float halfW = size.x * 0.5f;
float halfH = size.y * 0.5f;
// 检查四个角点是否都在椭圆内
return IsPointInsideEllipse(center.x - halfW, center.y - halfH, boundaryCenter, rx, ry)
&& IsPointInsideEllipse(center.x + halfW, center.y - halfH, boundaryCenter, rx, ry)
&& IsPointInsideEllipse(center.x - halfW, center.y + halfH, boundaryCenter, rx, ry)
&& IsPointInsideEllipse(center.x + halfW, center.y + halfH, boundaryCenter, rx, ry);
}
/// <summary>
/// 判断一个点是否在椭圆内:dx²/rx² + dy²/ry² ≤ 1
/// </summary>
private static bool IsPointInsideEllipse(float px, float py, Vector2 center, float rx, float ry)
{
float dx = (px - center.x) / rx;
float dy = (py - center.y) / ry;
return dx * dx + dy * dy <= 1f;
}
/// <summary>
/// 将元素中心钳制到边界内(保留最大可能的尺寸)
/// </summary>
private static Vector2 ClampToBoundary(
Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundarySize, BoundaryShapeMode mode)
{
float halfW = size.x * 0.5f;
float halfH = size.y * 0.5f;
switch (mode)
{
case BoundaryShapeMode.Rectangle:
{
float halfBW = boundarySize.x * 0.5f;
float halfBH = boundarySize.y * 0.5f;
// 确保有足够空间容纳元素
float maxW = Mathf.Max(halfBW - halfW, 0f);
float maxH = Mathf.Max(halfBH - halfH, 0f);
center.x = Mathf.Clamp(center.x, boundaryCenter.x - maxW, boundaryCenter.x + maxW);
center.y = Mathf.Clamp(center.y, boundaryCenter.y - maxH, boundaryCenter.y + maxH);
return center;
}
case BoundaryShapeMode.Ellipse:
{
float rx = boundarySize.x;
float ry = boundarySize.y;
if (rx <= halfW || ry <= halfH)
{
// 椭圆太小无法容纳元素,强制居中
return boundaryCenter;
}
// 计算元素中心在椭圆内的有效范围
float effectiveRx = rx - halfW;
float effectiveRy = ry - halfH;
// 将 center 投影到有效椭圆内
float dx = center.x - boundaryCenter.x;
float dy = center.y - boundaryCenter.y;
// 椭圆约束:dx²/effectiveRx² + dy²/effectiveRy² ≤ 1
if (effectiveRx > 0f && effectiveRy > 0f)
{
float nx = dx / effectiveRx;
float ny = dy / effectiveRy;
float dist = nx * nx + ny * ny;
if (dist > 1f)
{
float scale = 1f / Mathf.Sqrt(dist);
dx *= scale;
dy *= scale;
}
}
return boundaryCenter + new Vector2(dx, dy);
}
default:
return center;
}
}
#endregion
#region Editor Gizmos
#if UNITY_EDITOR
protected virtual void OnDrawGizmosSelected()
{
if (!isActiveAndEnabled)
return;
var parentRT = transform as RectTransform;
Vector2 layoutCenter;
if (parentRT != null)
{
layoutCenter = new Vector2(
parentRT.rect.width * (0.5f - parentRT.pivot.x),
parentRT.rect.height * (0.5f - parentRT.pivot.y));
}
else
{
layoutCenter = Vector2.zero;
}
Vector3 worldCenter = transform.TransformPoint(new Vector3(layoutCenter.x, layoutCenter.y, 0));
switch (m_ShapeMode)
{
case BoundaryShapeMode.Rectangle:
{
Vector3 worldSize = transform.TransformVector(new Vector3(m_BoundarySize.x, m_BoundarySize.y, 0));
Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.25f);
Gizmos.DrawCube(worldCenter, worldSize);
Gizmos.color = new Color(1f, 0.2f, 0.2f, 0.7f);
Gizmos.DrawWireCube(worldCenter, worldSize);
break;
}
case BoundaryShapeMode.Ellipse:
{
float rx = m_BoundarySize.x;
float ry = m_BoundarySize.y;
int segments = 64;
float step = 360f / segments * Mathf.Deg2Rad;
Vector3 prevPoint = worldCenter + new Vector3(Mathf.Cos(0) * rx, Mathf.Sin(0) * ry, 0);
// 半透明椭圆盘
Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.12f);
for (int i = 1; i <= segments; i++)
{
float angle = step * i;
Vector3 nextPoint = worldCenter + new Vector3(Mathf.Cos(angle) * rx, Mathf.Sin(angle) * ry, 0);
Gizmos.DrawLine(worldCenter, nextPoint); // 辐线填充
prevPoint = nextPoint;
}
// 椭圆边框
prevPoint = worldCenter + new Vector3(Mathf.Cos(0) * rx, Mathf.Sin(0) * ry, 0);
Gizmos.color = new Color(1f, 0.2f, 0.2f, 0.7f);
for (int i = 1; i <= segments; i++)
{
float angle = step * i;
Vector3 nextPoint = worldCenter + new Vector3(Mathf.Cos(angle) * rx, Mathf.Sin(angle) * ry, 0);
Gizmos.DrawLine(prevPoint, nextPoint);
prevPoint = nextPoint;
}
break;
}
}
}
#endif
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bd7765032236e2c42aea99d854c4e754
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+2 -2
View File
@@ -75,7 +75,7 @@ namespace XericUI.BubbleLayout
{
rectTransform = rt,
Key = $"{rt.name}_{rt.GetInstanceID()}",
position = rt.anchoredPosition,
position = rt.localPosition,
size = rt.rect.size,
rotation = rt.localEulerAngles.z,
velocity = Vector2.zero,
@@ -96,7 +96,7 @@ namespace XericUI.BubbleLayout
{
if (rectTransform != null)
{
rectTransform.anchoredPosition = position;
rectTransform.localPosition = new Vector3(position.x, position.y, rectTransform.localPosition.z);
}
}
}
+5 -4
View File
@@ -66,7 +66,6 @@ namespace XericUI.BubbleLayout
[Tooltip("是否启用插件系统")]
[SerializeField] private bool m_EnablePlugins = true;
[Header("物理参数")]
[Tooltip("速度阻尼系数 (0=无阻尼, 1=完全停止)")]
[SerializeField] private float m_PhysicsDamping = 0.9f;
@@ -79,7 +78,6 @@ namespace XericUI.BubbleLayout
[Tooltip("物理时间步长(0表示使用Time.deltaTime)")]
[SerializeField] private float m_PhysicsDeltaTime = 0f;
[Header("碰撞排挤")]
[Tooltip("最大迭代次数")]
[SerializeField] private int m_MaxIterations = 10;
@@ -394,12 +392,15 @@ namespace XericUI.BubbleLayout
}
/// <summary>
/// 获取布局中心点
/// 获取布局中心点(localPosition 空间)
/// localPosition (0,0) = pivot 位置,此方法返回 rect 中心相对 pivot 的偏移
/// </summary>
private Vector2 GetLayoutCenter()
{
var rt = rectTransform;
return new Vector2(rt.rect.width * 0.5f, rt.rect.height * 0.5f);
return new Vector2(
rt.rect.width * (0.5f - rt.pivot.x),
rt.rect.height * (0.5f - rt.pivot.y));
}
/// <summary>
@@ -9,13 +9,13 @@ namespace XericUI.BubbleLayout
public abstract class BubbleLayoutPluginBase : MonoBehaviour, IBubbleLayoutPlugin
{
[Tooltip("是否启用此插件")]
[SerializeField] protected bool m_Enabled = true;
[SerializeField] private bool m_PluginEnabled = true;
[Tooltip("执行优先级,值越大越靠后执行")]
[SerializeField] protected int m_Order = 10;
[SerializeField] private int m_PluginOrder = 10;
public bool Enabled => m_Enabled;
public int Order => m_Order;
public bool Enabled => m_PluginEnabled;
public int Order => m_PluginOrder;
#if UNITY_EDITOR
/// <summary>
@@ -25,8 +25,8 @@ namespace XericUI.BubbleLayout
protected virtual void Reset()
{
m_Order = EditorDefaultOrder;
m_Enabled = true;
m_PluginOrder = EditorDefaultOrder;
m_PluginEnabled = true;
}
#endif
}
@@ -113,5 +113,111 @@ namespace XericUI.BubbleLayout
break;
}
}
#if UNITY_EDITOR
private struct GizmoSlot
{
public RectTransform rectTransform;
public Vector2 size;
public int index;
}
private void OnDrawGizmosSelected()
{
if (!isActiveAndEnabled || transform.childCount == 0)
return;
var slots = new List<GizmoSlot>();
for (int i = 0; i < transform.childCount; i++)
{
var rt = transform.GetChild(i) as RectTransform;
if (rt == null || !rt.gameObject.activeInHierarchy)
continue;
slots.Add(new GizmoSlot { rectTransform = rt, size = rt.rect.size, index = i });
}
if (slots.Count == 0) return;
switch (m_SortMode)
{
case LayoutSortMode.PositionX:
slots.Sort((a, b) => a.rectTransform.localPosition.x.CompareTo(b.rectTransform.localPosition.x));
break;
case LayoutSortMode.PositionY:
slots.Sort((a, b) => b.rectTransform.localPosition.y.CompareTo(a.rectTransform.localPosition.y));
break;
case LayoutSortMode.Size:
slots.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
var parentRT = (RectTransform)transform;
Vector2 center = new Vector2(
parentRT.rect.width * (0.5f - parentRT.pivot.x),
parentRT.rect.height * (0.5f - parentRT.pivot.y));
// 计算角度步长
float angleStep;
if (m_AngleMode == AngleMode.Auto)
{
angleStep = slots.Count > 1 ? 360f / slots.Count : 0f;
if (slots.Count > 1)
{
float minRequired = m_MinAngle;
for (int i = 0; i < slots.Count; i++)
{
float arc = Mathf.Atan2(slots[i].size.x * 0.5f, m_Radius) * 2f * Mathf.Rad2Deg;
minRequired = Mathf.Max(minRequired, arc);
}
angleStep = Mathf.Max(angleStep, minRequired);
}
}
else
{
angleStep = m_AngleStep;
}
Vector3 worldCenter = transform.TransformPoint(new Vector3(center.x, center.y, 0));
// 绘制环绕参考圆
Color circleColor = new Color(0.2f, 1f, 0.5f, 0.4f);
Gizmos.color = circleColor;
DrawWireCircle(worldCenter, m_Radius, 64);
// 绘制元素目标位置标记
Color slotColor = new Color(0.2f, 1f, 0.5f, 0.7f);
for (int i = 0; i < slots.Count; i++)
{
var slot = slots[i];
float angle = m_StartAngle + (m_Clockwise ? -1f : 1f) * angleStep * i;
float rad = angle * Mathf.Deg2Rad;
Vector2 targetLocal = center + new Vector2(Mathf.Cos(rad) * m_Radius, Mathf.Sin(rad) * m_Radius);
Vector3 targetWorld = transform.TransformPoint(new Vector3(targetLocal.x, targetLocal.y, 0));
// 小方框标记
Vector3 slotScale = transform.TransformVector(new Vector3(slot.size.x * 0.3f, slot.size.y * 0.3f, 0));
Gizmos.color = slotColor;
Gizmos.DrawWireCube(targetWorld, slotScale);
// 从圆心到目标的连线
Gizmos.color = circleColor;
Gizmos.DrawLine(worldCenter, targetWorld);
}
}
private static void DrawWireCircle(Vector3 center, float radius, int segments)
{
float anglePerSegment = 360f / segments * Mathf.Deg2Rad;
Vector3 prevPoint = center + new Vector3(Mathf.Cos(0) * radius, Mathf.Sin(0) * radius, 0);
for (int i = 1; i <= segments; i++)
{
float a = anglePerSegment * i;
Vector3 nextPoint = center + new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0);
Gizmos.DrawLine(prevPoint, nextPoint);
prevPoint = nextPoint;
}
}
#endif
}
}
@@ -5,78 +5,115 @@ namespace XericUI.BubbleLayout
{
/// <summary>
/// 横排布局约束插件 —— 将气泡元素沿 X 轴排列,并通过弹簧力吸附到目标位置。
/// 支持多布局框穿插排列。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Horizontal Layout Constraint", 54)]
public class HorizontalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
public class HorizontalLayoutConstraint : LinearLayoutConstraintBase
{
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] private float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] private LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] private float m_Spacing = 10f;
[Tooltip("起始偏移(相对于布局中心左侧)")]
[SerializeField] private float m_StartOffsetX = 0f;
[Header("横排特有")]
[Tooltip("垂直对齐方式:0=顶部, 1=居中, 2=底部")]
[Range(0f, 2f)]
[SerializeField] private float m_VerticalAlignment = 1f;
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
[Tooltip("起始水平偏移(相对布局框区域中心)")]
[SerializeField] private float m_StartOffsetX = 0f;
#region LinearLayoutConstraintBase 实现
/// <inheritdoc/>
protected override string PrimaryAxisName => "X";
/// <inheritdoc/>
protected override bool IsPrimaryHorizontal => true;
/// <inheritdoc/>
protected override float GetPrimarySize(BubbleItemData item) => item.size.x;
/// <inheritdoc/>
protected override float GetPrimarySizeFromRT(RectTransform rt) => rt.rect.width;
/// <inheritdoc/>
protected override Vector2 MakeTargetPosition(float primary, float secondary)
{
if (items == null || items.Count == 0)
return;
var sorted = new List<BubbleItemData>(items);
SortItems(sorted);
// 计算总宽度
float totalWidth = 0f;
for (int i = 0; i < sorted.Count; i++)
totalWidth += sorted[i].size.x + m_Spacing;
totalWidth -= m_Spacing;
float startX = layoutCenter.x + m_StartOffsetX - totalWidth * 0.5f;
float currentX = startX;
for (int i = 0; i < sorted.Count; i++)
{
var item = sorted[i];
// 垂直对齐
float alignOffset = (m_VerticalAlignment - 1f) * item.size.y * 0.5f;
float targetY = layoutCenter.y - alignOffset;
Vector2 targetPos = new Vector2(currentX, targetY);
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
currentX += item.size.x + m_Spacing;
}
return new Vector2(primary, secondary);
}
private void SortItems(List<BubbleItemData> items)
/// <inheritdoc/>
protected override float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box)
{
switch (m_SortMode)
// 垂直对齐偏移:0=顶部(top), 1=居中, 2=底部(bottom)
// 在 localPosition 空间,Y 轴向上,所以 top = 正方向偏移
float offset = (1f - m_VerticalAlignment) * (box.Region.height - item.size.y) * 0.5f;
return offset;
}
#endregion
#region Editor Gizmos
#if UNITY_EDITOR
/// <inheritdoc/>
protected override void DrawItemTargets(List<GizmoSlot> allSlots, Vector2 layoutCenter)
{
// 将 slots 按 boxIndex 分组
var boxSlots = new List<GizmoSlot>[m_LayoutBoxes.Count];
for (int b = 0; b < m_LayoutBoxes.Count; b++)
boxSlots[b] = new List<GizmoSlot>();
for (int i = 0; i < allSlots.Count; i++)
{
case LayoutSortMode.HierarchyOrder:
break;
case LayoutSortMode.PositionX:
items.Sort((a, b) => a.position.x.CompareTo(b.position.x));
break;
case LayoutSortMode.PositionY:
items.Sort((a, b) => b.position.y.CompareTo(a.position.y));
break;
case LayoutSortMode.Size:
items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
var slot = allSlots[i];
int bIdx = Mathf.Clamp(slot.boxIndex, 0, m_LayoutBoxes.Count - 1);
boxSlots[bIdx].Add(slot);
}
// 穿插迭代,计算每个 slot 的目标位置
int[] indices = new int[boxSlots.Length];
float[] advances = new float[boxSlots.Length];
for (int b = 0; b < boxSlots.Length; b++)
{
float total = 0f;
for (int i = 0; i < boxSlots[b].Count; i++)
total += boxSlots[b][i].size.x + m_Spacing;
if (boxSlots[b].Count > 0) total -= m_Spacing;
advances[b] = m_LayoutBoxes[b].Region.center.x + m_StartOffsetX - total * 0.5f;
}
Color fillColor = new Color(1f, 0.6f, 0.1f, 0.15f);
Color wireColor = new Color(1f, 0.55f, 0f, 0.7f);
bool anyRemaining = true;
while (anyRemaining)
{
anyRemaining = false;
for (int b = 0; b < boxSlots.Length; b++)
{
if (indices[b] >= boxSlots[b].Count)
continue;
anyRemaining = true;
var slot = boxSlots[b][indices[b]];
var box = m_LayoutBoxes[b];
float alignOffset = (1f - m_VerticalAlignment) * (box.Region.height - slot.size.y) * 0.5f;
float targetY = box.Region.center.y + alignOffset;
float targetX = advances[b];
Rect localRect = new Rect(
targetX - slot.size.x * 0.5f,
targetY - slot.size.y * 0.5f,
slot.size.x,
slot.size.y);
DrawLocalRectAsWireCube(localRect, wireColor, fillColor);
advances[b] += slot.size.x + m_Spacing;
indices[b]++;
}
}
}
#endif
#endregion
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 布局框定义 —— 描述一个在父级坐标空间中的矩形区域,
/// 用作横向/竖向布局中特定轨道的目标区域。
/// </summary>
[Serializable]
public class LayoutBox
{
/// <summary>
/// 布局框名称(仅用于 Inspector 标识)
/// </summary>
[Tooltip("布局框名称")]
public string Name = "Box";
/// <summary>
/// 此框在父级 RectTransform localPosition 空间中的矩形区域
/// </summary>
[Tooltip("布局框的矩形区域(localPosition 空间)")]
public Rect Region = new Rect(-100, -100, 200, 200);
/// <summary>
/// 矩形区域内使用的枢轴点(0~1),用于计算子项在区域内的排列起点
/// </summary>
[Tooltip("区域内枢轴")]
public Vector2 Pivot = new Vector2(0.5f, 0.5f);
/// <summary>
/// 每行/列的最大元素数量(0=不限制),仅对穿插模式有意义
/// </summary>
[Tooltip("每轨最大元素数,0=不限制")]
public int MaxItemsPerTrack;
public LayoutBox() { }
public LayoutBox(string name)
{
Name = name;
}
public LayoutBox(string name, Rect region, Vector2 pivot = default)
{
Name = name;
Region = region;
Pivot = pivot == default ? new Vector2(0.5f, 0.5f) : pivot;
}
/// <summary>
/// 获取 Region 在 localPosition 空间中的中心点
/// </summary>
public Vector2 GetCenter()
{
return Region.center;
}
/// <summary>
/// 获取排列的起始锚点位置(根据 Pivot 在 Region 内的映射)
/// </summary>
public Vector2 GetAnchorPoint()
{
return new Vector2(
Region.x + Region.width * Pivot.x,
Region.y + Region.height * Pivot.y);
}
}
/// <summary>
/// 元素到布局框的序列化映射项(仅用于 Editor 持久化)
/// </summary>
[Serializable]
public struct LayoutBoxAssignment
{
/// <summary>目标子项的 Key(name_instanceID)</summary>
public string ItemKey;
/// <summary>分配的布局框索引</summary>
public int BoxIndex;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 72b571aa1837cf54ba45b65983f7999f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,531 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 横向 / 竖向线性布局约束插件的共用基类。
/// 提供布局框管理、元素分组排序、穿插迭代、弹簧吸附/瞬移、Editor Gizmo 框架等复用逻辑。
/// </summary>
public abstract class LinearLayoutConstraintBase : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
#region 字段
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] protected float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] protected LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] protected float m_Spacing = 10f;
[Header("布局框")]
[Tooltip("布局框列表(默认拥有一个全区域框)。每个框代表一个独立排列轨道,子项按框穿插排列。")]
[SerializeField] protected List<LayoutBox> m_LayoutBoxes = new List<LayoutBox>
{
new LayoutBox("Default", new Rect(-200, -200, 400, 400)),
};
/// <summary>
/// 元素到布局框的分配表(运行时字典的序列化后备)
/// </summary>
[SerializeField] private List<LayoutBoxAssignment> m_SerializedAssignments = new List<LayoutBoxAssignment>();
[NonSerialized]
protected Dictionary<string, int> m_BoxAssignments;
#endregion
#region 属性
/// <summary>主轴名称("X" / "Y"),用于子类在 Gizmo / 日志中标识</summary>
protected abstract string PrimaryAxisName { get; }
/// <summary>获取元素沿主轴方向的尺寸</summary>
protected abstract float GetPrimarySize(BubbleItemData item);
/// <summary>
/// 获取元素沿主轴方向的尺寸(用于 Gizmo 预览的 RectTransform 版本)
/// </summary>
protected abstract float GetPrimarySizeFromRT(RectTransform rt);
/// <summary>
/// 根据主轴/辅轴坐标合成最终 localPosition
/// </summary>
protected abstract Vector2 MakeTargetPosition(float primary, float secondary);
#endregion
#region 布局框管理
/// <summary>
/// 惰性初始化运行时字典
/// </summary>
protected void EnsureAssignmentsInitialized()
{
if (m_BoxAssignments != null)
return;
m_BoxAssignments = new Dictionary<string, int>();
for (int i = 0; i < m_SerializedAssignments.Count; i++)
{
var entry = m_SerializedAssignments[i];
if (!string.IsNullOrEmpty(entry.ItemKey))
m_BoxAssignments[entry.ItemKey] = entry.BoxIndex;
}
}
/// <summary>
/// 确保布局框列表至少有一项
/// </summary>
protected void EnsureAtLeastOneBox()
{
if (m_LayoutBoxes == null)
m_LayoutBoxes = new List<LayoutBox>();
if (m_LayoutBoxes.Count == 0)
m_LayoutBoxes.Add(new LayoutBox("Default", new Rect(-200, -200, 400, 400)));
}
/// <summary>
/// 将指定 GameObject 分配到指定编号的布局框。
/// 默认所有子项属于第 0 号框。
/// </summary>
/// <param name="boxIndex">布局框索引(从 0 开始),超出范围会被钳制</param>
/// <param name="target">要分配的子项 GameObject</param>
public void MarkObject2LayoutBox(int boxIndex, GameObject target)
{
if (target == null)
return;
EnsureAtLeastOneBox();
boxIndex = Mathf.Clamp(boxIndex, 0, m_LayoutBoxes.Count - 1);
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
m_BoxAssignments[key] = boxIndex;
// 同步回序列化列表
SyncDictionaryToSerialized();
}
/// <summary>
/// 清除指定 GameObject 的布局框分配(恢复默认框 0)
/// </summary>
public void ClearBoxAssignment(GameObject target)
{
if (target == null)
return;
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
m_BoxAssignments.Remove(key);
SyncDictionaryToSerialized();
}
/// <summary>
/// 根据屏幕空间坐标和指定 Pivot,将布局框的 Region 映射到屏幕对应位置。
/// 适用于 Canvas ScreenSpace-Overlay / Camera 模式。
/// </summary>
/// <param name="boxIndex">目标布局框索引</param>
/// <param name="screenRect">屏幕空间矩形(像素坐标)</param>
/// <param name="camera">渲染 Canvas 的相机,ScreenSpace-Overlay 时传 null</param>
public void SetBoxRegionFromScreenSpace(int boxIndex, Rect screenRect, Camera camera = null)
{
EnsureAtLeastOneBox();
if (boxIndex < 0 || boxIndex >= m_LayoutBoxes.Count)
return;
var parentRT = transform as RectTransform;
if (parentRT == null)
return;
Vector2 localCenter;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parentRT, screenRect.center, camera, out localCenter);
Vector2 localSize = screenRect.size / parentRT.lossyScale;
m_LayoutBoxes[boxIndex].Region = new Rect(
localCenter.x - localSize.x * 0.5f,
localCenter.y - localSize.y * 0.5f,
localSize.x,
localSize.y);
}
/// <summary>
/// 获取指定 GameObject 当前分配的布局框索引
/// </summary>
public int GetBoxIndex(GameObject target)
{
if (target == null)
return 0;
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
return m_BoxAssignments.TryGetValue(key, out int idx) ? idx : 0;
}
/// <summary>
/// 获取所有布局框(只读)
/// </summary>
public IReadOnlyList<LayoutBox> GetLayoutBoxes()
{
EnsureAtLeastOneBox();
return m_LayoutBoxes;
}
private void SyncDictionaryToSerialized()
{
m_SerializedAssignments.Clear();
if (m_BoxAssignments == null)
return;
foreach (var kv in m_BoxAssignments)
{
m_SerializedAssignments.Add(new LayoutBoxAssignment
{
ItemKey = kv.Key,
BoxIndex = kv.Value,
});
}
}
#endregion
#region IBubbleConstraintPlugin 实现
public virtual void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
EnsureAtLeastOneBox();
EnsureAssignmentsInitialized();
// 1. 将元素按布局框分组
var boxGroups = new List<BubbleItemData>[m_LayoutBoxes.Count];
for (int b = 0; b < m_LayoutBoxes.Count; b++)
boxGroups[b] = new List<BubbleItemData>();
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
int boxIdx = 0;
if (item.rectTransform != null)
{
string key = $"{item.rectTransform.name}_{item.rectTransform.GetInstanceID()}";
if (m_BoxAssignments.TryGetValue(key, out int assigned))
boxIdx = Mathf.Clamp(assigned, 0, m_LayoutBoxes.Count - 1);
}
boxGroups[boxIdx].Add(item);
}
// 2. 各组内部排序
for (int b = 0; b < boxGroups.Length; b++)
SortItems(boxGroups[b]);
// 3. 计算各组总主轴尺寸
float[] boxTotalPrimarySize = new float[boxGroups.Length];
float[] boxAdvance = new float[boxGroups.Length];
for (int b = 0; b < boxGroups.Length; b++)
{
float total = 0f;
for (int i = 0; i < boxGroups[b].Count; i++)
total += GetPrimarySize(boxGroups[b][i]) + m_Spacing;
if (boxGroups[b].Count > 0)
total -= m_Spacing;
boxTotalPrimarySize[b] = total;
}
// 4. 计算各框的起始位置(主轴方向),位于 Region 中心偏前
for (int b = 0; b < boxGroups.Length; b++)
{
var region = m_LayoutBoxes[b].Region;
float primaryCenter = IsPrimaryHorizontal
? region.center.x
: region.center.y;
boxAdvance[b] = primaryCenter - boxTotalPrimarySize[b] * 0.5f;
}
// 5. 穿插迭代:按轨轮询各框
int[] boxIndices = new int[boxGroups.Length];
bool anyRemaining = true;
var targetPositions = new List<(BubbleItemData item, int boxIndex, Vector2 targetPos)>();
while (anyRemaining)
{
anyRemaining = false;
for (int b = 0; b < boxGroups.Length; b++)
{
if (boxIndices[b] >= boxGroups[b].Count)
continue;
anyRemaining = true;
var item = boxGroups[b][boxIndices[b]];
var box = m_LayoutBoxes[b];
// 计算辅轴位置(box 区域中心 ± 对齐偏移)
float secondary = IsPrimaryHorizontal
? box.Region.center.y + GetSecondaryAlignOffset(item, box)
: box.Region.center.x + GetSecondaryAlignOffset(item, box);
Vector2 targetPos = MakeTargetPosition(boxAdvance[b], secondary);
// 确保目标在 box 区域内
targetPos = ClampToBox(targetPos, item, box);
targetPositions.Add((item, b, targetPos));
// 推进主轴位置
boxAdvance[b] += GetPrimarySize(item) + m_Spacing;
boxIndices[b]++;
}
}
// 6. 按计算顺序依次施加力(确保穿插顺序被物理系统感知)
for (int i = 0; i < targetPositions.Count; i++)
{
var (item, boxIndex, targetPos) = targetPositions[i];
var box = m_LayoutBoxes[boxIndex];
// 检查是否超出 box 允许范围,超出则瞬移
if (IsOutOfBox(item.position, item, box))
{
item.position = targetPos;
item.velocity = Vector2.zero;
}
else
{
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
}
}
}
/// <summary>
/// 判断主轴是水平还是垂直
/// </summary>
protected abstract bool IsPrimaryHorizontal { get; }
/// <summary>
/// 子类提供辅轴对齐偏移量(如横排的垂直对齐、竖排的拉链偏移)
/// </summary>
/// <returns>偏移量(正值 = 远离区域左上角方向)</returns>
protected virtual float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box)
{
return 0f;
}
/// <summary>
/// 将目标位置钳制到布局框区域内(考虑元素自身尺寸)
/// </summary>
protected Vector2 ClampToBox(Vector2 target, BubbleItemData item, LayoutBox box)
{
Rect r = box.Region;
float halfW = item.size.x * 0.5f;
float halfH = item.size.y * 0.5f;
target.x = Mathf.Clamp(target.x, r.xMin + halfW, r.xMax - halfW);
target.y = Mathf.Clamp(target.y, r.yMin + halfH, r.yMax - halfH);
return target;
}
/// <summary>
/// 判断位置是否超出布局框(用于瞬移判断)
/// </summary>
protected bool IsOutOfBox(Vector2 pos, BubbleItemData item, LayoutBox box)
{
Rect r = box.Region;
float halfW = item.size.x * 0.5f;
float halfH = item.size.y * 0.5f;
return pos.x - halfW < r.xMin || pos.x + halfW > r.xMax
|| pos.y - halfH < r.yMin || pos.y + halfH > r.yMax;
}
/// <summary>
/// 排序元素列表
/// </summary>
protected void SortItems(List<BubbleItemData> items)
{
if (items.Count <= 1)
return;
switch (m_SortMode)
{
case LayoutSortMode.HierarchyOrder:
break;
case LayoutSortMode.PositionX:
items.Sort((a, b) => a.position.x.CompareTo(b.position.x));
break;
case LayoutSortMode.PositionY:
items.Sort((a, b) => b.position.y.CompareTo(a.position.y));
break;
case LayoutSortMode.Size:
items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
}
#endregion
#region Editor Gizmos
#if UNITY_EDITOR
/// <summary>
/// Gizmo 预览数据结构
/// </summary>
protected struct GizmoSlot
{
public RectTransform rectTransform;
public Vector2 size;
public int index;
public int boxIndex;
}
protected virtual void OnDrawGizmosSelected()
{
if (!isActiveAndEnabled || transform.childCount == 0)
return;
// 收集子元素
var allSlots = new List<GizmoSlot>();
for (int i = 0; i < transform.childCount; i++)
{
var rt = transform.GetChild(i) as RectTransform;
if (rt == null || !rt.gameObject.activeInHierarchy)
continue;
allSlots.Add(new GizmoSlot { rectTransform = rt, size = rt.rect.size, index = i, boxIndex = 0 });
}
if (allSlots.Count == 0) return;
// 读回分配
EnsureAssignmentsInitialized();
for (int i = 0; i < allSlots.Count; i++)
{
var slot = allSlots[i];
string key = $"{slot.rectTransform.name}_{slot.rectTransform.GetInstanceID()}";
if (m_BoxAssignments.TryGetValue(key, out int bIdx))
{
slot.boxIndex = Mathf.Clamp(bIdx, 0, m_LayoutBoxes.Count - 1);
allSlots[i] = slot;
}
}
// 按当前排序模式排序
switch (m_SortMode)
{
case LayoutSortMode.PositionX:
allSlots.Sort((a, b) => a.rectTransform.localPosition.x.CompareTo(b.rectTransform.localPosition.x));
break;
case LayoutSortMode.PositionY:
allSlots.Sort((a, b) => b.rectTransform.localPosition.y.CompareTo(a.rectTransform.localPosition.y));
break;
case LayoutSortMode.Size:
allSlots.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
var parentRT = (RectTransform)transform;
Vector2 layoutCenter = new Vector2(
parentRT.rect.width * (0.5f - parentRT.pivot.x),
parentRT.rect.height * (0.5f - parentRT.pivot.y));
// 1. 绘制所有布局框区域
DrawBoxRegions();
// 2. 委托子类绘制元素目标位置
DrawItemTargets(allSlots, layoutCenter);
}
/// <summary>
/// 绘制所有布局框的矩形区域(白色虚线框)
/// </summary>
protected virtual void DrawBoxRegions()
{
Color[] boxColors =
{
new Color(0.5f, 0.5f, 1f, 0.35f), // 蓝色调
new Color(1f, 0.5f, 0.5f, 0.35f), // 红色调
new Color(0.5f, 1f, 0.5f, 0.35f), // 绿色调
new Color(1f, 1f, 0.5f, 0.35f), // 黄色调
new Color(0.5f, 1f, 1f, 0.35f), // 青色调
new Color(1f, 0.5f, 1f, 0.35f), // 紫色调
};
for (int b = 0; b < m_LayoutBoxes.Count; b++)
{
var box = m_LayoutBoxes[b];
Color c = boxColors[b % boxColors.Length];
Rect r = box.Region;
Vector3 centerWorld = transform.TransformPoint(new Vector3(r.center.x, r.center.y, 0));
Vector3 sizeWorld = transform.TransformVector(new Vector3(r.width, r.height, 0));
// 半透明填充
Gizmos.color = new Color(c.r, c.g, c.b, 0.08f);
Gizmos.DrawCube(centerWorld, sizeWorld);
// 虚线边框
Gizmos.color = c;
DrawWireRect(centerWorld, sizeWorld);
// 中心十字
float crossLen = Mathf.Min(r.width, r.height) * 0.08f;
Vector3 cxWorld = transform.TransformVector(new Vector3(crossLen, 0, 0));
Vector3 cyWorld = transform.TransformVector(new Vector3(0, crossLen, 0));
Gizmos.DrawLine(centerWorld - cxWorld, centerWorld + cxWorld);
Gizmos.DrawLine(centerWorld - cyWorld, centerWorld + cyWorld);
}
}
/// <summary>
/// 子类重写:根据已排序的 slots 和 layoutCenter 绘制各元素的目标位置预览
/// </summary>
protected abstract void DrawItemTargets(List<GizmoSlot> allSlots, Vector2 layoutCenter);
/// <summary>
/// 绘制一个线框矩形
/// </summary>
protected static void DrawWireRect(Vector3 center, Vector3 size)
{
Vector3 half = size * 0.5f;
Vector3[] corners = new Vector3[5]
{
center + new Vector3(-half.x, -half.y, 0),
center + new Vector3( half.x, -half.y, 0),
center + new Vector3( half.x, half.y, 0),
center + new Vector3(-half.x, half.y, 0),
center + new Vector3(-half.x, -half.y, 0),
};
for (int i = 0; i < 4; i++)
Gizmos.DrawLine(corners[i], corners[i + 1]);
}
/// <summary>
/// 将 localPosition 空间的 Rect 绘制为世界空间线框
/// </summary>
protected void DrawLocalRectAsWireCube(Rect localRect, Color wireColor, Color? fillColor = null)
{
Vector3 worldCenter = transform.TransformPoint(new Vector3(localRect.center.x, localRect.center.y, 0));
Vector3 worldSize = transform.TransformVector(new Vector3(localRect.width, localRect.height, 0));
if (fillColor.HasValue)
{
Gizmos.color = fillColor.Value;
Gizmos.DrawCube(worldCenter, worldSize);
}
Gizmos.color = wireColor;
Gizmos.DrawWireCube(worldCenter, worldSize);
}
#endif
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0158a0d6480014d46ac381596bfe78fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+451
View File
@@ -0,0 +1,451 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
#if Unity_Runtime
namespace XericLibrary.Runtime.Type.SpatialAlgorithm
{
/// <summary>
/// 灵活的四叉树实现,支持多种坐标类型
/// </summary>
public class QuadTree<T>
{
#region 字段属性
private int maxObjectsPerNode = 5;
private int maxDepth = 4;
private Node root;
private Func<T, Rect> getRectFunc; // 获取对象边界的委托
/// <summary>
/// 获取此四叉树的范围
/// </summary>
public Rect RootRect => root != null ? root.boundary : Rect.zero;
public int Count { get; private set; }
/// <summary>
/// 清空四叉树,保留边界和配置以便重用
/// </summary>
public void Clear()
{
if (root != null)
{
root.ClearAll();
}
Count = 0;
}
/// <summary>
/// 重建四叉树,使用相同边界和配置
/// </summary>
public void Rebuild(IEnumerable<T> items)
{
Clear();
foreach (var item in items)
{
Insert(item);
}
}
/// <summary>
/// 获取四叉树中所有元素
/// </summary>
public void GetAllItems(HashSet<T> result)
{
if (result == null)
return;
root?.CollectAllItems(result);
}
#endregion
#region 构造函数
/// <summary>
/// 初始化四叉树
/// </summary>
/// <param name="boundary">四叉树边界</param>
/// <param name="getRectFunc">获取对象边界的函数</param>
public QuadTree(Rect boundary, Func<T, Rect> getRectFunc, int maxObjectsPerNode = 5, int maxDepth = 4)
{
this.maxObjectsPerNode = Math.Max(1, maxObjectsPerNode);
this.maxDepth = Math.Max(1, maxDepth);
this.root = new Node(boundary, 0, getRectFunc, this);
this.getRectFunc = getRectFunc ?? throw new ArgumentNullException(nameof(getRectFunc));
}
#endregion
#region 公共方法
/// <summary>
/// 插入元素
/// </summary>
public bool Insert(T item)
{
return root.Insert(item);
}
/// <summary>
/// 查找范围内的元素
/// </summary>
/// <param name="area">查找范围</param>
/// <param name="result">建议使用数据容器对象池控制,避免频繁GC,默认空将自动创建新的数据容器</param>
/// <returns></returns>
public HashSet<T> Retrieve(Rect area, HashSet<T> result = null)
{
return root.Retrieve(area, result);
}
/// <summary>
/// 查找最近的元素
/// </summary>
public T FindNearest(Vector2 target, float maxDistance = float.MaxValue)
{
T nearest = default;
float minDistanceSquared = maxDistance * maxDistance;
root.FindNearest(target, ref nearest, ref minDistanceSquared);
return nearest;
}
/// <summary>
/// 调试绘制网格
/// </summary>
public void DrawGrid()
{
root.DrawGrid(Color.white);
}
#endregion
#region 节点类
private class Node : IEnumerable<Node>
{
internal Rect boundary;
internal HashSet<T> objects = new HashSet<T>();
internal Node[] children;
internal readonly int depth;
internal readonly Func<T, Rect> getRectFunc;
internal readonly QuadTree<T> parent;
public Node(Rect boundary, int depth, Func<T, Rect> getRectFunc, QuadTree<T> parent)
{
this.boundary = boundary;
this.depth = depth;
this.getRectFunc = getRectFunc;
this.parent = parent;
this.children = null;
}
/// <summary>
/// 插入元素
/// </summary>
public bool Insert(T item)
{
Rect itemRect = getRectFunc(item);
// 如果元素与当前节点不重叠,无法插入
if (!IsOverlapping(boundary, itemRect))
return false;
// 如果是叶子节点且未达容量上限
if (objects != null && (objects.Count < parent.maxObjectsPerNode || depth >= parent.maxDepth))
{
objects.Add(item);
parent.Count++;
return true;
}
// 分裂节点
if (children == null)
Split();
// 插入到子节点
bool inserted = false;
foreach (var child in children)
{
if (child.Insert(item))
inserted = true;
}
return inserted;
}
/// <summary>
/// 分裂为四个子节点
/// </summary>
private void Split()
{
float halfWidth = boundary.width / 2;
float halfHeight = boundary.height / 2;
float midX = boundary.x + halfWidth;
float midY = boundary.y + halfHeight;
children = new Node[4];
children[0] = new Node(new Rect(boundary.x, boundary.y, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 左上
children[1] = new Node(new Rect(midX, boundary.y, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 右上
children[2] = new Node(new Rect(boundary.x, midY, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 左下
children[3] = new Node(new Rect(midX, midY, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 右下
// 移动当前节点的对象到子节点
var tempObjects = new List<T>(objects);
objects.Clear();
objects = null; // 标记为非叶子节点
foreach (var obj in tempObjects)
{
Insert(obj);
}
}
/// <summary>
/// 查找最近的元素
/// </summary>
public void FindNearest(Vector2 target, ref T nearest, ref float minDistanceSquared)
{
// 如果当前节点距离大于已知最小距离,直接返回
float distanceToBoundary = GetDistanceToRectSquared(target, boundary);
if (distanceToBoundary >= minDistanceSquared)
return;
// 检查当前节点中的对象
if (objects != null)
{
foreach (var obj in objects)
{
Rect objRect = getRectFunc(obj);
float distanceSquared = GetDistanceToRectSquared(target, objRect);
if (distanceSquared < minDistanceSquared)
{
minDistanceSquared = distanceSquared;
nearest = obj;
}
}
}
// 递归检查子节点
if (children != null)
{
// 按距离排序子节点,优先检查更近的
var childDistances = new List<Tuple<Node, float>>();
foreach (var child in children)
{
float dist = child.GetDistanceToRectSquared(target, child.boundary);
childDistances.Add(Tuple.Create(child, dist));
}
// 按距离升序排序
childDistances.Sort((a, b) => a.Item2.CompareTo(b.Item2));
foreach (var tuple in childDistances)
{
if (tuple.Item2 < minDistanceSquared)
{
tuple.Item1.FindNearest(target, ref nearest, ref minDistanceSquared);
}
}
}
}
/// <summary>
/// 查找范围内的元素
/// </summary>
public HashSet<T> Retrieve(Rect area, HashSet<T> result)
{
if (result == null)
result = new HashSet<T>();
// 如果与当前节点不重叠,返回空集
if (!IsOverlapping(boundary, area))
return result;
// 检查当前节点中的对象
if (objects != null)
{
foreach (var obj in objects)
{
if (IsOverlapping(getRectFunc(obj), area))
{
result.Add(obj);
}
}
}
// 递归查询子节点
if (children != null)
{
foreach (var child in children)
{
var childResults = child.Retrieve(area, result);
foreach (var item in childResults)
{
result.Add(item);
}
}
}
return result;
}
/// <summary>
/// 绘制节点网格
/// </summary>
public void DrawGrid(Color color)
{
Debug.DrawLine(new Vector2(boundary.x, boundary.y), new Vector2(boundary.xMax, boundary.y), color);
Debug.DrawLine(new Vector2(boundary.x, boundary.y), new Vector2(boundary.x, boundary.yMax), color);
Debug.DrawLine(new Vector2(boundary.xMax, boundary.y), new Vector2(boundary.xMax, boundary.yMax), color);
Debug.DrawLine(new Vector2(boundary.x, boundary.yMax), new Vector2(boundary.xMax, boundary.yMax), color);
if (children != null)
{
foreach (var child in children)
{
child.DrawGrid(color);
}
}
}
#region 辅助方法
/// <summary>
/// 判断两个矩形是否重叠
/// </summary>
private bool IsOverlapping(Rect a, Rect b)
{
return a.x < b.xMax && a.xMax > b.x &&
a.y < b.yMax && a.yMax > b.y;
}
/// <summary>
/// 计算点到矩形的最小距离平方
/// </summary>
private float GetDistanceToRectSquared(Vector2 point, Rect rect)
{
float dx = Mathf.Max(rect.x - point.x, point.x - rect.xMax, 0);
float dy = Mathf.Max(rect.y - point.y, point.y - rect.yMax, 0);
return dx * dx + dy * dy;
}
/// <summary>
/// 递归清空所有节点
/// </summary>
public void ClearAll()
{
if (objects != null)
{
objects.Clear();
}
if (children != null)
{
for (int i = 0; i < children.Length; i++)
{
children[i]?.ClearAll();
}
children = null;
}
objects = new HashSet<T>();
}
/// <summary>
/// 递归收集所有元素
/// </summary>
public void CollectAllItems(HashSet<T> result)
{
if (objects != null)
{
foreach (var obj in objects)
{
result.Add(obj);
}
return;
}
if (children != null)
{
for (int i = 0; i < children.Length; i++)
{
children[i]?.CollectAllItems(result);
}
}
}
public IEnumerator<Node> GetEnumerator()
{
if (objects != null)
yield return this;
else
foreach (var child in children)
foreach (var node in child)
yield return node;
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endregion
}
#endregion
}
/// <summary>
/// 四叉树扩展方法,提供不同类型的矩形转换
/// </summary>
public static class QuadTreeExtensions
{
// Vector2 扩展 - 视为点
public static Rect ToQuadTreeRect(this Vector2 vector)
{
return new Rect(vector, Vector2.zero);
}
// Vector3 扩展 - 使用X和Y坐标,视为点
public static Rect ToQuadTreeRect(this Vector3 vector)
{
return new Rect(vector.x, vector.y, 0, 0);
}
// Rect 扩展 - 直接使用自身作为边界
public static Rect ToQuadTreeRect(this Rect rect)
{
return rect;
}
// RectTransform 扩展 - 使用Rect范围
public static Rect ToQuadTreeRect(this RectTransform transform)
{
return transform.rect;
}
// Transform 扩展 - 使用位置的X和Y坐标,视为点
public static Rect ToQuadTreeRect(this Transform transform)
{
Vector3 pos = transform.position;
return new Rect(pos.x, pos.y, 0, 0);
}
// 简化四叉树创建的扩展方法
public static QuadTree<T> CreateQuadTree<T>(this IEnumerable<T> collection, Rect boundary, Func<T, Rect> getRectFunc)
{
var quadTree = new QuadTree<T>(boundary, getRectFunc);
foreach (var item in collection)
{
quadTree.Insert(item);
}
return quadTree;
}
}
}
#endif
+3
View File
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: eac931867b3f4645a5c0e454b9deb626
timeCreated: 1781071846
+193 -60
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using UnityEngine;
@@ -24,89 +23,223 @@ namespace XericUI.BubbleLayout
/// <summary>
/// 竖排布局约束插件 —— 将气泡元素沿 Y 轴排列,并通过弹簧力吸附到目标位置。
/// 支持多布局框穿插排列和拉链模式(一左一右交替)。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Vertical Layout Constraint", 53)]
public class VerticalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
public class VerticalLayoutConstraint : LinearLayoutConstraintBase
{
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] private float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] private LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] private float m_Spacing = 10f;
[Tooltip("起始偏移(相对于布局中心上方)")]
[Header("竖排特有")]
[Tooltip("起始竖直偏移(相对布局框区域中心)")]
[SerializeField] private float m_StartOffsetY = 0f;
[Header("拉链模式")]
[Tooltip("启用拉链排列(偶数索引偏左,奇数索引偏右)")]
[Tooltip("启用拉链排列(偶数索引偏左,奇数索引偏右,仅对默认单框布局有效)")]
[SerializeField] private bool m_ZipperMode = false;
[Tooltip("拉链模式下左右间距")]
[SerializeField] private float m_ZipperGap = 20f;
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
#region LinearLayoutConstraintBase 实现
/// <inheritdoc/>
protected override string PrimaryAxisName => "Y";
/// <inheritdoc/>
protected override bool IsPrimaryHorizontal => false;
/// <inheritdoc/>
protected override float GetPrimarySize(BubbleItemData item) => item.size.y;
/// <inheritdoc/>
protected override float GetPrimarySizeFromRT(RectTransform rt) => rt.rect.height;
/// <inheritdoc/>
protected override Vector2 MakeTargetPosition(float primary, float secondary)
{
return new Vector2(secondary, primary);
}
/// <inheritdoc/>
protected override float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box)
{
// 拉链偏移由外部交叉索引决定,这里返回 0
return 0f;
}
#endregion
#region 重写 ProcessConstraint 以支持拉链模式
/// <inheritdoc/>
public override void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
// 排序
var sorted = new List<BubbleItemData>(items);
SortItems(sorted);
EnsureAtLeastOneBox();
EnsureAssignmentsInitialized();
// 计算起始 Y
float totalHeight = 0f;
for (int i = 0; i < sorted.Count; i++)
totalHeight += sorted[i].size.y + m_Spacing;
totalHeight -= m_Spacing; // 去掉最后一个间距
// 分组
var boxGroups = new List<BubbleItemData>[m_LayoutBoxes.Count];
for (int b = 0; b < m_LayoutBoxes.Count; b++)
boxGroups[b] = new List<BubbleItemData>();
float startY = layoutCenter.y + m_StartOffsetY + totalHeight * 0.5f;
// 逐元素设置目标位置并施加弹簧力
float currentY = startY;
for (int i = 0; i < sorted.Count; i++)
for (int i = 0; i < items.Count; i++)
{
var item = sorted[i];
float targetX = layoutCenter.x;
float targetY = currentY;
// 拉链模式:交替左右偏移
if (m_ZipperMode)
var item = items[i];
int boxIdx = 0;
if (item.rectTransform != null)
{
targetX = layoutCenter.x + (i % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f;
string key = $"{item.rectTransform.name}_{item.rectTransform.GetInstanceID()}";
if (m_BoxAssignments.TryGetValue(key, out int assigned))
boxIdx = Mathf.Clamp(assigned, 0, m_LayoutBoxes.Count - 1);
}
Vector2 targetPos = new Vector2(targetX, targetY);
// 弹簧力:F = (target - current) * forceMultiplier
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
currentY -= item.size.y + m_Spacing;
boxGroups[boxIdx].Add(item);
}
}
private void SortItems(List<BubbleItemData> items)
{
switch (m_SortMode)
// 排序各组
for (int b = 0; b < boxGroups.Length; b++)
SortItems(boxGroups[b]);
// 计算各组总主轴尺寸及起始位置
float[] boxTotalPrimary = new float[boxGroups.Length];
float[] boxAdvance = new float[boxGroups.Length];
for (int b = 0; b < boxGroups.Length; b++)
{
case LayoutSortMode.HierarchyOrder:
// 保持原始顺序(即 rectChildren 的顺序)
break;
case LayoutSortMode.PositionX:
items.Sort((a, b) => a.position.x.CompareTo(b.position.x));
break;
case LayoutSortMode.PositionY:
items.Sort((a, b) => b.position.y.CompareTo(a.position.y)); // Y 从大到小(上到下)
break;
case LayoutSortMode.Size:
items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
float total = 0f;
for (int i = 0; i < boxGroups[b].Count; i++)
total += boxGroups[b][i].size.y + m_Spacing;
if (boxGroups[b].Count > 0) total -= m_Spacing;
boxTotalPrimary[b] = total;
boxAdvance[b] = m_LayoutBoxes[b].Region.center.y + m_StartOffsetY + total * 0.5f;
}
// 穿插迭代
int[] boxIndices = new int[boxGroups.Length];
int globalIndex = 0;
bool anyRemaining = true;
var targetList = new List<(BubbleItemData item, int boxIndex, Vector2 targetPos)>();
while (anyRemaining)
{
anyRemaining = false;
for (int b = 0; b < boxGroups.Length; b++)
{
if (boxIndices[b] >= boxGroups[b].Count)
continue;
anyRemaining = true;
var item = boxGroups[b][boxIndices[b]];
var box = m_LayoutBoxes[b];
// 辅轴位置:拉链模式下的 X 偏移
float secondaryX = box.Region.center.x;
if (m_ZipperMode)
{
secondaryX += (globalIndex % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f;
}
// 主轴位置推进:从上往下(Y 减小)
float targetY = boxAdvance[b];
Vector2 targetPos = new Vector2(secondaryX, targetY);
targetPos = ClampToBox(targetPos, item, box);
targetList.Add((item, b, targetPos));
boxAdvance[b] -= item.size.y + m_Spacing;
boxIndices[b]++;
globalIndex++;
}
}
// 施加力 / 瞬移
for (int i = 0; i < targetList.Count; i++)
{
var (item, boxIndex, targetPos) = targetList[i];
var box = m_LayoutBoxes[boxIndex];
if (IsOutOfBox(item.position, item, box))
{
item.position = targetPos;
item.velocity = Vector2.zero;
}
else
{
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
}
}
}
#endregion
#region Editor Gizmos
#if UNITY_EDITOR
/// <inheritdoc/>
protected override void DrawItemTargets(List<GizmoSlot> allSlots, Vector2 layoutCenter)
{
var boxSlots = new List<GizmoSlot>[m_LayoutBoxes.Count];
for (int b = 0; b < m_LayoutBoxes.Count; b++)
boxSlots[b] = new List<GizmoSlot>();
for (int i = 0; i < allSlots.Count; i++)
{
var slot = allSlots[i];
int bIdx = Mathf.Clamp(slot.boxIndex, 0, m_LayoutBoxes.Count - 1);
boxSlots[bIdx].Add(slot);
}
int[] indices = new int[boxSlots.Length];
float[] advances = new float[boxSlots.Length];
for (int b = 0; b < boxSlots.Length; b++)
{
float total = 0f;
for (int i = 0; i < boxSlots[b].Count; i++)
total += boxSlots[b][i].size.y + m_Spacing;
if (boxSlots[b].Count > 0) total -= m_Spacing;
advances[b] = m_LayoutBoxes[b].Region.center.y + m_StartOffsetY + total * 0.5f;
}
Color fillColor = new Color(0.1f, 0.7f, 1f, 0.15f);
Color wireColor = new Color(0f, 0.6f, 1f, 0.7f);
int globalIndex = 0;
bool anyRemaining = true;
while (anyRemaining)
{
anyRemaining = false;
for (int b = 0; b < boxSlots.Length; b++)
{
if (indices[b] >= boxSlots[b].Count)
continue;
anyRemaining = true;
var slot = boxSlots[b][indices[b]];
var box = m_LayoutBoxes[b];
float targetX = box.Region.center.x;
if (m_ZipperMode)
targetX += (globalIndex % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f;
float targetY = advances[b];
Rect localRect = new Rect(
targetX - slot.size.x * 0.5f,
targetY - slot.size.y * 0.5f,
slot.size.x,
slot.size.y);
DrawLocalRectAsWireCube(localRect, wireColor, fillColor);
advances[b] -= slot.size.y + m_Spacing;
indices[b]++;
globalIndex++;
}
}
}
#endif
#endregion
}
}
-3
View File
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 73eb6a65472648ddafa15ed1a6f525a2
timeCreated: 1757990443
@@ -1,325 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Pool;
using UnityEngine.Serialization;
using UnityEngine.UI;
using XericLibrary.Runtime.MacroLibrary;
using XericLibrary.Runtime.Type.SpatialAlgorithm;
using XericUI.Core.Base;
using XericUI.OverrideLayout;
#if UNITY_EDITOR
using XericLibraryEditor.Debug;
#endif
#if ODIN_INSPECTOR
using Sirenix.OdinInspector;
#endif
namespace XericUI.DynamicActivitiesImpact
{
/// <summary>
/// 导入动态激活组件(需要外部控制部分检查与生命周期事件)
/// </summary>
[Obsolete("直接用XericScrollRect")]
public class UIDynamicActiveImpact : XericUIBehaciour
{
#region 字段属性
#if ODIN_INSPECTOR
[LabelText("主要容器")]
[BoxGroup("基本容器", centerLabel: true)]
#endif
[Tooltip("容器子集应呈现平摊的样式")]
public RectTransform containerTransform = null;
#if ODIN_INSPECTOR
[BoxGroup("基本容器")]
[LabelText("遮罩容器")]
#endif
[Tooltip("用作遮罩计算的组件")]
public RectTransform maskRectTransform = null;
#if ODIN_INSPECTOR
[BoxGroup("基本容器")]
[LabelText("容器排版控制")]
#endif
public LayoutGroup layoutGroup;
#if ODIN_INSPECTOR
[BoxGroup("容器性能")]
[LabelText("预加载像素范围")]
#endif
public float preloadMargin = 200f;
#if ODIN_INSPECTOR
[BoxGroup("容器性能")]
[LabelText("可被察觉的响应变化长度")]
#endif
public float discernibleDistance = 10f;
#if ODIN_INSPECTOR
[BoxGroup("容器性能")]
[LabelText("每秒检查更新的次数")]
#endif
[Range(0.1f, 50)]
public float updateRateLimit = 2;
#endregion
#region 容器成员
internal bool _layoutDirty;
internal bool _transformDirty;
internal int _lastChildrenCount;
internal Vector2 _lastPosition;
internal HashSet<RectTransform> _activeSet = null;
internal HashSet<RectTransform> _allObjsSet = null;
private HashSetPool<RectTransform> _tempQuadUIHashSetPool = new HashSetPool<RectTransform>();
/// <summary>
/// 保存所有ui对象的四叉树
/// </summary>
protected QuadTree<RectTransform> QuadUITargetTree;
public virtual IEnumerable<RectTransform> ContainerChildren => containerTransform.GetChildren().Select(a => a.RectTransform());
public virtual int ContainerChildrenCount => containerTransform.childCount;
public virtual Rect ContainerTransformRectRange => containerTransform.ToQuadTreeRect();
public virtual Rect MaskRectTransformRectRange => maskRectTransform.ToQuadTreeRect();
public bool IsLayoutDirty => _layoutDirty;
public bool IsTransformDirty => _transformDirty;
public float SaftyDiscernibleUpdateTime => Mathf.Max(1f / 60, 1f / updateRateLimit);
public LayoutGroup MainLayoutGroup => transform.GetComponent<LayoutGroup>();
#endregion
#region 生命周期
#if UNITY_EDITOR
private Vector3[] vertex1 = new Vector3[4];
private Vector3[] vertex2 = new Vector3[4];
private Vector3[] vertex3 = new Vector3[4];
private void OnDrawGizmosSelected()
{
var lossyScale = rectTransform.lossyScale;
var scale = lossyScale * preloadMargin;
var dis2 = lossyScale * discernibleDistance;
containerTransform.GetWorldCorners(vertex1);
vertex2[0] = new Vector3(vertex1[0].x - scale.x, vertex1[0].y - scale.y, vertex1[0].z);
vertex2[1] = new Vector3(vertex1[1].x - scale.x, vertex1[1].y + scale.y, vertex1[1].z);
vertex2[2] = new Vector3(vertex1[2].x + scale.x, vertex1[2].y + scale.y, vertex1[2].z);
vertex2[3] = new Vector3(vertex1[3].x + scale.x, vertex1[3].y - scale.y, vertex1[3].z);
vertex3[0] = new Vector3(vertex1[0].x - dis2.x, vertex1[0].y - dis2.y, vertex1[0].z);
vertex3[1] = new Vector3(vertex1[1].x - dis2.x, vertex1[1].y + dis2.y, vertex1[1].z);
vertex3[2] = new Vector3(vertex1[2].x + dis2.x, vertex1[2].y + dis2.y, vertex1[2].z);
vertex3[3] = new Vector3(vertex1[3].x + dis2.x, vertex1[3].y - dis2.y, vertex1[3].z);
Gizmos.color = Color.yellow;
MacroGizmosDraw.DrawPolygonLineByPointArray(vertex3);
MacroGizmosDraw.DrawGizmos2RectLinkCube(vertex1, vertex2, Color.grey, Color.cyan, true);
}
#endif
protected override void Awake()
{
base.Awake();
// 容器
if (containerTransform == null)
containerTransform = rectTransform;
// 遮罩
if (maskRectTransform == null)
{
var mask = transform.GetComponentInParent<Mask>();
maskRectTransform = mask.RectTransform();
}
// 布局
if (layoutGroup == null)
layoutGroup = transform.GetComponentInParent<LayoutGroup>();
if (layoutGroup is XericHorizontalOrVerticalLayoutGroup xericLayoutGroup)
{
xericLayoutGroup.layoutIgnoreInactive = false;
}
CheckIsDirty();
}
protected override void OnEnable()
{
base.OnEnable();
}
protected override void OnDisable()
{
base.OnDisable();
}
private float accTime = 0;
protected virtual void Update()
{
if (accTime < SaftyDiscernibleUpdateTime)
{
accTime += Time.unscaledDeltaTime;
return;
}
// 毕竟这里不是粒子计算器
accTime = accTime % SaftyDiscernibleUpdateTime;
if (IsLayoutDirty)
{
ResetDirtyFlag();
RebuildQurdTree();
}
else if (IsTransformDirty)
{
ResetDirtyFlag();
}
}
protected override void OnRectTransformDimensionsChange()
{
base.OnRectTransformDimensionsChange();
CheckIsDirty();
}
private void OnDestroy()
{
// 恢复所有元素的原始状态
foreach (var element in _allObjsSet)
{
if (element != null && _activeSet.Contains(element))
{
element.gameObject.SetActive(true);
}
}
}
#endregion
#region 刷新框架
/// <summary>
/// 设置布局脏
/// </summary>
public void LayoutDirty()
=> _layoutDirty = true;
/// <summary>
/// 设置变换脏
/// </summary>
public void TransformDirty()
=> _transformDirty = true;
/// <summary>
/// 重置脏标记
/// </summary>
private void ResetDirtyFlag()
{
_layoutDirty = false;
_transformDirty = false;
}
/// <summary>
/// 检查容器脏
/// </summary>
public virtual void CheckIsDirty()
{
if (QuadUITargetTree == null || // 四叉树不存在?
_lastChildrenCount != ContainerChildrenCount // 成员数量明显不对
)
{
LayoutDirty();
BackupQuadRectValue();
return;
}
// todo 这里暂时只简单考虑直来直去的移动方式,如果有其他复杂移动,需要修改判别条件
var deltaPosIsDirty = Mathf.Abs(QuadUITargetTree.RootRect.position.sqrMagnitude - _lastPosition.sqrMagnitude) < discernibleDistance;
if (deltaPosIsDirty // 发生不可忽视的变换?
)
{
TransformDirty();
BackupQuadRectValue();
return;
}
}
/// <summary>
/// 重建四叉树
/// </summary>
public void RebuildQurdTree()
{
// 重建四叉树
QuadUITargetTree = new QuadTree<RectTransform>(ContainerTransformRectRange, a => a.rect);
// 记录参数
BackupQuadRectValue();
}
/// <summary>
/// 保存自动重建四叉树所需的参数
/// </summary>
/// <param name="target"></param>
protected virtual void BackupQuadRectValue()
{
_lastPosition = ContainerTransformRectRange.position;
_lastChildrenCount = ContainerChildrenCount;
}
#if ODIN_INSPECTOR
[Button("更新所有可见性")]
#endif
/// <summary>
/// 更新所有成员的可见性
/// </summary>
public virtual void UpdateAllObjectVisible()
{
if (QuadUITargetTree == null)
{
Debug.LogError($"在更新成员可见性之前,需要确保四叉树存在!");
return;
}
var visibalSet = QuadUITargetTree.Retrieve(MaskRectTransformRectRange);
ContainerChildren.ForEachDo(obj =>
{
obj.gameObject.SetActive(visibalSet.Contains(obj));
});
}
/// <summary>
/// 要求控制布局组的激活一次,提供为何激活的原因
/// </summary>
/// <param name="hasIncreased">由于新增成员导致布局更新</param>
/// <param name="hasDecreased">由于销毁成员导致布局更新</param>
public virtual void UpdateLayoutGroupOnce(bool hasIncreased, bool hasDecreased)
{
MainLayoutGroup.enabled = true;
StartCoroutine(WaitForLayoutDisable());
return;
IEnumerator WaitForLayoutDisable()
{
if (CanvasUpdateRegistry.IsRebuildingLayout())
yield return new WaitForEndOfFrame();
else
yield return new WaitForFixedUpdate();
MainLayoutGroup.enabled = false;
}
}
#endregion
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 48016863e20f4c61a91245c593d575da
timeCreated: 1757990480