diff --git a/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs b/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs new file mode 100644 index 0000000..6deddd3 --- /dev/null +++ b/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs @@ -0,0 +1,495 @@ +/// Credit [PeterParkers007] +/// Sourced from - [https://github.com/PeterParkers007] +/// Based on FlowLayoutGroup by Simie - http://forum.unity3d.com/threads/flowlayoutgroup.296709/ +/// Enhanced with: +/// - Multiple sizing modes (AutoSize, UniformCell, UniformCellKeepAspect, StretchRow) +/// - Fixed columns per row support +/// - Reverse wrap direction +/// - Aspect ratio preservation +/// - Improved handling of nested LayoutGroups +using System.Collections.Generic; +using UnityEngine.UI; + +namespace UnityEngine.UI.Extensions +{ + /// + /// Smart Flow Layout Group - Enhanced version of FlowLayoutGroup with multiple sizing modes. + /// Supports uniform cells, aspect ratio preservation, stretch rows, and reverse wrapping. + /// + [AddComponentMenu("Layout/Extensions/Smart Flow Layout Group")] + [ExecuteAlways] + [DisallowMultipleComponent] + public class SmartFlowLayoutGroup : LayoutGroup + { + public enum Axis { Horizontal = 0, Vertical = 1 } + + public enum SizingMode + { + /// Reads child PreferredSize (original FlowLayoutGroup behavior). + AutoSize = 0, + /// Forces all children to CellSize, ignoring original proportions. + UniformCell = 1, + /// Uniform cell size; children scale to fit inside while preserving aspect ratio. + UniformCellKeepAspect = 2, + /// List rows: width fills container, height uses PreferredHeight. CellSize.y as minimum row height. + StretchRow = 3, + } + + [Tooltip("Flow start axis: Horizontal = left to right, wrap on overflow; Vertical = top to bottom, wrap on overflow")] + [SerializeField] Axis m_StartAxis = Axis.Horizontal; + + [Tooltip("AutoSize=read preferred; UniformCell=force CellSize; KeepAspect=icon grid; StretchRow=full width list")] + [SerializeField] SizingMode m_SizingMode = SizingMode.UniformCellKeepAspect; + + [Tooltip("Uniform cell size (used by UniformCell/KeepAspect; StretchRow uses y as minimum row height)")] + [SerializeField] Vector2 m_CellSize = new Vector2(100f, 100f); + + [Tooltip("≤0 = auto wrap by container width; >0 = fixed columns per row (Horizontal axis only)")] + [SerializeField] int m_ColumnsPerRow = 0; + + [Tooltip("Horizontal spacing between elements")] + [SerializeField] float m_SpacingX = 0f; + + [Tooltip("Vertical spacing between rows")] + [SerializeField] float m_SpacingY = 0f; + + [Tooltip("Stretch children height to fill the row height")] + [SerializeField] bool m_ChildForceExpandHeight = false; + + [Tooltip("AutoSize mode: stretch children width to fill container width")] + [SerializeField] bool m_ChildForceExpandWidth = false; + + [Tooltip("Reverse wrap direction: true = first row at bottom, stacking upward (Horizontal axis only)")] + [SerializeField] bool m_ReverseWrap = false; + + /// Flow start axis. + public Axis StartAxis { get => m_StartAxis; set => SetProperty(ref m_StartAxis, value); } + + /// Sizing mode determines how child elements are sized. + public SizingMode Sizing { get => m_SizingMode; set => SetProperty(ref m_SizingMode, value); } + + /// Uniform cell size for grid-based modes. + public Vector2 CellSize { get => m_CellSize; set => SetProperty(ref m_CellSize, value); } + + /// Fixed columns per row (0 = auto). + public int ColumnsPerRow + { + get => m_ColumnsPerRow; + set => SetProperty(ref m_ColumnsPerRow, Mathf.Max(0, value)); + } + + /// Spacing between elements (X = horizontal, Y = vertical). + public Vector2 Spacing + { + get => new Vector2(m_SpacingX, m_SpacingY); + set + { + m_SpacingX = value.x; + m_SpacingY = value.y; + SetDirty(); + } + } + + /// Horizontal spacing between elements. + public float SpacingX { get => m_SpacingX; set => SetProperty(ref m_SpacingX, value); } + + /// Vertical spacing between rows. + public float SpacingY { get => m_SpacingY; set => SetProperty(ref m_SpacingY, value); } + + /// Stretch children to fill row height. + public bool ChildForceExpandHeight + { + get => m_ChildForceExpandHeight; + set => SetProperty(ref m_ChildForceExpandHeight, value); + } + + /// Stretch children to fill container width in AutoSize mode. + public bool ChildForceExpandWidth + { + get => m_ChildForceExpandWidth; + set => SetProperty(ref m_ChildForceExpandWidth, value); + } + + /// Reverse wrap direction (first row at bottom). + public bool ReverseWrap + { + get => m_ReverseWrap; + set => SetProperty(ref m_ReverseWrap, value); + } + + float _layoutWidth; + float _layoutHeight; + + public override void CalculateLayoutInputHorizontal() + { + base.CalculateLayoutInputHorizontal(); + if (m_StartAxis == Axis.Horizontal) + { + float min = GetGreatestMinimumChildWidth() + padding.left + padding.right; + SetLayoutInputForAxis(min, -1, -1, 0); + } + else + { + _layoutWidth = SetLayout(0, true); + } + } + + public override void CalculateLayoutInputVertical() + { + if (m_StartAxis == Axis.Horizontal) + { + _layoutHeight = SetLayout(1, true); + } + else + { + float min = GetGreatestMinimumChildHeight() + padding.top + padding.bottom; + SetLayoutInputForAxis(min, -1, -1, 1); + } + } + + public override void SetLayoutHorizontal() => SetLayout(0, false); + public override void SetLayoutVertical() => SetLayout(1, false); + + /// Main layout calculation. Returns total size along the cross axis. + float SetLayout(int axis, bool layoutInput) + { + float groupWidth = rectTransform.rect.width; + float groupHeight = rectTransform.rect.height; + + bool horizontal = m_StartAxis == Axis.Horizontal; + float workingSize = horizontal + ? groupWidth - padding.left - padding.right + : groupHeight - padding.top - padding.bottom; + + // Handle zero-size rect on first frame + if (workingSize <= 0.01f && horizontal) + workingSize = Mathf.Max(0f, GetGreatestMinimumChildWidth()); + + var entries = new List(rectChildren.Count); + for (int i = 0; i < rectChildren.Count; i++) + entries.Add(ResolveEntry(rectChildren[i], workingSize, horizontal)); + + // Group entries into lines (rows or columns) + var lines = new List>(); + var currentLine = new List(); + float lineCursor = 0f; + + int columnsPerRow = (horizontal && m_SizingMode != SizingMode.StretchRow) + ? m_ColumnsPerRow + : 0; + + float mainSpacing = horizontal ? m_SpacingX : m_SpacingY; + + for (int i = 0; i < entries.Count; i++) + { + var e = entries[i]; + float itemSize = horizontal ? e.SlotW : e.SlotH; + + bool exceedsWidth = currentLine.Count > 0 + && lineCursor + mainSpacing + itemSize > workingSize + 0.01f; + bool exceedsColumns = columnsPerRow > 0 && currentLine.Count >= columnsPerRow; + + if (currentLine.Count > 0 && (exceedsWidth || exceedsColumns)) + { + lines.Add(currentLine); + currentLine = new List(); + lineCursor = 0f; + } + + if (currentLine.Count > 0) + lineCursor += mainSpacing; + currentLine.Add(e); + lineCursor += itemSize; + } + if (currentLine.Count > 0) + lines.Add(currentLine); + + // Fill incomplete rows when using fixed columns + if (columnsPerRow > 0) + { + for (int i = 0; i < lines.Count; i++) + { + while (lines[i].Count < columnsPerRow) + lines[i].Add(Entry.Empty); + } + } + + bool reverse = m_ReverseWrap && horizontal; + float offset; + if (reverse) + offset = groupHeight - padding.bottom; + else + offset = horizontal ? padding.top : padding.left; + + float crossSpacing = horizontal ? m_SpacingY : m_SpacingX; + float totalCross = 0f; + + for (int li = 0; li < lines.Count; li++) + { + var line = lines[li]; + float lineMain = 0f; + float lineCross = 0f; + + for (int j = 0; j < line.Count; j++) + { + var e = line[j]; + lineMain += (j > 0 ? mainSpacing : 0f) + (horizontal ? e.SlotW : e.SlotH); + if (e.IsValid) + lineCross = Mathf.Max(lineCross, horizontal ? e.SlotH : e.SlotW); + } + + if (m_ChildForceExpandHeight && horizontal + && m_SizingMode != SizingMode.UniformCellKeepAspect + && m_SizingMode != SizingMode.StretchRow) + lineCross = Mathf.Max(lineCross, m_CellSize.y); + + if (reverse) + { + offset -= lineCross; + if (!layoutInput) + PlaceLine(line, lineMain, lineCross, workingSize, offset, axis, horizontal, mainSpacing); + offset -= crossSpacing; + } + else + { + if (!layoutInput) + PlaceLine(line, lineMain, lineCross, workingSize, offset, axis, horizontal, mainSpacing); + offset += lineCross + crossSpacing; + } + totalCross += lineCross + (li > 0 ? crossSpacing : 0f); + } + totalCross += (horizontal ? padding.top + padding.bottom : padding.left + padding.right); + + if (layoutInput) + { + if (horizontal) + { + _layoutHeight = totalCross; + SetLayoutInputForAxis(totalCross, totalCross, -1, 1); + } + else + { + _layoutWidth = totalCross; + SetLayoutInputForAxis(totalCross, totalCross, -1, 0); + } + } + + return totalCross; + } + + void PlaceLine(List line, float lineMain, float lineCross, float workingSize, + float crossOffset, int axis, bool horizontal, float mainSpacing) + { + float mainStart = horizontal ? padding.left : padding.top; + float slack = workingSize - lineMain; + + if (slack > 0f) + { + if (IsCenterAlign) mainStart += slack * 0.5f; + else if (IsRightAlign) mainStart += slack; + } + + float cursor = mainStart; + for (int j = 0; j < line.Count; j++) + { + var e = line[j]; + if (j > 0) + cursor += mainSpacing; + + if (e.IsValid) + { + float slotW = e.SlotW; + float slotH = e.SlotH; + float childW = e.Width; + float childH = e.Height; + + // Handle height expansion (except KeepAspect and StretchRow modes) + if (m_ChildForceExpandHeight && horizontal + && m_SizingMode != SizingMode.UniformCellKeepAspect + && m_SizingMode != SizingMode.StretchRow) + { + slotH = lineCross; + childH = lineCross; + } + + float x = cursor + (slotW - childW) * 0.5f; + float y = crossOffset + (lineCross - childH) * 0.5f; + + // Left-align for StretchRow and ExpandWidth modes + if (m_SizingMode == SizingMode.StretchRow + || (m_ChildForceExpandWidth && horizontal && m_SizingMode == SizingMode.AutoSize)) + { + x = cursor; + y = crossOffset; + } + + if (axis == 0) + SetChildAlongAxis(e.Child, 0, x, childW); + else + SetChildAlongAxis(e.Child, 1, y, childH); + + if (horizontal) + SetChildAlongAxis(e.Child, 1, y, childH); + else + SetChildAlongAxis(e.Child, 0, x, childW); + + // Rebuild nested LayoutGroups to prevent layout corruption + if (e.Child.TryGetComponent(out var nestedLayout)) + LayoutRebuilder.ForceRebuildLayoutImmediate(e.Child); + } + cursor += (horizontal ? e.SlotW : e.SlotH); + } + } + + struct Entry + { + public RectTransform Child; + public float SlotW; + public float SlotH; + public float Width; + public float Height; + public bool IsValid => Child != null; + + public static Entry Empty => new Entry { Child = null, SlotW = 0f, SlotH = 0f, Width = 0f, Height = 0f }; + } + + /// Resolves child dimensions based on SizingMode. + Entry ResolveEntry(RectTransform child, float workingMain, bool horizontal) + { + float prefW = LayoutUtility.GetPreferredSize(child, 0); + float prefH = LayoutUtility.GetPreferredSize(child, 1); + + // Fallback when preferred sizes are not properly declared + if (prefW <= 0.01f) prefW = child.rect.width; + if (prefH <= 0.01f) prefH = child.rect.height; + if (prefW <= 0.01f) prefW = m_CellSize.x > 0.01f ? m_CellSize.x : 1f; + if (prefH <= 0.01f) prefH = m_CellSize.y > 0.01f ? m_CellSize.y : 1f; + + switch (m_SizingMode) + { + case SizingMode.UniformCell: + return new Entry + { + Child = child, + SlotW = m_CellSize.x, + SlotH = m_CellSize.y, + Width = m_CellSize.x, + Height = m_CellSize.y + }; + + case SizingMode.UniformCellKeepAspect: + { + float scale = Mathf.Min(m_CellSize.x / prefW, m_CellSize.y / prefH); + return new Entry + { + Child = child, + SlotW = m_CellSize.x, + SlotH = m_CellSize.y, + Width = prefW * scale, + Height = prefH * scale + }; + } + + case SizingMode.StretchRow: + { + float w = workingMain > 0.01f ? workingMain : prefW; + float h = ResolveRowHeight(child, prefH); + return new Entry + { + Child = child, + SlotW = w, + SlotH = h, + Width = w, + Height = h + }; + } + + case SizingMode.AutoSize: + default: + { + float w = prefW; + float h = prefH; + + if (horizontal && m_ChildForceExpandWidth && workingMain > 0.01f) + w = workingMain; + else if (horizontal && workingMain > 0.01f && w > workingMain) + w = workingMain; + + if (!horizontal && m_ChildForceExpandWidth && workingMain > 0.01f) + h = workingMain; + + return new Entry + { + Child = child, + SlotW = w, + SlotH = h, + Width = w, + Height = h + }; + } + } + } + + float ResolveRowHeight(RectTransform child, float prefH) + { + float h = prefH; + + if (child.TryGetComponent(out var le)) + { + if (le.preferredHeight > 0.01f) + h = le.preferredHeight; + else if (le.minHeight > 0.01f) + h = le.minHeight; + } + + // Clamp to reasonable height to prevent layout corruption + float cap = m_CellSize.y > 0.01f ? m_CellSize.y * 3f : 200f; + if (h > cap) + h = m_CellSize.y > 0.01f ? m_CellSize.y : 56f; + + if (m_CellSize.y > 0.01f) + h = Mathf.Max(h, m_CellSize.y); + + return h; + } + + bool IsCenterAlign => + childAlignment == TextAnchor.UpperCenter + || childAlignment == TextAnchor.MiddleCenter + || childAlignment == TextAnchor.LowerCenter; + + bool IsRightAlign => + childAlignment == TextAnchor.UpperRight + || childAlignment == TextAnchor.MiddleRight + || childAlignment == TextAnchor.LowerRight; + + float GetGreatestMinimumChildWidth() + { + float max = 0f; + for (int i = 0; i < rectChildren.Count; i++) + max = Mathf.Max(max, LayoutUtility.GetMinWidth(rectChildren[i])); + return max; + } + + float GetGreatestMinimumChildHeight() + { + float max = 0f; + for (int i = 0; i < rectChildren.Count; i++) + max = Mathf.Max(max, LayoutUtility.GetMinHeight(rectChildren[i])); + return max; + } + + protected override void OnEnable() + { + base.OnEnable(); + LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform); + } + + protected override void OnDisable() + { + m_Tracker.Clear(); + LayoutRebuilder.MarkLayoutForRebuild(rectTransform); + base.OnDisable(); + } + } +} diff --git a/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs.meta b/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs.meta new file mode 100644 index 0000000..e122d0e --- /dev/null +++ b/Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1a2b3c4d5e6478901a2b3c4d5e6f708 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: