开发中碰到的需求:在滚动视图中添加不同尺寸的图片,并尽量保持紧密排列,如上图所示
在Asset Store中发现插件 Optimized ScrollView Adapter 已经实现了这个功能,这里截取其中的关键代码做演示
原理
插件使用了二叉树装箱算法(Binary Tree Bin Packing Algorithm)
当我们在整个区域内添加一个黄色方块后,可以把整个区域分成3个矩形:黄色矩形,矩形A,矩形B
这里有两种划分方法,一种是矩形A与黄色方块等高,一种是矩形B与黄色方块等宽,如图所示,在代码中设计一些规则来决定使用哪种划分方式
然后就可以构建二叉树,记录整个空间的划分,根节点Root记录第一个黄色方块的位置,大小等信息,它的两个节点分别记录右侧区域和下面区域的信息,当添加第二个方块时,检查A,B那个区域能放下,如果放在区域A下,则按照上面的方式再次划分,区域A记录第二个方块位置,大小信息,它的两个子节点记录两个新划分的区域,这样递归下去
下面是这部分算法的代码
using System;
using System.Collections.Generic;
/// <summary>
/// Heavily modified version of https://github.com/cariquitanmac/2D-Bin-Pack-Binary-Search,
/// which is a C# implementation of Jakes Gordon Binary Tree Algorithm for 2D Bin Packing https://github.com/jakesgordon/bin-packing/
/// <para>All rights go to the original author.</para>
/// </summary>
public class Packer2DBox
{
List<Box> _Boxes;
Node _RootNode;
double _Spacing;
bool _AlternatingStrategyBiggerRightNode;
bool _AlternatingOtherStrategyBiggerRightNode;
double _ContainerWidth;
double _ContainerHeight;
// 分割节点时的策略
NodeChoosingStrategy _ChoosingStrategy;
public Packer2DBox(double containerWidth, double containerHeight, double spacing)
{
this._ContainerWidth = containerWidth;
this._ContainerHeight = containerHeight;
this._Spacing = spacing;
}
public void Pack(List<Box> boxes, bool sort, NodeChoosingStrategy choosingStrategy, out double totalWidth, out double totalHeight)
{
_ChoosingStrategy = choosingStrategy;
_AlternatingStrategyBiggerRightNode = _ChoosingStrategy == NodeChoosingStrategy.ALTERNATING_START_WITH_RIGHT;
_RootNode = new Node(0d, 0d) { height = _ContainerHeight, width = _ContainerWidth };
_Boxes = boxes;
if (sort)
{
// Biggest boxes first with maxside, then secondarily by volume
// More info: https://codeincomplete.com/posts/bin-packing/
_Boxes.Sort((a, b) =>
{
var aMax = System.Math.Max(a.width, a.height);
var bMax = System.Math.Max(b.width, b.height);
if (aMax != bMax)
return (int)(bMax - aMax);
return (int)(b.volume - a.volume);
});
}
totalWidth = 0f;
totalHeight = 0f;
foreach (var box in _Boxes)
{
Node node = null;
FindNode(_RootNode, box.width, box.height, ref node);
if (node != null)
{
// Split rectangles
box.position = SplitNode(node, box.width, box.height);
double width = box.position.x + box.width;
if (width > totalWidth)
totalWidth = width;
double height = box.position.y + box.height;
if (height > totalHeight)
totalHeight = height;
}
}
}
void FindNode(Node rootNode, double boxWidth, double boxHeight, ref Node node)
{
if (rootNode.isOccupied)
{
FindNode(rootNode.rightNode, boxWidth, boxHeight, ref node);
FindNode(rootNode.bottomNode, boxWidth, boxHeight, ref node);
}
else if (boxWidth <= rootNode.width && boxHeight <= rootNode.height)
{
if (node == null || rootNode.distFromOrigin < node.distFromOrigin)
node = rootNode;
}
}
/// <summary>
/// 分割节点,有两种分割方法,按策略选择
/// 方法1.右侧节点与填入的box一样高
/// 方法2.下面节点与填入的box一样宽
/// </summary>
Node SplitNode(Node node, double boxWidth, double boxHeight)
{
node.isOccupied = true;
double rightNodeFullWidth = node.width - (boxWidth + _Spacing);
double rightNodeFullHeight = node.height;
double bottomNodeFullWidth = node.width;
double bottomNodeFullHeight = node.height - (boxHeight + _Spacing);
bool biggerRightNode;
var localStrategy = _ChoosingStrategy;
if (localStrategy == NodeChoosingStrategy.MAX_VOLUME)
{
// 比较box面积,如果分割后右侧节点的面积更大,就按照方法1分割
double rightVolume = rightNodeFullWidth * rightNodeFullHeight;
double bottomVolume = bottomNodeFullWidth * bottomNodeFullHeight;
if (rightVolume == bottomVolume)
{
// In case of equality, alternate between what we chose the last time
biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
}
else
{
biggerRightNode = rightVolume > bottomVolume;
}
}
else if (localStrategy == NodeChoosingStrategy.MAX_SIDE)
{
// 比较两个节点的最大边长,边长大的优先
double rightMaxSide = Math.Max(rightNodeFullWidth, rightNodeFullHeight);
double bottomMaxSide = Math.Max(bottomNodeFullWidth, bottomNodeFullHeight);
if (rightMaxSide == bottomMaxSide)
{
// In case of equality, alternate between what we chose the last time
biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
}
else
{
biggerRightNode = rightMaxSide > bottomMaxSide;
}
}
else if (localStrategy == NodeChoosingStrategy.MAX_SUM)
{
// 比较两个节点的边长和,结果大的优先
double rightSum = rightNodeFullWidth + rightNodeFullHeight;
double bottomSum = bottomNodeFullWidth + bottomNodeFullHeight;
if (rightSum == bottomSum)
{
// In case of equality, alternate between what we chose the last time
biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
}
else
{
biggerRightNode = rightSum > bottomSum;
}
}
else
{
if (_ChoosingStrategy == NodeChoosingStrategy.RIGHT)
biggerRightNode = true;
else if (_ChoosingStrategy == NodeChoosingStrategy.BOTTOM)
biggerRightNode = false;
else
{
// Alternating
biggerRightNode = _AlternatingStrategyBiggerRightNode;
_AlternatingStrategyBiggerRightNode = !_AlternatingStrategyBiggerRightNode;
}
}
node.rightNode = new Node(node.x + (boxWidth + _Spacing), node.y)
{
depth = node.depth + 1,
width = rightNodeFullWidth,
height = biggerRightNode ? node.height : boxHeight
};
node.bottomNode = new Node(node.x, node.y + (boxHeight + _Spacing))
{
depth = node.depth + 1,
width = biggerRightNode ? boxWidth : node.width,
height = bottomNodeFullHeight
};
return node;
}
public class Node
{
public int depth;
public Node rightNode;
public Node bottomNode;
// 节点左上角坐标
public double x;
public double y;
public double width;
public double height;
readonly public double distFromOrigin;
// 节点被占用,也就是被分割了
public bool isOccupied;
public Node(double x, double y)
{
this.x = x;
this.y = y;
distFromOrigin = Math.Sqrt(x * x + y * y);
}
}
public class Box
{
public double height;
public double width;
public double volume;
public Node position;
public Box(double width, double height)
{
this.width = width;
this.height = height;
volume = width * height;
}
}
/// <summary>Note expanding choices, in order of success rate</summary>
public enum NodeChoosingStrategy
{
MAX_VOLUME,
MAX_SUM,
MAX_SIDE,
ALTERNATING_START_WITH_BOTTOM,
RIGHT,
BOTTOM,
ALTERNATING_START_WITH_RIGHT, // same as BOTTOM, in 99% of cases
COUNT_
}
}
作者设计了几种划分空间的策略,如根据区域面积,边长和,最大边长等
还需要一个布局脚本管理所有的子节点
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Layout class to arrange children elements in a circular grid format. Circular in the . Items will try to occupy as much space as possible.
/// </summary>
public class PackedGridLayoutGroup : LayoutGroup
{
[SerializeField] protected float m_ForcedSpacing = 0f;
[Tooltip("The specified axis will have the 'Preferred' size set based on children")]
[SerializeField] protected AxisOrNone m_childrenControlSize = AxisOrNone.Vertical;
/// <summary>
/// 执行每个策略算一个pass,然后对比不同策略的空间填充率,填充率要求不高设置为1就行
/// </summary>
[Tooltip("Set to as many as possible, if the FPS allows")]
[Range(1, (int)Packer2DBox.NodeChoosingStrategy.COUNT_)]
[SerializeField] protected int m_numPasses = (int)Packer2DBox.NodeChoosingStrategy.COUNT_;
/// <summary>
/// The spacing to use between layout elements in the grid on both axes.
/// The spacing is created by shrinking the childrens' sizes rather than actually adding spaces.
/// If you want true spacing, consider modifying the children themselves to also include some padding inside them
/// </summary>
public float ForcedSpacing { get { return m_ForcedSpacing; } set { SetProperty(ref m_ForcedSpacing, value); } }
/// <summary>
/// The specified axis will have the 'Preferred' size set based on children
/// </summary>
public AxisOrNone ChildrenControlSize { get { return m_childrenControlSize; } set { SetProperty(ref m_childrenControlSize, value); } }
protected PackedGridLayoutGroup() { }
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
}
#endif
public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();
float minWidthToSet;
float preferredWidthToSet;
if (m_childrenControlSize == AxisOrNone.Horizontal)
{
float width, _;
GetChildSetups(out width, out _);
minWidthToSet = preferredWidthToSet = width + padding.horizontal;
}
else
{
minWidthToSet = minWidth;
preferredWidthToSet = preferredWidth;
}
SetLayoutInputForAxis(minWidthToSet, preferredWidthToSet, -1, 0);
}
public override void CalculateLayoutInputVertical()
{
float minHeightToSet;
float preferredHeightToSet;
if (m_childrenControlSize == AxisOrNone.Vertical)
{
float _, height;
GetChildSetups(out _, out height);
minHeightToSet = preferredHeightToSet = height + padding.vertical;
}
else
{
minHeightToSet = minHeight;
preferredHeightToSet = preferredHeight;
}
SetLayoutInputForAxis(minHeightToSet, preferredHeightToSet, -1, 1);
}
public override void SetLayoutHorizontal()
{
LayoutChildren(true, false);
}
public override void SetLayoutVertical()
{
LayoutChildren(false, true);
}
void GetInsetAndEdges(float chWidth, float chHeight, out RectTransform.Edge xInsetEdge, out RectTransform.Edge yInsetEdge, out float xAddInset, out float yAddInset)
{
var gridRect = rectTransform.rect;
xInsetEdge = RectTransform.Edge.Left;
yInsetEdge = RectTransform.Edge.Top;
xAddInset = 0f;
yAddInset = 0f;
if (m_childrenControlSize != AxisOrNone.Horizontal)
{
switch (childAlignment)
{
case TextAnchor.UpperLeft:
case TextAnchor.LowerLeft:
case TextAnchor.MiddleLeft:
xInsetEdge = RectTransform.Edge.Left;
xAddInset = padding.left;
break;
case TextAnchor.UpperRight:
case TextAnchor.LowerRight:
case TextAnchor.MiddleRight:
xInsetEdge = RectTransform.Edge.Right;
xAddInset = padding.right;
break;
case TextAnchor.UpperCenter:
case TextAnchor.MiddleCenter:
case TextAnchor.LowerCenter:
xAddInset = Mathf.Max((gridRect.width - chWidth), padding.horizontal) / 2f;
break;
}
}
if (m_childrenControlSize != AxisOrNone.Vertical)
{
switch (childAlignment)
{
case TextAnchor.UpperLeft:
case TextAnchor.UpperCenter:
case TextAnchor.UpperRight:
yInsetEdge = RectTransform.Edge.Top;
yAddInset = padding.top;
break;
case TextAnchor.LowerLeft:
case TextAnchor.LowerCenter:
case TextAnchor.LowerRight:
yInsetEdge = RectTransform.Edge.Bottom;
yAddInset = padding.bottom;
break;
case TextAnchor.MiddleLeft:
case TextAnchor.MiddleCenter:
case TextAnchor.MiddleRight:
yAddInset = Mathf.Max((gridRect.height - chHeight), padding.vertical) / 2f;
break;
}
}
}
void LayoutChildren(bool hor, bool vert)
{
float chWidth, chHeight;
var setups = GetChildSetups(out chWidth, out chHeight);
RectTransform.Edge xInsetEdge, yInsetEdge;
float xAddInset, yAddInset;
GetInsetAndEdges(chWidth, chHeight, out xInsetEdge, out yInsetEdge, out xAddInset, out yAddInset);
foreach (var child in setups)
{
var c = child as ChildSetup;
if (c.box.position == null)
continue;
if (hor)
c.child.SetInsetAndSizeFromParentEdge(xInsetEdge, (float)c.box.position.x + xAddInset, (float)c.box.width - ForcedSpacing);
if (vert)
c.child.SetInsetAndSizeFromParentEdge(yInsetEdge, (float)c.box.position.y + yAddInset, (float)c.box.height - ForcedSpacing);
}
}
List<ChildSetup> GetChildSetups(out float width, out float height)
{
var list = new List<ChildSetup>(rectChildren.Count);
foreach (var child in rectChildren)
{
float chWidth = LayoutUtility.GetPreferredSize(child, 0);
float chHeight = LayoutUtility.GetPreferredSize(child, 1);
list.Add(new ChildSetup(chWidth, chHeight, child));
}
float availableWidth, availableHeight;
if (m_childrenControlSize == AxisOrNone.Horizontal)
{
availableWidth = float.MaxValue;
availableHeight = rectTransform.rect.height - padding.vertical;
}
else if (m_childrenControlSize == AxisOrNone.Vertical)
{
availableWidth = rectTransform.rect.width - padding.horizontal;
availableHeight = float.MaxValue;
}
else
{
availableHeight = rectTransform.rect.height - padding.vertical;
availableWidth = rectTransform.rect.width - padding.horizontal;
}
// Spacing usually creates mode big empty spaces where no item can fit
float spacingToUse = 0f;
var packer = new Packer2DBox(availableWidth, availableHeight, spacingToUse);
int maxStrategiesToUse = m_numPasses;
List<Packer2DBox.Box>[] boxesPerStrategy = new List<Packer2DBox.Box>[maxStrategiesToUse];
int[] nullPositionsPerStrategy = new int[maxStrategiesToUse];
double[] totalWidthsPerStrategy = new double[maxStrategiesToUse];
double[] totalHeightsPerStrategy = new double[maxStrategiesToUse];
int iBest = -1;
bool copyBoxesForFinalResult = maxStrategiesToUse > 1;
for (int i = 0; i < maxStrategiesToUse; i++)
{
var boxesThisPass = i == 0 ? list.ConvertAll(c => c.box) : list.ConvertAll(c => { c.ReinitBox(); return c.box; });
double totalWidthThisPass;
double totalHeightThisPass;
packer.Pack(boxesThisPass, false, (Packer2DBox.NodeChoosingStrategy)i, out totalWidthThisPass, out totalHeightThisPass);
int thisPassNullPositions = 0;
for (int j = 0; j < boxesThisPass.Count; j++)
{
var b = boxesThisPass[j];
if (b.position == null)
++thisPassNullPositions;
}
boxesPerStrategy[i] = boxesThisPass;
nullPositionsPerStrategy[i] = thisPassNullPositions;
totalWidthsPerStrategy[i] = totalWidthThisPass;
totalHeightsPerStrategy[i] = totalHeightThisPass;
if (iBest == -1)
{
iBest = i;
if (thisPassNullPositions == 0)
{
// Boxes won't be overridden by next strategies, so no need to copy them
copyBoxesForFinalResult = false;
// First pass is Packer2DBox.NodeChoosingStrategy.MAX_VOLUME and all boxes were fit => there's no better strategy
break;
}
continue;
}
if (thisPassNullPositions < nullPositionsPerStrategy[iBest])
{
iBest = i;
continue;
}
if (thisPassNullPositions > nullPositionsPerStrategy[iBest])
continue;
if (totalWidthThisPass * totalHeightThisPass < totalWidthsPerStrategy[i] * totalHeightsPerStrategy[i])
{
iBest = i;
continue;
}
}
if (copyBoxesForFinalResult)
{
var bestBoxes = boxesPerStrategy[iBest];
for (int i = 0; i < list.Count; i++)
list[i].box = bestBoxes[i];
}
width = (float)totalWidthsPerStrategy[iBest];
height = (float)totalHeightsPerStrategy[iBest];
return list;
}
public enum AxisOrNone
{
Horizontal = RectTransform.Axis.Horizontal,
Vertical = RectTransform.Axis.Vertical,
None
}
class ChildSetup
{
public RectTransform child;
public Packer2DBox.Box box;
public ChildSetup(double width, double height, RectTransform child)
{
this.child = child;
box = new Packer2DBox.Box(width, height);
}
public void ReinitBox() { box = new Packer2DBox.Box(box.width, box.height); }
}
}
实现
新建一个Scroll View,在Content节点上添加布局脚本,注意Content的区域大小需要根据所有子节点占用宽高做调整,布局脚本PackedGridLayoutGroup 中的 GetChildSetups 方法可以计算填充后所有子对象占用的高度和宽度
Forced Spacing 控制子对象的间距,它是通过缩小子对象的宽高实现的
Num Passes 执行每个分割策略算一个pass,然后对比不同策略的空间填充率,填充率要求不高设置为1就行
Image作为模板,添加Layout Element组件,使用Preferred Width和Preferred Height控制宽高
using UnityEngine;
using UnityEngine.UI;
using Random = UnityEngine.Random;
public class PackDemo : MonoBehaviour
{
public Image template;
// Scroll View 下的content节点
public Transform content;
// 生成数量
public int generateCount;
private int lastCount;
void Start()
{
lastCount = generateCount;
GenerateCell();
}
void Update()
{
if (generateCount != lastCount)
{
lastCount = generateCount;
GenerateCell();
}
}
private void GenerateCell()
{
for (int i = 0; i < content.childCount; ++i)
{
var tf = content.GetChild(i);
DestroyImmediate(tf.gameObject);
}
for (int i = 0; i < generateCount; ++i)
{
var go = Instantiate(template, content);
go.gameObject.SetActive(true);
go.color = new Color(Random.Range(0, 1f), Random.Range(0, 1f), Random.Range(0, 1f));
var layout = go.GetComponent<LayoutElement>();
layout.preferredWidth = Random.Range(1, 5) * 100;
layout.preferredHeight = Random.Range(1, 5) * 100;
}
}
}
这里写了一个简单的脚本进行测试,会在Content节点下生成一定数量的Image,并随机颜色和大小
运行效果
优化
在脚本 PackedGridLayoutGroup 的 GetChildSetups 方法中会比较不同分割策略并选出最好的一个策略,但是一般情况下使用第一个面积分割策略就可以了,这里简化了源码来减少new对象
public List<ChildSetup> GetChildSetups(out float width, out float height)
{
var list = new List<ChildSetup>(rectChildren.Count);
foreach (var child in rectChildren)
{
float chWidth = LayoutUtility.GetPreferredSize(child, 0);
float chHeight = LayoutUtility.GetPreferredSize(child, 1);
list.Add(new ChildSetup(chWidth, chHeight, child));
}
float availableWidth, availableHeight;
if (m_childrenControlSize == AxisOrNone.Horizontal)
{
availableWidth = float.MaxValue;
availableHeight = rectTransform.rect.height - padding.vertical;
}
else if (m_childrenControlSize == AxisOrNone.Vertical)
{
availableWidth = rectTransform.rect.width - padding.horizontal;
availableHeight = float.MaxValue;
}
else
{
availableHeight = rectTransform.rect.height - padding.vertical;
availableWidth = rectTransform.rect.width - padding.horizontal;
}
// Spacing usually creates mode big empty spaces where no item can fit
float spacingToUse = 0f;
var packer = new Packer2DBox(availableWidth, availableHeight, spacingToUse);
# region 修改部分,这里简化源码,只使用 MAX_VOLUME 策略,减少new对象
var boxesThisPass = list.ConvertAll(c => c.box);
packer.Pack(boxesThisPass, false, Packer2DBox.NodeChoosingStrategy.MAX_VOLUME, out var totalWidthThisPass, out var totalHeightThisPass);
width = (float)totalWidthThisPass;
height = (float)totalHeightThisPass;
#endregion
return list;
}