前言

​ 在毕设demo中想要实现一个角色选择界面,其中是需要用到类似背包的GridLayoutGroup组件的,同时之前的EGUI文章中也没对其中的LoopHorizontalScrollRect和LoopVerticalScrollRect进行解析,所以也趁这个机会看一下源码,且其对于背包系统的优化来说对很多类型的游戏都是通用的。

整体设计

​ 对于抽象类LoopScrollRect来说,其基于Unity官方的ScrollView进行了一定的拓展。由LoopScrollRect继承出两种滚动类型,分别为HorizontalScrollRect和VerticalScrollRect。

本文主要针对于其的拓展与采取的优化手段进行解析:

​ 拓展部分的UML图(包括Item的数据存储):

EGUI_LoopScrollRect1.drawio

数据存储

对于数据的存储主要是通过LoopScrollPrefabSourceLoopScrollDataSource,前者主要是对UIPrefab源,后者是对其具体存储数据源

LoopScrollPrefabSource

  • 内部存储了对应UIPrefab的prefabName,通过AB包的方式加载,并生成对应的对象池Pool,其Pool的GameObject存放在Global/PoolRoot中。
  • 具体封装了GetObject()和ReturnObject(Transform go,bool isDestroy = false)

LoopScrollDataSource

  • scrollMoveEvent存储滑动时对应Index的Item及其Transform的Action,在LoopScrollRect的GetFromTempPool(int itemIdx)中会对池子获取到的nextItem进行data注入(即执行dataSource.ProvideData(nextItem,itemIdx))
  • 具有LoopScrollSendIndexSource和LoopScrollArraySource<T>两种继承类,两者都在ProvideData接口重写中调用scrollMoveEvent,而后者在初始化的构造函数中需要传入objectToFill作为传入参数(可供后期在ProvideData执行时拓展对objectsToFill的统一操作)。

滚动优化

UpdateItems

  • 对于Content来说,其内部的Item是动态进行对象池的获取和回收的。

  • UpdateItems在其父类LoopScrollRect中的UpdateBounds(bool updateItems = false)中调用
    而UpdateBounds在updateItems=true下的具体调用则分为以下几种情况:

    • SetContentAnchoredPosition(Vector2 position),即设定Content窗口的AnchoredPosition,其具体在OnScroll(PointerEventData data)、OnDrag(PointerEventData data)、LateUpdate()中进行调用
    • SetNormalizedPosition(float value,int axis),即设定Content窗口的规格化NormalizedPosition,其具体在SetHorizontalNormalizedPosition(float value)、SetVerticalNormalizedPosition(float value)中进行调用
    1
    2
    3
    4
    5
    6
    7
    8
    // ============LoopScrollRect============
    // Don't do this in Rebuild
    if (Application.isPlaying && updateItems && UpdateItems(m_ViewBounds,m_ContentBounds))
    {
    Canvas.ForceUpdateCanvases();
    m_ContentBounds = GetBounds();
    }
    // ============LoopScrollRect============
  • UpdateItems具体的实现主要分为五种情况(以LoopVerticalScrollRect为例子,Horizontal类似)

    • 在一帧内滑动超过一页,判断条件为(viewBounds.min.y > contentBounds.max.y && itemTypeEnd > itemTypeStart)

      对应源码为:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      if (viewBounds.min.y > contentBounds.max.y && itemTypeEnd > itemTypeStart)
      {
      float currentSize = contentBounds.size.y;
      float elementSize = (currentSize - contentSpacing * (CurrentLines - 1)) / CurrentLines;
      ReturnToTempPool(false, itemTypeEnd - itemTypeStart);
      itemTypeEnd = itemTypeStart;

      int offsetCount = Mathf.FloorToInt((viewBounds.min.y - contentBounds.max.y) / (elementSize + contentSpacing));
      if (totalCount >= 0 && itemTypeStart - offsetCount * contentConstraintCount < 0)
      {
      offsetCount = Mathf.FloorToInt((float)(itemTypeStart) / contentConstraintCount);
      }
      itemTypeStart -= offsetCount * contentConstraintCount;
      if (totalCount >= 0)
      {
      itemTypeStart = Mathf.Max(itemTypeStart, 0);
      }
      itemTypeEnd = itemTypeStart;

      float offset = offsetCount * (elementSize + contentSpacing);
      content.anchoredPosition += new Vector2(0, offset + (reverseDirection ? currentSize : 0));
      contentBounds.center += new Vector3(0, offset + currentSize / 2, 0);
      contentBounds.size = Vector3.zero;

      changed = true;
      }
      • 计算出elementSize,即每一行Item的size.y大小
      • 将当前的所有Item都放进待删除的脏池子中(翻页情况)
      • 计算出offsetCount,即滑动偏移的行数,并与实际最多可偏移行数比较得出最终offsetCount
      • 计算出最终需要偏移的offset,同时将content的anchoredPosition向上方移动offset,设置其包围盒的中心点与anchoredPosition重合,并重置包围盒大小
    • 显示包围盒的y的最小值大于内容包围盒的y的最小值+Item判定大小,判断条件为(viewBounds.min.y>contentBounds.min.y+threshold),threshold为item的boundSize的1.5倍,用一张图来方便观察。
      红色为view,蓝色为content

      image-20211225183335276
      进行代码分析:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      if (viewBounds.min.y > contentBounds.min.y + threshold)
      {
      float size = DeleteItemAtEnd(), totalSize = size;
      while (size > 0 && viewBounds.min.y > contentBounds.min.y + threshold + totalSize)
      {
      size = DeleteItemAtEnd();
      totalSize += size;
      }
      if (totalSize > 0)
      changed = true;
      }
      • 删除末尾的Item并计算得出删除的size,对totalSize进行赋值
      • 判断删除后边界大小是否满足viewBounds.min.y+threshold+totalSize>viewBounds.min.y情况,若不满足则继续删除末尾Item并累计删除的size
      • 如果删除的totalSize>0,则将changed设为true,在函数的末尾进行脏数据的删除和清空
    • 显示包围盒的y最大值小于内容包围盒的y最大值-Item判定大小,判断条件为(viewBounds.max.y < contentBounds.max.y - threshold)
      红色为viewBounds,蓝色为contentBounds
      image-20211225185359966

      进行代码分析:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      if (viewBounds.max.y < contentBounds.max.y - threshold)
      {
      float size = DeleteItemAtStart(), totalSize = size;
      while (size > 0 && viewBounds.max.y < contentBounds.max.y - threshold - totalSize)
      {
      size = DeleteItemAtStart();
      totalSize += size;
      }
      if (totalSize > 0)
      changed = true;
      }
      • 删除开头的Item并得出删除Item的size,用totalSize进行存储
      • 判断是否符合viewBounds.max.y>contentBounds.max.y-threshold-totalSize,若不满足则继续删除开头的Item
      • 如果删除的totalSize>0,则将changed设为true,在函数的末尾进行脏数据的删除和清空
    • 显示包围盒的y最小值小于内容包围盒的y最小值,判断条件为(viewBounds.min.y<contentBounds.min.y)
      红色为viewBounds,蓝色为contentBounds
      image-20211225190814660
      代码分析:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      if (viewBounds.min.y < contentBounds.min.y)
      {
      float size = NewItemAtEnd(), totalSize = size;
      while (size > 0 && viewBounds.min.y < contentBounds.min.y - totalSize)
      {
      size = NewItemAtEnd();
      totalSize += size;
      }
      if (totalSize > 0)
      changed = true;
      }
      • 在末尾添加Item并记录下添加的Item大小,用totalSize进行存储
      • 判断是否符合viewBounds.min.y>contentBounds.min.y-totalSize,若不满足则继续在末尾增加Item
      • 如果删除的totalSize>0,则将changed设为true,在函数的末尾进行脏数据的删除和清空
    • 显示包围盒的y最大值大于内容包围盒的y最大值,判断条件为(viewBounds.max.y>contentBounds.max.y)
      红色为viewBounds,蓝色为contentBounds
      image-20211225192304044

      代码分析:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      if (viewBounds.max.y > contentBounds.max.y)
      {
      float size = NewItemAtStart(), totalSize = size;
      while (size > 0 && viewBounds.max.y > contentBounds.max.y + totalSize)
      {
      size = NewItemAtStart();
      totalSize += size;
      }
      if (totalSize > 0)
      changed = true;
      }
      • 在开头添加Item并记录下添加的Item大小,用totalSize进行存储
      • 判断是否符合viewBounds.max.y<contentBounds.max.y+totalSize,若不满足则继续在开头增加Item
      • 如果删除的totalSize>0,则将changed设为true,在函数的末尾进行脏数据的删除和清空

New/Delete ItemAt Start/End

​ 上述的UpdateItems中,在对Item进行首尾的增删时用到了标题中的函数,我们以NewItemAtStart()与DeleteItemAtStart()为例子对函数的源码进行分析:

NewItemAtStart()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
protected float NewItemAtStart()
{
if (totalCount >= 0 && itemTypeStart - contentConstraintCount < 0)
{
return 0;
}
float size = 0;
//逐行/列添加
for (int i = 0; i < contentConstraintCount; i++)
{
itemTypeStart--;
RectTransform newItem = GetFromTempPool(itemTypeStart);
newItem.SetSiblingIndex(deletedItemTypeStart); //放在待删除的ItemTypeStart,防止被ClearTempPool删除
size = Mathf.Max(GetSize(newItem), size);
}
threshold = Mathf.Max(threshold, size * 1.5f);

if (!reverseDirection)
{
Vector2 offset = GetVector(size);
content.anchoredPosition += offset;
m_PrevPosition += offset;
m_ContentStartPosition += offset;
}

return size;
}
  • 对于itemTypeStart的增删是逐行/列进行的,每次增加会减少itemTypeStart(首部的Itemindex记录,用于获取信息)
  • 实际添加的位置会根据deletedItemTypeStart调整(待删除Item的起始点,其前面所有Item都会在该帧ClearTempPool()时被删除)
  • 在首部增加Item时需要对content的anchoredPosition进行上移,在后续GetBounds()时获取到新的包围盒

DeleteItemAtStart()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
protected float DeleteItemAtStart()
{
// special case: when moving or dragging, we cannot simply delete start when we've reached the end
if ((m_Dragging || m_Velocity != Vector2.zero) && totalCount >= 0 && itemTypeEnd >= totalCount - contentConstraintCount)
{
return 0;
}
int availableChilds = content.childCount - deletedItemTypeStart - deletedItemTypeEnd;
Debug.Assert(availableChilds >= 0);
if (availableChilds == 0)
{
return 0;
}

float size = 0;
//逐行/列删除
for (int i = 0; i < contentConstraintCount; i++)
{
RectTransform oldItem = content.GetChild(deletedItemTypeStart) as RectTransform;
size = Mathf.Max(GetSize(oldItem), size);
ReturnToTempPool(true);
availableChilds--;
itemTypeStart++;

if (availableChilds == 0)
{
break;
}
}

if (!reverseDirection)
{
Vector2 offset = GetVector(size);
content.anchoredPosition -= offset;
m_PrevPosition -= offset;
m_ContentStartPosition -= offset;
}
return size;
}
  • 在ReturnToTempPool(true)内部会执行deletedItemTypeStart+=1,即将待删除结点的起点向后移一位
  • 在首部删除Item时需要对content的anchoredPosition进行下移,在后续GetBounds()时获取到新的包围盒

总体结构

image-20211228153308241

滑动效果展示

主要是增删Item对Content的动态anchoredPosition修改展示:

ScrollRect