原文鏈接:https://www.cnblogs.com/jingjiangtao/p/15827823.html
問題
Unity的ScrollView可以用滾動視圖的形式顯示列表。但是當列表中的數據非常多的時候,用ScrollView一次顯示出來就會卡頓,並且生成列表的速度也會變慢。
要解決這個問題,可以只顯示能看到的數據項,看不到的數據項就不加載,滑動列表時實時更新數據項,這樣就只需要創建和更新能看到的數據項,加載和滑動都不會卡頓。
本文只實現垂直滾動的列表。其它形式的列表實現方式類似。
實現方法
要實現一個通用的滾動視圖列表,有兩種代碼結構可供選擇:接口和委托。其中接口的實現方法比較傳統,委托的實現方法比較靈活。
本文選擇用接口實現,如果要用委托,可以把所有接口中定義的方法在對應的位置改成委托。
搭建UI
新建ScrollView
首先需要搭建ScrollView的UI用來測試。新建一個場景,如果場景中沒有Canvas就新建一個,然后在Canvas下新建一個Scroll View。

接下來要對列表做一些重要設置:
1. 選中ScrollRect組件所在的物體,取消勾選Horizontal以禁用水平滾動,將Scroll Sensitivity設為50使得鼠標滾輪滑動更靈敏。

2. 選中Content物體,先將Content物體的錨點和大小設置為和Viewport一樣大,具體操作是點擊Content物體的RectTransform組件的錨點設置工具,按住Alt,選擇圖中的布局:

這樣,Content的大小就和Viewport一樣大了
3. 再次選中Content物體,點擊Content物體的RectTransform組件錨點設置工具,按住Shift,選擇圖中的布局:

這樣就可以通過Content的PosY值獲取Content的位置了。最后放一張Content的RectTransform組件值的圖:

新建列表項
接下來需要創建列表項來顯示列表中的數據。簡單起見,列表項只包含一個表示列表項編號的文本和一個刪除按鈕。
1. 選中Content,在Content上點擊右鍵,新建空物體,重命名為TextItem,並將空物體調整到合適的大小:

2. 對列表項進行一些重要設置:選中TextItem,點擊RectTransform組件的錨點工具,按住Shift,選擇圖中的布局:

這是設置完成的RectTransform組件值:

3. 在TextItem中添加文本和按鈕:

4. 在Assets目錄下新建Prefabs目錄,把TextItem物體拖到這個目錄中,做成一個預制體,然后刪掉Content下的TextItem:

5. 在Canvas下新建一個按鈕,命名為AddBtn,位置如圖,用來實現添加列表項的功能:

至此,用於測試的UI搭建完畢。
代碼實現
存儲滾動視圖列表項的類 LoopScrollItem.cs
這個類用於保存單獨列表項中的數據,需要用列表項的GameObject初始化。在這個例子中,列表項的數據只有它自身的RectTransform和GameObject,如果要加新的數據,可以直接在這個類中暴露出來,方便訪問。
namespace LoopScrollTest.LoopScroll { public class LoopScrollItem { public RectTransform RectTransform => _transform; private RectTransform _transform; private GameObject _gameObject; public LoopScrollItem(GameObject gameObject) { _gameObject = gameObject; _transform = _gameObject.transform as RectTransform; } } }
用於滾動視圖腳本調用的接口 ILoopScrollView.cs
滾動列表的接口,包含幾種對列表的操作。如果有其它操作,可以在接口中聲明,並在LoopScrollView中調用。
namespace LoopScrollTest.LoopScroll { public interface ILoopScrollView { /// <summary> /// 用給定索引處的數據更新給定的列表項 /// </summary> void UpdateItemContent(int index, LoopScrollItem item); /// <summary> /// 用給定索引處的數據生成列表項並返回 /// </summary> GameObject InitScrollViewList(int index); /// <summary> /// 生成一個新的列表項並返回 /// </summary> GameObject InitOneItem(); /// <summary> /// 刪除指定的列表項 /// </summary> void DeleteOneItem(Transform item); /// <summary> /// 刪除一個列表項之后處理其它所有的列表項 /// </summary> void OtherItemAfterDeleteOne(LoopScrollItem item); } }
控制滾動視圖的腳本 LoopScrollView.cs
滾動視圖的主要類,繼承了MonoBehaviour,並且需要和ScrollRect組件掛在同一個物體上,並將ScrollRect組件拖到引用槽中。這個類處理了對滾動視圖的所有操作,並調用了接口中的方法。
namespace LoopScrollTest.LoopScroll { [RequireComponent(typeof(ScrollRect))] public class LoopScrollView : MonoBehaviour { public ScrollRect scrollRect; // 列表項數組 protected List<LoopScrollItem> _items = new List<LoopScrollItem>(); protected float _itemHeight; protected int _visibleCount; protected float _visibleHeight; protected int _sourceListCount; // 列表操作接口類型的實例,需要在初始化時賦值 protected ILoopScrollView _scrollViewOperate; protected virtual void Update() { RefreshGestureScrollView(); } /// <summary> /// 初始化循環列表的數值和引用 /// </summary> public virtual void InitScrollView(float itemHeight, ILoopScrollView scrollViewOperate) { _itemHeight = itemHeight; _visibleHeight = (scrollRect.transform as RectTransform).rect.height; _visibleCount = (int)(_visibleHeight / _itemHeight) + 1; _scrollViewOperate = scrollViewOperate; } /// <summary> /// 初始化循環列表的數據源 /// </summary> public virtual void InitScrollViewList(int sourceListCount) { _sourceListCount = sourceListCount; int generateCount = ResizeContent(); scrollRect.content.anchoredPosition = Vector2.zero; _items.Clear(); for (int i = 0; i < generateCount; i++) { GameObject itemGameObject = _scrollViewOperate.InitScrollViewList(i); LoopScrollItem item = new LoopScrollItem(itemGameObject); float itemY = -i * _itemHeight; item.RectTransform.anchoredPosition = new Vector2(scrollRect.content.anchoredPosition.x, itemY); _items.Add(item); } } /// <summary> /// 將指定索引的項對齊到列表界面的頂部 /// </summary> public virtual void MoveIndexToTop(int index) { float contentY = index * _itemHeight; scrollRect.content.anchoredPosition = new Vector2(scrollRect.content.anchoredPosition.x, contentY); RefreshGestureScrollView(); } /// <summary> /// 將指定索引的項對齊到列表界面的底部 /// </summary> public virtual void MoveIndexToBottom(int index) { float contentY = (index + 1) * _itemHeight - _visibleHeight; contentY = contentY < 0 ? 0f : contentY; scrollRect.content.anchoredPosition = new Vector2(scrollRect.content.anchoredPosition.x, contentY); RefreshGestureScrollView(); } /// <summary> /// 判斷指定的索引是否需要聚焦到底部,如果需要就對齊 /// </summary> public virtual void MoveToBottomIfNeeded(int index) { float itemY = -(index + 1) * _itemHeight; float bottomY = -(scrollRect.content.anchoredPosition.y + _visibleHeight); if (itemY < bottomY) { MoveIndexToBottom(index); } } /// <summary> /// 添加一條新項到列表中 /// </summary> public virtual void AddOneItem() { _sourceListCount++; int generateCount = ResizeContent(); if (_items.Count < generateCount) { GameObject itemGameObject = _scrollViewOperate.InitOneItem(); LoopScrollItem item = new LoopScrollItem(itemGameObject); _items.Add(item); } RefreshGestureScrollView(); } /// <summary> /// 刪除一條列表項 /// </summary> public virtual void DeleteOneItem() { _sourceListCount--; int generateCount = ResizeContent(); if (generateCount < _items.Count) { int lastIndex = _items.Count - 1; _scrollViewOperate.DeleteOneItem(_items[lastIndex].RectTransform); _items.RemoveAt(lastIndex); } RefreshGestureScrollView(); foreach (LoopScrollItem item in _items) { _scrollViewOperate.OtherItemAfterDeleteOne(item); } } /// <summary> /// 根據當前手勢項的數量重新調整內容的高度 /// </summary> protected virtual int ResizeContent() { int generateCount = Mathf.Min(_visibleCount, _sourceListCount); float contentHeight = _sourceListCount * _itemHeight; scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, contentHeight); return generateCount; } /// <summary> /// 刷新列表內容 /// </summary> protected virtual void RefreshGestureScrollView() { float contentY = scrollRect.content.anchoredPosition.y; int skipCount = (int)(contentY / _itemHeight); for (int i = 0; i < _items.Count; i++) { if (skipCount >= 0 && skipCount < _sourceListCount) { _scrollViewOperate.UpdateItemContent(skipCount, _items[i]); float itemY = -skipCount * _itemHeight; _items[i].RectTransform.anchoredPosition = new Vector2(scrollRect.content.anchoredPosition.x, itemY); skipCount++; } } } } }

以上的腳本就是滾動視圖的所有代碼,使用時掛載需要的腳本,並在其它類中實現接口即可使用。
以下是調用循環列表的示例:
控制單獨項的腳本 TextItem.cs
腳本繼承了MonoBehaviour,需要掛在TextItem預制體上,並將Text和Button拖到引用字段中,用來於控制單獨列表項的行為。
namespace LoopScrollTest { public class TextItem : MonoBehaviour { public Text text; public Button deleteBtn; public SourceData Data => _sourceData; private Action<TextItem> _deleteItemAction; private SourceData _sourceData; private void Awake() { deleteBtn.onClick.AddListener(OnClickDelete); } /// <summary> /// 初始化數據和按鈕點擊的委托 /// </summary> public void Init(SourceData data, Action<TextItem> deleteItemAction) { _sourceData = data; _deleteItemAction = deleteItemAction; text.text = _sourceData.text; } /// <summary> /// 更新引用的源數據 /// </summary> public void UpdateSourceData(SourceData data) { _sourceData = data; text.text = _sourceData.text; } /// <summary> /// 刪除按鈕的點擊事件 /// </summary> private void OnClickDelete() { _deleteItemAction?.Invoke(this); } } }

數據源實體類
namespace LoopScrollTest { public class SourceData { // 數據內容 public string text; } }
調用其它腳本用於測試的腳本 TestLoopScrollView.cs
用於控制UI和調用循環列表的腳本,需要在場景中新建GameObject物體,並將對應的引用拖到引用槽中以賦值。
namespace LoopScrollTest { public class TestLoopScrollView : MonoBehaviour, ILoopScrollView { [Tooltip("源數據列表的個數,修改后需要重新運行才能生效")] public uint count = 300; // 對滾動列表的引用 public LoopScrollView loopScrollView; // 對添加按鈕的引用 public Button addBtn; // 對ScrollView的Content物體的引用 public RectTransform content; // 對單個列表項預制體的引用 public TextItem textItemPrefab; // 源數據列表 private List<SourceData> _sourceList = new List<SourceData>(); private int nextId = 1; private void Awake() { addBtn.onClick.AddListener(OnClickAddItem); // 獲取單個列表項的高度 float itemHeight = (textItemPrefab.transform as RectTransform).rect.height; // 初始化滾動列表中的引用 loopScrollView.InitScrollView(itemHeight, this); } private void Start() { // 初始化源數據列表,作為列表的數據源 for (int i = 0; i < count; i++) { SourceData data = new SourceData { text = nextId.ToString() }; _sourceList.Add(data); nextId++; } // 初始化滾動列表的顯示 loopScrollView.InitScrollViewList(_sourceList.Count); } /// <summary> /// 在列表中追加新項 /// </summary> private void OnClickAddItem() { SourceData data = new SourceData { text = nextId.ToString(), }; _sourceList.Add(data); nextId++; loopScrollView.AddOneItem(); loopScrollView.MoveIndexToBottom(_sourceList.Count - 1); } /// <summary> /// 根據給定的源數據生成單個列表項 /// </summary> private TextItem InitTextItem(SourceData sourceData) { TextItem item = Instantiate(textItemPrefab, content); item.Init(sourceData, DeleteItemAction); return item; } /// <summary> /// 點擊單個列表項的刪除按鈕的回調,刪除單個列表項 /// </summary> private void DeleteItemAction(TextItem item) { _sourceList.Remove(item.Data); loopScrollView.DeleteOneItem(); } #region 實現的接口方法 public virtual void DeleteOneItem(Transform item) { DestroyImmediate(item.gameObject); } public virtual GameObject InitOneItem() { TextItem item = InitTextItem(_sourceList[_sourceList.Count - 1]); return item.gameObject; } public virtual GameObject InitScrollViewList(int index) { TextItem item = InitTextItem(_sourceList[index]); return item.gameObject; } public virtual void OtherItemAfterDeleteOne(LoopScrollItem item) { } public virtual void UpdateItemContent(int index, LoopScrollItem item) { // 更新列表項引用的源數據 TextItem textItem = item.RectTransform.GetComponent<TextItem>(); textItem.UpdateSourceData(_sourceList[index]); } #endregion } }

效果

完整項目
https://github.com/jingjiangtao/LoopScrollView
參考
https://blog.csdn.net/lxt610/article/details/90036080
