From cbc365f3296a9dd30f5734510cf4c6ca3ecfc58d Mon Sep 17 00:00:00 2001
From: Peter <3427463164@qq.com>
Date: Mon, 31 Aug 2026 01:06:25 +0800
Subject: [PATCH] Add SmartFlowLayoutGroup component
Enhanced version of FlowLayoutGroup that adds several sizing modes. Supports uniform grid cells, aspect ratio preservation for icons, stretch rows for list items, and reverse wrap direction for panels that stack from the bottom. Also fixes the issue where nested LayoutGroups weren't rebuilding properly after layout changes.
It's a new component placed alongside the existing FlowLayoutGroup, so nothing breaks for current users. Tested in Unity 2021.3 with ContentSizeFitter and dynamic children.
---
.../Scripts/Layout/SmartFlowLayoutGroup.cs | 495 ++++++++++++++++++
.../Layout/SmartFlowLayoutGroup.cs.meta | 11 +
2 files changed, 506 insertions(+)
create mode 100644 Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs
create mode 100644 Runtime/Scripts/Layout/SmartFlowLayoutGroup.cs.meta
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: