实现原理
1) 只生成裁剪区域所需的条目
2) 向上滑动时,顶部滑到裁剪区域外的条目回收放入缓冲池,底部即将滑入裁剪区域的条目从缓冲池获取后生成。
3) 向下滑动时,底部滑到裁剪区域外的条目回收放入缓冲池,顶部即将滑入裁剪区域的条目从缓冲池获取后生成。
原理图解
1) 只生成裁剪区域所需的条目
a) 黄色区域为裁剪区域(为了演示关闭了裁剪功能),裁剪区域也叫viewport(视口)
b) 5不在裁剪区域内,为啥也生成了?实际中为了防止穿帮,一般会多生成一条。
伪码
var content = m_ScrollRect.content; float tmpItemTopDis = 0; //条目top到content的top的距离 for (int i = 0; i < m_DataCount; ++i) {var newItemRtf = m_OnObtainItem(this, i);if (null == newItemRtf)break;newItemRtf.gameObject.SetActive(true);newItemRtf.SetParent(content, false);m_ItemNodeList.Add(new ItemNode(newItemRtf, i));if (0 == i)tmpItemTopDis = GetItemNodeTopDis(newItemRtf);float itemSize = newItemRtf.rect.height;float itemBottomDis = tmpItemTopDis + itemSize;m_ItemPosAndSizeList.Add(new ItemPosAndSize(itemSize, itemBottomDis));if (tmpItemTopDis > viewportSize)break;tmpItemTopDis = itemBottomDis + m_ItemSpace; }
2) 向上滑动时:
2-1) 顶部滑到裁剪区域外的条目回收放入缓冲池
a) 如何判断顶部滑到裁剪区域外? 条目的bottomDis < scrollDis(滚动距离)
b) 下图中条目1、2、3都向上滑到了裁剪区域外,都满足条件,但条目3在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,
导致生成->回收->生成这样不断重复的情况。
伪码
private void CheckAndRecycleTopItems(float scrollDis) {int recycleItemNodeIndex = -1;for (int i = 0; i < m_ItemNodeList.Count; ++i){var itemNode = m_ItemNodeList[i];var itemPosAndSize = m_ItemPosAndSizeList[itemNode.itemIndex];if (itemPosAndSize.bottomDis < scrollDis){//该条目到裁剪区域外了 }else{//遇到第1个在裁剪区域内的条目recycleItemNodeIndex = i - 2;break;}}if (recycleItemNodeIndex >= 0){for (int i = recycleItemNodeIndex; i >= 0; --i){var tmpItemNode = m_ItemNodeList[i];m_ItemNodeList.RemoveAt(i);RecycleItem(tmpItemNode.rtf);}} }
2-2) 底部即将滑入裁剪区域的条目从缓冲池获取后生成
a) 如何判断底部即将滑入裁剪区域?条目的topDis <= (scrollDis + viewportSize)
b) 下图中,条目9的topDis从底部滑入裁剪区域时,表示他后面的条目10即将滑入裁剪区域,此时要从缓存池获取并生成条目10
伪码
private void CheckAndFillBottomItems(float viewportBottomDis) {var listViewContent = m_ScrollRect.content;var lastItemNode = m_ItemNodeList[m_ItemNodeList.Count - 1];var lastItemPosAndSize = m_ItemPosAndSizeList[lastItemNode.itemIndex];float tmpTopDis = lastItemPosAndSize.TopDis;float tmpBottomDis = 0;float prevItemSize = lastItemPosAndSize.size;int tmpItemIndex = lastItemNode.itemIndex;while (tmpTopDis <= viewportBottomDis){tmpItemIndex++;if (tmpItemIndex >= m_DataCount)break;var newItemRtf = m_OnObtainItem(this, tmpItemIndex);if (null == newItemRtf)break;newItemRtf.gameObject.SetActive(true);newItemRtf.SetParent(listViewContent, false);newItemRtf.SetAsLastSibling();m_ItemNodeList.Add(new ItemNode(newItemRtf, tmpItemIndex));tmpTopDis += prevItemSize;tmpTopDis += m_ItemSpace;prevItemSize = newItemRtf.rect.height;tmpBottomDis = tmpTopDis + prevItemSize;m_ItemPosAndSizeList[tmpItemIndex] = new ItemPosAndSize(prevItemSize, tmpBottomDis);} }
3) 向下滑动时
3-1) 底部滑到裁剪区域外的条目回收放入缓冲池
a) 如何判断底部滑到裁剪区域外? 条目的topDis > (scrollDis + viewportSize)
b) 下图中条目8、9、10都往下滑到了裁剪区域外,都满足条件,但条目8在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,
导致生成->回收->生成这样不断重复的情况。
伪码
private void CheckAndRecycleBottomItems(float viewportBottomDis) {int recycleItemNodeIndex = -1;int endIndex = m_ItemNodeList.Count - 1;for (int i = endIndex; i >= 0; --i){var itemNode = m_ItemNodeList[i];var itemPosAndSize = m_ItemPosAndSizeList[itemNode.itemIndex];float topDis = itemPosAndSize.TopDis; //GetItemNodeTopDis(itemNode);if (topDis > viewportBottomDis){//该条目向下滑动到裁剪区域外了 }else{//遇到第1个在裁剪区域内的条目recycleItemNodeIndex = i + 2;break;}}if (recycleItemNodeIndex >= 0 && recycleItemNodeIndex <= endIndex){for (int i = endIndex; i >= recycleItemNodeIndex; --i){var tmpItemNode = m_ItemNodeList[i];m_ItemNodeList.RemoveAt(i);RecycleItem(tmpItemNode.rtf);}} }
3-2) 顶部即将滑入裁剪区域的条目从缓冲池获取后生成。
a) 如何判断顶部即将滑入裁剪区域?条目bottomDis >= scrollDis
b) 下图中,条目2的bottomDis从顶部滑入裁剪区域时,表示他前面的条目1即将滑入裁剪区域,此时要从缓存池获取并生成条目1
伪码
private void CheckAndFillTopItems(float scrollDis) {var listViewContent = m_ScrollRect.content;var firstItemNode = m_ItemNodeList[0];var firstItemPosAndSize = m_ItemPosAndSizeList[firstItemNode.itemIndex];float prevItemSize = firstItemPosAndSize.size;float tmpTopDis = firstItemPosAndSize.TopDis;float tmpBottomDis = firstItemPosAndSize.bottomDis;int tmpItemIndex = firstItemNode.itemIndex;while (tmpItemIndex > 0 && tmpBottomDis >= scrollDis){tmpItemIndex -= 1;var newItemRtf = m_OnObtainItem(this, tmpItemIndex);if (null == newItemRtf)break;newItemRtf.gameObject.SetActive(true);newItemRtf.SetParent(listViewContent, false);newItemRtf.SetAsFirstSibling();m_ItemNodeList.Insert(0, new ItemNode(newItemRtf, tmpItemIndex));tmpBottomDis -= prevItemSize;tmpBottomDis -= m_ItemSpace;prevItemSize = newItemRtf.rect.height;m_ItemPosAndSizeList[tmpItemIndex] = new ItemPosAndSize(prevItemSize, tmpBottomDis);tmpTopDis = tmpBottomDis - prevItemSize;} }
还要解决的问题
1) 如果content使用了LayoutGroup和ContentSizeFitter组件来自动布局,上面的几种情况会遇到以下问题:
a) 向上滑动,顶部条目回收后,content的大小会变小,同时所有条目会上移。解决办法:
在顶部加一个占位节点startStub,回收掉后,让占位节点占据掉回收节点的空间。2-1)中就是把占位节点高度设为:条目2的bottomDis
b) 向下滑动,底部条目回收后,content的大小会变小,此时条目不会上移。解决办法:
在底部增加一个占位节点endStub,回收掉后,让占位节点占据掉回收节点的空间。3-1)中就是把占位节点的高度设置为:contentSize - 条目9的topDis
c) 向上滑动,底部生成条目后,content的大小会变大,此时需要修正endStub的大小
2-2)中要把占位节点endStub的高度设置为:contantSize - 条目10的bottomDis - 条目间隔
d) 向下滑动,顶部生成条目后,content的大小会变大,所有条目会下移,此时要修正startStub的大小
3-2)中要把顶部占位节点startStub的高度设置为:条目1的topDis - 条目间隔
2) 使用ugui的自动布局,还有一个问题是,大小和位置不是立即设置,而是在Update中统一设置,
这个会造成获取到条目的topDis, bottomDis或itemSize这种不对,这个怎么解决?
a) 有回收或生成条目时,自动标记要在LateUpate中,再获取一遍条目的topDis, bottomDis, itemSize这些做修正。
b) 外部修改内容导致了自动布局的触发,我们手动标记要在LateUpdate中做修正。
//设置占位节点大小 private static void SetStubSize(RectTransform stubRtf, float newSize) {Vector2 sizeDelta = stubRtf.sizeDelta;float oldSize = sizeDelta.y;if (oldSize <= 0.001f){if (newSize > 0.001f){stubRtf.gameObject.SetActive(true);sizeDelta.y = newSize;stubRtf.sizeDelta = sizeDelta;}}else{sizeDelta.y = newSize;stubRtf.sizeDelta = sizeDelta;if (newSize <= 0.001f)stubRtf.gameObject.SetActive(false);} }
public void UpdateItemsPosAndSizeNextFrame() {m_LateUpdateFlag = 1; }public void UpdateItemsPosAndSize() {int endIndex = m_ItemNodeList.Count - 1;if (endIndex < 0)return;foreach (var itemNode in m_ItemNodeList){float itemSize = itemNode.rtf.rect.height;float itemBottomDis = GetItemNodeTopDis(itemNode.rtf) + itemSize;m_ItemPosAndSizeList[itemNode.itemIndex] = new ItemPosAndSize(itemSize, itemBottomDis);}var firstItemNode = m_ItemNodeList[0];int firstItemIndex = firstItemNode.itemIndex;var firstItemPosAndSize = m_ItemPosAndSizeList[firstItemIndex];if (firstItemIndex <= 0){SetStubSize(m_BeginStub, 0);}else{float newSize = firstItemPosAndSize.TopDis - m_ItemSpace;SetStubSize(m_BeginStub, newSize);}//再更新后续的条目var lastItemNode = m_ItemNodeList[endIndex];var lastItemPosAndSize = m_ItemPosAndSizeList[lastItemNode.itemIndex];float tmpBottomDis = lastItemPosAndSize.bottomDis;for (int i = lastItemNode.itemIndex + 1; i < m_DataCount; ++i){var itemPosAndSize = m_ItemPosAndSizeList[i];tmpBottomDis += m_ItemSpace;tmpBottomDis += itemPosAndSize.size;itemPosAndSize.bottomDis = tmpBottomDis;m_ItemPosAndSizeList[i] = itemPosAndSize;}float contentSize = tmpBottomDis;SetStubSize(m_EndStub, contentSize - (lastItemPosAndSize.bottomDis + m_ItemSpace)); }private void LateUpdate() {if (m_LateUpdateFlag > 0){m_LateUpdateFlag--;if (0 == m_LateUpdateFlag){UpdateItemsPosAndSize();}} }
实际使用中需要提供的Api
DataCount: 要展示的数据数量
RefreshItems: 根据数据数量和裁剪区域,重新生成所需的条目
ContentSize: 所有数据条目对应UI的总大小
ViewportSize: 裁剪区域大小
ScrollDistance: 滚动距离
ItemNodeCount: 实际生成的条目UI数量
GetItemNodeAt: 获取实际生成的条目UI
ScrollToItem(int itemIndex, float offset): 瞬时滚动到某个条目,常用于滚动到默认选中条目
AnimScrollToItem: 动画滚动到某个条目
ItemObtainDelegate: 获取条目UI
ItemRecycleDelegate: 回收条目UI
ScrollPercent: 当前的滚动条百分比, 值为0~100