文章目錄
  1. 1. 前言
    1. 1.1. 列表
    2. 1.2. 单元格容器
      1. 1.2.1. 单元格实现分析
        1. 1.2.1.1. LoopScrollRect
        2. 1.2.1.2. FancyScrollView
        3. 1.2.1.3. super-scrollview
      2. 1.2.2. 实战单元格容器
        1. 1.2.2.1. 深入讲解
        2. 1.2.2.2. 未来优化
  2. 2. Github地址
  3. 3. Reference

前言

多年使用Unity经验的朋友对于列表单元肯定很熟悉,每个公司也都有着自己的一套单元格设计。本章节博客着重学习Unity里列表单元格框架的设计和实现,深入学习不同单元格框架的设计优劣。

列表

什么是列表了?
可以滚动,支持创建多个基于特定模板显示的单元格容器。

列表的功能需求((√)表示本人已经实现的部分)?

  1. 滚动显示(支持滚动到指定位置)(√)
  2. 动态添加(支持任何位置添加元素)(√)
  3. 循环列表(支持从头滚到尾再接着滚动到头的循环方式)
  4. 不同大小单元格显示(支持自适应大小或者自定义传单元格大小)(√)
  5. 多种列表类型显示(e.g. 横向,竖向,横向网格,竖向网格等)(√)
  6. 单元格构造自定义传参(√)
  7. 自定义滚动动画(√)
  8. 不同单元格朝向(e.g. 从左往右或从右往左。从上往下或者从下往上。)(√)
  9. 单元格矫正(最终单元格会滚动到某个最近的单元格位置)(√)
  10. 单元格对象池(e.g. 单元格逻辑对象(Object)对象池。单元格实体对象(GameObject)对象池。)(√)
  11. 单元格嵌套滚动(e.g. 嵌套不同方向的单元格容器滚动支持)(√)
  12. 本地单元格模拟创建查看效果(e.g. Inspector支持快速模拟查看排版等)(√)

单元格容器

这里关于单元格容器,这里主要实现了两套:

  1. 支持不同滚动方向不同初始化朝向支持不同大小(手动传Size或者自动计算Size)嵌套滚动单元格的一套通用单元格容器。
  2. 支持两侧深度滚动显示的单元格容器(这一套实现的效果比较特别所以单独实现的)
    其中本人主要以第一套(以前在公司学习到的一套实现方案,后来在此基础上扩展支持了很多功能)为重点来学习实战。

单元格实现分析

这里通过学习了解市面上常见的单元格实现来了解常见的单元格实现方案:

LoopScrollRect

LoopScrollRect
这一套是钱康来大佬编写分享的一套支持循环列表的实现方案。
通过查看源码,可以了解到,这一套实现核心是基于ContentSizeFitter,LayoutGroup和LayoutElement相关组件来实现动态计算单元格位置显示循环滚动以及不同大小等效果的。
亮点分析:

  1. 使用了原生Scroller,但并不依赖原生ScrolRect那套来计算滚动,这样为支持循环列表打下了基础。
  2. 单元格位置数据与单元格逻辑对象分离,可以做到动态计算单元格显示时只有需要显示的才有逻辑单元格对象(避免New大量的单元格)

缺点分析:

  1. 虽然基于LayoutGroup和LayoutElement,但并非正向通过他们的排版功能得到不同单元格大小显示,而是反向设置LayoutElement来通过LayoutUtility.GetPreferredHeight()等接口方法来实现正确的计算滚动排版。这儿也就意味着做不到自动计算单元格大小的功能。
  2. 单元格显示是通过SendMessage的形式触发,感觉这种方式效率可能不如直接调用接口方法来的快(个人感觉)。
    核心获取单元格大小的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    protected override float GetSize(RectTransform item)
    {

    float size = contentSpacing;
    if (m_GridLayout != null)
    {
    size += m_GridLayout.cellSize.y;
    }
    else
    {
    size += LayoutUtility.GetPreferredHeight(item);
    }
    return size;
    }

单元格初始化显示触发代码:

1
2
3
4
public override void ProvideData(Transform transform, int idx)
{

transform.SendMessage("ScrollCellIndex", idx);
}

FancyScrollView

FancyScrollView
FancyScrollViewExhibition
这一套是在UWA上看到的,貌似是日本开发者开发的。
通过上面的效果图可以看到,这一套实现了很强大的结合自定义动画或者Shader的单元格容器框架。
通过查看源代码可以发现,这一套非常强大的一点是通过自定义实现Scroller强大的滚动单元格容器需要的滚动相关的计算和回调支持。
上次代码只需要通过ScrollView和Scroller接口设置显示数据以及单元格模板等就能做到很好的单元格容器滚动效果。
亮点分析:

  1. 没有依赖原生Scroller,自定义实现了Scroller,可以更加容易的支持循环列表。
  2. 动态计算显示与否是通过当前滚动到的位置来判定,而单元格位置信息并没有耦合到单元格对象里,这样可以做到逻辑对象数量等于最大可显示的数量。
  3. 更新显示回调(UpdatePosition)里有传入位置数据,可以基于位置数据做动画(Animator)或者Shader相关的表现展示
  4. 不依赖于Scroller,LayoutGroup等组件,开销更低

缺点分析:

  1. 单元格不支持不同大小,都是按预制件统一大小来计算

super-scrollview

ugui-super-scrollview-example
这一套是Unity Asset Store里出售的一套滚动单元格插件。
让我们先来一张效果图展示:
SuperScrollViewExhibition
从上面可以看到,SuperScrollView可以做到很多效果(e.g. 翻页,动态加载,动态大小变化,滚动到指定位置,动态增删单元格,不同朝向单元格容器显示……)
基本上能想到的单元格容器效果,这个插件都支持。
接下来让我们结合源码和实例来分析下这一套单元格容器的好坏:
亮点分析:

  1. 单元格位置数据与逻辑对象分离,可以有效的减少逻辑单元格对象数量
  2. 自定义的单元格大小抽象,可以方便的自定义传入不同大小的单元格并显示
  3. 支持不同朝向的单元格容器显示(核心是反向初始化和计算单元格位置显示)

缺点分析:

  1. 使用了ScrollRect部分功能(特别是容器滚动部分),这一点导致要做循环列表就很困难

后续本人的单元格容器很大程度上跟SuperScrollView更相近(指功能设计上而非代码设计)。

实战单元格容器

通过学习了解市面上的单元格容器实现,可以看到有几个比较重要的点:

  1. 不依赖于ScrollRect滚动,自定义分离单元格位置数据和单元格逻辑对象,是实现循环列表和减少逻辑单元格对象数量的关键。
  2. 大部分都不支持自动计算大小,需要手动计算传递单元格大小,相对来说没那么方便,但开销会小。

接下来让我们看看博主实战最终实现的效果(主要讲解第一套单元格):
LeftToRightScene

TopToBottomScene

HorizontalGalleryDemoScene

ChatMessageListScene

ChangeItemSizeScene

ClickAndLoadMoreScene

GridViewScene

PageViewScene

SpinDatePickerScene

SelectAndDeleteOrMoveScene

上面展示了我支持的各类单元格容器,比如横向单元格容器,竖向单元格容器,横向网格单元格容器,竖向网格单元格容器,动态大小等各种用法。
DepthScrollExhibition
第三章图主要展示的是另一套单元格容器,支持左右两侧指定深度滚动的单元格容器。

Note:

基于ContentSizeFitter和LayoutGroup的自动计算大小测试性能开销比较大最后决定不予支持了。

深入讲解

让我们通过横向单元格的面板来大致了解下单元格都支持哪些功能:
!(HorizontalContainerInspector)(/img/Unity/CellContainer/HorizontalContainerInspector.png)
从面板上可以看出,博主的单元格容器主要支持了以下几个功能:

  1. 单元格滚动自动矫正以及相关速度设置(最终确保滚动到单元格正中心位置)
  2. 支持不同朝向的单元格创建显示
  3. 支持嵌套单元格容器(不同朝向的单元格容器可以嵌套滚动)
  4. 支持单元格的间距和初始位置排版设置
  5. 支持设置多预制件用于绑定不同逻辑单元格对象显示在同一个单元格容器下
  6. 支持编辑器下快速模拟创建单元格查看排版

单元格容器核心实现原理:

  1. 通过手动传单元格大小或者自动计算单元格大小来得到每个单元格的大小数据
  2. 通过累加计算出总的可滚动Content大小,同时计算出单元格的抽象单元格位置
  3. 结合IBeginDragHandler, IDragHandler, IEndDragHandler接口来判定拖拽状态(比如是否开始或结束拖拽,拖拽方向等),以及实现嵌套单元格的拖拽事件判定以及分发
  4. Content结合ScrollRect来实现滚动回调(OnValueChanged)触发滚动计算判定,结合单元格的抽象位置来判定每个单元格的显隐状态。同时判定是否满足单元格矫正条件
  5. 通过IEndDragHandler响应结束拖拽时判定是否需要执行单元格矫正
  6. 向上层暴露OnShow,OnVisibleScroll,MoveTOIndex,OnHide等接口来实现单元格逻辑流程显示回收(BaseScrollContainer:bindContainerCallBack()接口传递)

结合UML类图来理解下单元格容器的设计:
CellContainerUML

首先先放一段完整的创建单元格显示的事例代码,后续会一一讲解具体实现细节流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RightToLeftContainer.bindContainerCallBack(onCellShow);
RightToLeftContainer.setCellDatasByCellCount(20);

/// <summary>
/// 单元格显示回调
/// </summary>
/// <param name="cellindex"></param>
/// <param name="cellinstance"></param>
private void onCellShow(int cellindex, GameObject cellinstance)
{

var toptobottomcell = cellinstance.GetComponent<ShowCellIndexCell>();
if (toptobottomcell == null)
{
toptobottomcell = cellinstance.AddComponent<ShowCellIndexCell>();
}
toptobottomcell.init(cellindex);
}

接下来让我们看看单元格容器的细节实现:
CellData.cs(单元格相关抽象

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
public class CellData : IRecycle
{
/// <summary>
/// 当前Cell索引
/// </summary>
public int CellIndex
{
get;
set;
}

/// <summary>
/// 拥有者单元格
/// </summary>
protected BaseScrollContainer mOwnerContainer;

/// <summary>
/// Cell实例对象
/// </summary>
public GameObject CellGO
{
get;
protected set;
}

/// <summary>
/// Cell实例对象的RectTransform
/// </summary>
public RectTransform CellGORectTransform
{
get;
protected set;
}

/// <summary>
/// 单元格大小(宽和高)
/// </summary>
private Vector2 mCellSize;

/// <summary>
/// 单元格预制件索引
/// </summary>
public int CellPrefabIndex
{
get;
private set;
}


......

public void onCreate()
{

//Debug.Log("NewCellData:onCreate()");
}


public void onDispose()
{

//Debug.Log("NewCellData:onDispose()");
CellIndex = -1;
mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
CellPrefabIndex = -1;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
mRectAbsPos = Vector2.zero;
CellRect.Set(0.0f, 0.0f, 0.0f, 0.0f);
}

public CellData()
{

CellIndex = -1;
mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
CellPrefabIndex = -1;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
mRectAbsPos = Vector2.zero;
CellRect.Set(0.0f, 0.0f, 0.0f, 0.0f);
}

/// <summary>
/// 初始化单元格实例对象
/// </summary>
/// <param name="cellinstance"></param>
public void init(GameObject cellinstance)
{

if(cellinstance != null)
{
CellGO = cellinstance;
// 不同单元格容器公用同一个模板对象可能会出现锚点不一致问题,所以每次强制设置
CellGORectTransform = CellGO.transform as RectTransform;
CellGORectTransform.anchorMax = AnchorMax;
CellGORectTransform.anchorMin = AnchorMin;
CellGORectTransform.pivot = Pivot;
updateCellSizeAndPosition();
if (!CellGO.activeSelf)
{
CellGO.SetActive(true);
}
}
else
{
CellGO = null;
CellGORectTransform = null;
}
}

/// <summary>
/// Cell数据清除
/// </summary>
public void clear()
{

mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
CellRect = Rect.zero;
ObjectPool.Singleton.push<CellData>(this);
}

......
}

上面列举了单元格参与容器显示判定计算比较重要的成员变量以及生命周期函数,可以看出单元格的大小和位置以及显示区域信息会用作单元格容器滚动时的判定数据,从而判定是否需要显示特定单元格。

从前面的事例代码可以看到单元格的创建可以手动指定单元格大小,不传会用默认预制件大小

设定单元格容器回调后触发setCellDatasByCellCount()等设置容器数量相关接口后,触发单元格以及单元格容器相关数据的初始化和判定以及滚动判定监听:
BaseScrollContainer.cs(单元格容器基类)

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// Use this for initialization
public virtual void Start()
{

ScrollRect.onValueChanged.AddListener(onScrollChanged);
TryCorrectData();
}

/// <summary>
/// 滚动回调刷新Cell显示
/// </summary>
/// <param name="scrollpos"></param>
protected override void onScrollChanged(Vector2 scrollpos)
{

if (mCellDatas != null)
{
updateScrollValue();
mMaskRect.x = mAvalibleScrollDistance * ScrollRect.horizontalNormalizedPosition;
for (int i = 0; i < mCellDatas.Count; i++)
{
onCellDisplay(i);
}
checkCellPostionCorrect(mCurrentScrollDir);
}
}

/// <summary>
/// Set container celldatas by pass data
/// 设置Container的cell数据通过列表数据
/// </summary>
/// <param name="prefabindexlist">预制件索引列表</param>
/// <param width="cellsizelist">单元格大小列表(为空表示采用预制件默认大小)</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatasByDataList(List<int> prefabindexlist, List<Vector2> cellsizelist = null, Vector2? scrollnormalizedposition = null)
{

var celldatalist = createNormalCellDataList(prefabindexlist, cellsizelist);
setCellDatas(celldatalist, scrollnormalizedposition);
}

/// <summary>
/// Set container celldatas by pass cell count
/// 设置Container的cell数据通过单元格数量
/// </summary>
/// <param name="prefabindexlist">预制件索引列表</param>
/// <param width="cellsizelist">单元格大小列表(为空表示采用预制件默认大小)</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatasByCellCount(int cellcount, Vector2? scrollnormalizedposition = null)
{

var celldatalist = createNormalCellDataListWithCount(cellcount);
setCellDatas(celldatalist, scrollnormalizedposition);
}

/// <summary>
/// Set container celldatas by pass celldata list
/// 设置Container的cell数据
/// </summary>
/// <param name="celldatas">所有的Cell数据</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatas(List<CellData> celldatas, Vector2? scrollnormalizedposition = null)
{

......
}

/// <summary>
/// Update container relative datas
/// 更新容器数据
/// </summary>
/// <param name="scrollnormalizaedposition">单元格初始滚动位置</param>
/// <param name="keeprectcontentpos">是否保持rect content的相对位置(优先于scrollnormalizaedposition)</param>
protected abstract void updateContainerData(Vector2? scrollnormalizaedposition = null, bool keeprectcontentpos = false);

......

因为单元格的流程和细节还是比较多,这里讲解的不太全面,很多地方没有说到,但考虑到项目还在用这两套单元格滚动,所以展示不打算放出源码,等未来实际成熟再分享源码。

亮点分析:

  1. 支持嵌套单元格滚动(通过判定滚动方向手动向上传递滚动事件实现)
  2. 支持手动传Size
  3. 支持不同单元格模板以及不同单元格逻辑对象的显示

缺点分析:

  1. 滚动单元格依赖于ScrollRect的滚动机制需要一开始就计算出总的单元格大小之和,导致很难支持循环列表。
  2. 滚动列表要一开始就知道单元格的大小,导致一开始如果有过多的动态大小会导致单元格初始化前的计算开销过大。

未来优化

  1. 设计成支持纯虚拟的滚动列表,可以在滚动时再请求单元格大小信息做到动态更新ScrollRect滚动容器大小,动态单元格大小在初始化时的开销过大。
  2. 设计成不基于ScrollRect的滚动机制,为实现循环列表做准备

——————————————2021/4/20更新开始———————————————-

开始着手优化第一个问题,分离单元格位置和单元格逻辑对象,确保只需创建可见的单元格逻辑对象,减少不必要的逻辑对象创建开销。(已设计成回调似方式来优化逻辑对象问题)

——————————————2021/4/20更新结束———————————————-

Note:

  1. 源代码里默认带了一套组件绑定(结合代码生成)的方案,感兴趣的朋友可以自己了解下
  2. 源代码里也默认带了ObjectPool和GameObjectPool两种对象池的实现,感兴趣的朋友可以自己了解下

Github地址

ScrollContainer

Reference

LoopScrollRect
FancyScrollView
ugui-super-scrollview-example
Unity3D研究院之ContentSizeFitter同步立即响应回调

文章目錄
  1. 1. 前言
    1. 1.1. 列表
    2. 1.2. 单元格容器
      1. 1.2.1. 单元格实现分析
        1. 1.2.1.1. LoopScrollRect
        2. 1.2.1.2. FancyScrollView
        3. 1.2.1.3. super-scrollview
      2. 1.2.2. 实战单元格容器
        1. 1.2.2.1. 深入讲解
        2. 1.2.2.2. 未来优化
  2. 2. Github地址
  3. 3. Reference