Unity的ScrollView滚动视图复用

发现问题

在游戏开发中有一个常见的需求,就是需要在屏幕显示多个(多达上百)显示item,然后用户用手指滚动视图可以选择需要查看的item。

现在的情况是在100个data的时候,Unity引擎是直接创建出对应的100个显示item。

这样的问题是显示屏只有6~7个是当前用户看得到的,其余的90多个一直放在内存中,这样的处理是一个比较浪费内存空间的处理方法。

所以我们现在需要一种优化,就是在data有100个的时候,我们只创建显示区域的几个显示item就好了,然后这几个显示item,我们会复用起来,不断的更新data到这几个显示item上。

要完成以上逻辑,需要处理的地方有一下几个:

1.item的更新data回调

2.item的数量回调

3.计算item的index、尺寸及对应的位置

模仿FairyGUI的处理

在FairyGUI,对于前两个问题,FairyGUI中有"列表"组件来完成;对于第三个问题,就使用了虚拟列表,来完成这种优化,现在,我们来模仿FiryGUI的逻辑在Unity的组件中完成这个功能。

解决前两个问题

框架代码

首先,对于前两个问题,我们来做一个简单的自定义滚动视图(先不处理复用的逻辑)。

cs 复制代码
using System;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RectTransform))]
[DisallowMultipleComponent]
public class ScrollView : ScrollRect
{

    [Tooltip("item的模板")]
    public RectTransform itemTemplate;
    
    //更新数据回调
    public Action<int, RectTransform> updateFunc;
    
    //设置数量回调(更新数据)
    public Func<int> itemCountFunc;

    public virtual void SetUpdateFunc(Action<int,RectTransform> func)
    {
        updateFunc = func;
    }

    public virtual void SetItemCountFunc(Func<int> func)
    {
        itemCountFunc = func;
        InternalUpdateData();
    }

    protected virtual void InternalUpdateData()
    {
        if (updateFunc == null)
        {
            return;
        }
        RemoveAllChildren();
        for (int i = 0; i < itemCountFunc(); i++)
        {
            GameObject itemObj = Instantiate(itemTemplate.gameObject, content, true);
            itemObj.transform.localPosition = itemTemplate.localPosition;
            itemObj.SetActive(true);
            updateFunc(i, itemObj.GetComponent<RectTransform>());
        }
    }

    public void RemoveAllChildren()
    {
        for(int i = 0;i < content.childCount; i++)
        {
            Transform child = content.GetChild(i);
            if (itemTemplate != child)
            {
                Destroy(child.gameObject);
            }
        }
    }
}

在这个脚本中,我们继承了ScrollRect组件,添加了item的更新数据回调;以及item的数据设置回调。

这两个问题的处理相对还算比较简单。

主要是通过回调来自定义data在对应显示item的创建。

脚本的在编辑器上显示为:

由于我们没有在ScrollView脚本中处理复用的逻辑,所以需要在显示对象Content上,添加Layout组件。

至此,我们解决前两个问题的框架的逻辑就处理好了。

示例

现在,我们贴出如何使用ScrollView的示例代码。

UIBoxRoguelike.cs

cs 复制代码
using UnityEngine.UI;

/// <summary>
/// 宝箱翻牌UI
/// </summary>
public class UIBoxRoguelike : BasePanel
{
    public const string ItemsList = "ItemsList";// 奖励列表
    public const string ClaimMagicBox = "ClaimMagicBox";// 领取神秘宝箱

    /// <summary>
    /// 随机类型 0正常 1随机 2神秘
    /// </summary>
    public enum RoguelikeType
    {
        Normal = 0,
        Random,
        Secret,
    }

    public Image imgBoxIcon;
    public Button btnMask;
    public ScrollView roguelikeSr;

    private bool _isSecret;// 是否神秘奖励

    private void Start()
    {
        var type = RoguelikeType.Normal;

        var boxCfg = ConfigManager._BoxCfgMgr.GetDataByID((int)BoxModel.Box.BoxID);
        imgBoxIcon.sprite = AssetBundleMgr.GetInstance().LoadUISprite(boxCfg.Icon);
        
        // 根据当前宝箱
        
        btnMask.onClick.AddListener(() =>
        {
            for (int i = 0; i < BoxModel.ItemsList.Count; i++)
            {
                // 存在神秘奖励 且 未领取
                if (BoxModel.ItemsList[i].Type != RoguelikeType.Secret && !BoxModel.HasSecretGet) continue;
                
                type = RoguelikeType.Secret;
                break;
            }
            
            // 存在神秘奖励 且 未领取
            if (type == RoguelikeType.Secret && !BoxModel.HasSecretGet)
            {
                UIMgr.GetInstance().ShowPanel<UIBoxPop>(UIDef.UI_BOXPOP, BoxModel.Box);
            }
            else
            {
                UIMgr.GetInstance().ShowPanel<UIRewardPanel>(UIDef.UI_REWARDPANEL, BoxModel.RewardList.ToArray());
            
                TimerHelper.SetTimeOut(0.3f, () =>
                {
                    UIMgr.GetInstance().ShowPanel<UIBoxDetail>(UIDef.UI_BOXDETAIL);
                });
                HideMe();
            }

            UIMgr.GetInstance().HidePanel(UIDef.UI_BOXDETAIL);
        });
    }

    public override void Notify(string msgType, object msgData)
    {
        base.Notify(msgType, msgData);

        switch (msgType)
        {
            case ItemsList:
                RefreshContent(msgData as RoguelikeItemData[]);
                break;
            case ClaimMagicBox:
                RefreshContent(msgData as RoguelikeItemData[]);
                break;
        }
    }

    private void RefreshContent(RoguelikeItemData[] data)
    {
        roguelikeSr.SetUpdateFunc((index, rectTransform) =>
        {
            UIBoxRoguelikeItem item = rectTransform.GetComponent<UIBoxRoguelikeItem>();
            item.OnRefresh(data[index]);
        });
        roguelikeSr.SetItemCountFunc(() => data.Length);
    }
}

这个示例代码,我们主要看RefreshConent方法就好了。

另一个脚本,UIBoxRoguelikeItem.cs。

cs 复制代码
using System.Text;
using UnityEngine;
using UnityEngine.UI;

public class UIBoxRoguelikeItem : MonoBehaviour
{
    public Image imgBg;
    public Image imgIcon;
    public Text txtTitle;
    public Text txtCount;
    public Button btnSecret;

    private RoguelikeItemData _data;

    private void Start()
    {
        btnSecret.onClick.AddListener(() =>
        {
            // 切换宝箱随机类型
            _data.Type = UIBoxRoguelike.RoguelikeType.Normal;
            // 刷新当前奖励信息
            OnRefresh(_data);
            
            // 禁用按钮
            btnSecret.gameObject.SetActive(false);
        });
    }

    public void OnRefresh(RoguelikeItemData data)
    {
        _data = data;
        
        imgIcon.sprite = AssetBundleMgr.GetInstance().LoadUISprite(data.Icon);
        imgBg.sprite = AssetBundleMgr.GetInstance().LoadUISprite(GetIconBgPathByType(data.Type));
        
        txtTitle.text = data.Name;
        txtCount.text = data.Count.ToString();
        
        txtTitle.gameObject.SetActive(data.Type != UIBoxRoguelike.RoguelikeType.Secret);
        txtCount.gameObject.SetActive(data.Type != UIBoxRoguelike.RoguelikeType.Secret);
        imgIcon.gameObject.SetActive(data.Type != UIBoxRoguelike.RoguelikeType.Secret);
        
        btnSecret.gameObject.SetActive(data.Type == UIBoxRoguelike.RoguelikeType.Secret);
    }

    private string GetIconBgPathByType(UIBoxRoguelike.RoguelikeType type)
    {
        StringBuilder iconBuilder = new StringBuilder();
        switch (type)
        {
            case UIBoxRoguelike.RoguelikeType.Normal:
                iconBuilder.Append("UIAtlas/Box/card02_icon");
                break;
            case UIBoxRoguelike.RoguelikeType.Random:
                iconBuilder.Append("UIAtlas/Box/card01_icon");
                break;
            case UIBoxRoguelike.RoguelikeType.Secret:
                iconBuilder.Append("UIAtlas/Box/card03_icon");
                break;
        }
        return iconBuilder.ToString();
    }
}

public class RoguelikeItemData
{
    public int ItemId;// 道具id
    public string Icon;// 图标
    public string Name;// 名字
    public int Count;// 数量
    public UIBoxRoguelike.RoguelikeType Type;// 随机类型

    public RoguelikeItemData(int itemId, string icon, string name, int count,
        UIBoxRoguelike.RoguelikeType type = UIBoxRoguelike.RoguelikeType.Normal)
    {
        ItemId = itemId;
        Icon = icon;
        Name = name;
        Count = count;
        Type = type; // 是否神秘宝箱
    }
}

复用的逻辑处理

框架代码

好了,现在我们来处理第三个问题,第三个问题比前两个问题要复杂得多。

处理的主要两个脚本文件是ScrollViewEx.cs和ScollViewExItem.cs

ScrollViewEx.cs代码:

cs 复制代码
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
using UnityEngine.Events;

[RequireComponent(typeof(RectTransform))]
[DisallowMultipleComponent]
public class ScrollViewEx : ScrollView
{
    
    [SerializeField]
    private int m_pageSize = 50;

    public int pageSize => m_pageSize;

    private int startOffset = 0;

    private Func<int> realItemCountFunc;

    private bool canNextPage = false;
    
    
    public class ScrollItemWithRect
    {
        // scroll item 身上的 RectTransform组件
        public RectTransform item;

        // scroll item 在scrollview中的位置
        public Rect rect;

        // rect 是否需要更新
        public bool rectDirty = true;
    }

    int m_dataCount = 0;
    List<ScrollItemWithRect> managedItems = new List<ScrollItemWithRect>();

    // for hide and show
    public enum ItemLayoutType
    {
                                        // 最后一位表示滚动方向
        Vertical = 1,                   // 0001
        Horizontal = 2,                 // 0010
        VerticalThenHorizontal = 4,     // 0100
        HorizontalThenVertical = 5,     // 0101
    }
    public const int flagScrollDirection = 1;  // 0001


    [SerializeField]
    ItemLayoutType m_layoutType = ItemLayoutType.Vertical;
    protected ItemLayoutType layoutType { get { return m_layoutType; } }


    // const int 代替 enum 减少 (int)和(CriticalItemType)转换
    protected static class CriticalItemType
    {
        public const int UpToHide = 0;
        public const int DownToHide = 1;
        public const int UpToShow = 2;
        public const int DownToShow = 3;
    }
    // 只保存4个临界index
    protected int[] criticalItemIndex = new int[4];
    Rect refRect;

    // resource management
    SimpleObjPool<RectTransform> itemPool = null;

    [Tooltip("初始化时池内item数量")]
    public int poolSize;

    [Tooltip("默认item尺寸")]
    public Vector2 defaultItemSize;

    [Tooltip("默认item间隔")]
    public Vector2 defaultItemSpace;

    //设置尺寸回调
    public Func<int, Vector2> itemSizeFunc;
    
    public Func<int, RectTransform> itemGetFunc;
    public Action<RectTransform> itemRecycleFunc;
    public Action<RectTransform> RecycleFunc;
    private Action UpdateCriticalItemsPreprocess = null;
    //选择元素回调
    private Action<int, RectTransform> selectIndexFunc;
    private UnityEvent<int, ScrollViewExItem> _onClickItem;
    
    // status
    private bool initialized = false;
    private int willUpdateData = 0;

    public override void SetUpdateFunc(Action<int,RectTransform> func)
    {
        if (func != null)
        {
            var f = func;
            func = (index, rect) =>
            {
                f(index + startOffset, rect);
            };
        }
        base.SetUpdateFunc(func);
    }

    public void SetItemSizeFunc(Func<int, Vector2> func)
    {
        if (func != null)
        {
            var f = func;
            func = (index) =>
            {
                return f(index + startOffset);
            };
        }
        itemSizeFunc = func;
    }

    public override void SetItemCountFunc(Func<int> func)
    {
        realItemCountFunc = func;
        if (func != null)
        {
            var f = func;
            func = () => Mathf.Min(f(), pageSize);
        }
        base.SetItemCountFunc(func);
    }
    public void SetItemRecycleFunc(Action<RectTransform> func)
    {
        RecycleFunc = func;
    }
    public void SetSelectIndexFunc(Action<int,RectTransform> func)
    {
        selectIndexFunc = func;
    }
    
    public void SetUpdateCriticalItemsPreprocess(Action func)
    {
        UpdateCriticalItemsPreprocess = func;
    }

    public void SetItemGetAndRecycleFunc(Func<int, RectTransform> getFunc, Action<RectTransform> recycleFunc)
    {
        if(getFunc != null && recycleFunc != null)
        {
            itemGetFunc = getFunc;
            itemRecycleFunc = recycleFunc;
        }
    }

    public void UpdateData(bool immediately = true)
    {
        if (!initialized)
        {
            InitScrollView();
        }
        if(immediately)
        {
            willUpdateData |= 3; // 0011
            InternalUpdateData();
        }
        else
        {
            if(willUpdateData == 0 && gameObject.active)
            {
                StartCoroutine(DelayUpdateData());
            }
            willUpdateData |= 3;
        }
    }

    public void UpdateDataIncrementally(bool immediately = true)
    {
        if (!initialized)
        {
            InitScrollView();
        }
        if (immediately)
        {
            willUpdateData |= 1; // 0001
            InternalUpdateData();
        }
        else
        {
            if (willUpdateData == 0)
            {
                StartCoroutine(DelayUpdateData());
            }
            willUpdateData |= 1;
        }
    }

    public void ScrollTo(int index)
    {
        InternalScrollTo(index);
    }

    protected void InternalScrollTo(int index)
    {
        int count = 0;
        if (realItemCountFunc != null)
        {
            count = realItemCountFunc();
        }
        index = Mathf.Clamp(index, 0, count - 1);
        startOffset = Mathf.Clamp(index - pageSize / 2, 0, count - itemCountFunc());
        UpdateData(true);
        
        index = Mathf.Clamp(index, 0, m_dataCount - 1);
        EnsureItemRect(index);
        Rect r = managedItems[index].rect;
        int dir = (int)layoutType & flagScrollDirection;
        if (dir == 1)
        {
            // vertical
            float value = 1 - (-r.yMax / (content.sizeDelta.y - refRect.height));
            //value = Mathf.Clamp01(value);
            SetNormalizedPosition(value, 1);
        }
        else
        {
            // horizontal
            float value = r.xMin / (content.sizeDelta.x - refRect.width);
            //value = Mathf.Clamp01(value);
            SetNormalizedPosition(value, 0);
        }
    }

    private IEnumerator DelayUpdateData()
    {
        yield return null;
        InternalUpdateData();
    }


    protected override void InternalUpdateData()
    {
        int newDataCount = 0;
        bool keepOldItems = ((willUpdateData & 2) == 0);

        if (itemCountFunc != null)
        {
            newDataCount = itemCountFunc();
        }

        // if (newDataCount != managedItems.Count)
        if (true)
        {
            if (managedItems.Count < newDataCount) //增加
            {
                if(!keepOldItems)
                {
                    foreach (var itemWithRect in managedItems)
                    {
                        // 重置所有rect
                        itemWithRect.rectDirty = true;
                    }
                }

                while (managedItems.Count < newDataCount)
                {
                    managedItems.Add(new ScrollItemWithRect());
                }
            }
            else //减少 保留空位 避免GC
            {
                for (int i = 0, count = managedItems.Count; i < count; ++i)
                {
                    if(i < newDataCount)
                    {
                        // 重置所有rect
                        if(!keepOldItems)
                        {
                            managedItems[i].rectDirty = true;
                        }

                        if(i == newDataCount - 1)
                        {
                            managedItems[i].rectDirty = true;
                        }
                    }

                    // 超出部分 清理回收item
                    if (i >= newDataCount)
                    {
                        managedItems[i].rectDirty = true;
                        if (managedItems[i].item != null)
                        {
                            RecycleOldItem(managedItems[i].item);
                            managedItems[i].item = null;
                        }
                    }
                }
            }
        }
        else
        {
            if(!keepOldItems)
            {
                for (int i = 0, count = managedItems.Count; i < count; ++i)
                {
                    // 重置所有rect
                    managedItems[i].rectDirty = true;
                }
            }
        }

        m_dataCount = newDataCount;

        ResetCriticalItems();

        willUpdateData = 0;
    }

    void ResetCriticalItems()
    {
        bool hasItem, shouldShow;
        int firstIndex = -1, lastIndex = -1;

        for (int i = 0; i < m_dataCount; i++)
        {
            hasItem = managedItems[i].item != null;
            shouldShow = ShouldItemSeenAtIndex(i);

            if (shouldShow)
            {
                if (firstIndex == -1)
                {
                    firstIndex = i;
                }
                lastIndex = i;
            }

            if (hasItem && shouldShow)
            {
                // 应显示且已显示
                SetDataForItemAtIndex(managedItems[i].item, i);
                continue;
            }

            if (hasItem == shouldShow)
            {
                // 不应显示且未显示
                //if (firstIndex != -1)
                //{
                //    // 已经遍历完所有要显示的了 后边的先跳过
                //    break;
                //}
                continue;
            }

            if (hasItem && !shouldShow)
            {
                // 不该显示 但是有
                RecycleOldItem(managedItems[i].item);
                managedItems[i].item = null;
                continue;
            }

            if (shouldShow && !hasItem)
            {
                // 需要显示 但是没有
                RectTransform item = GetNewItem(i);
                managedItems[i].item = item;
                OnGetItemForDataIndex(item, i);
                continue;
            }

        }

        // content.localPosition = Vector2.zero;
        criticalItemIndex[CriticalItemType.UpToHide] = firstIndex;
        criticalItemIndex[CriticalItemType.DownToHide] = lastIndex;
        criticalItemIndex[CriticalItemType.UpToShow] = Mathf.Max(firstIndex - 1, 0);
        criticalItemIndex[CriticalItemType.DownToShow] = Mathf.Min(lastIndex + 1, m_dataCount - 1);

    }

    protected override void SetContentAnchoredPosition(Vector2 position)
    {
        base.SetContentAnchoredPosition(position);
        UpdateCriticalItemsPreprocess?.Invoke();
        UpdateCriticalItems();
    }

    protected override void SetNormalizedPosition(float value, int axis)
    {
        base.SetNormalizedPosition(value, axis);
        ResetCriticalItems();
    }

    RectTransform GetCriticalItem(int type)
    {
        int index = criticalItemIndex[type];
        if(index >= 0 && index < m_dataCount)
        {
            return managedItems[index].item;
        }
        return null;
    }
    void UpdateCriticalItems()
    {
        //if (itemSizeFunc != null)
        //{
        //    managedItems.ForEach(item =>
        //    {
        //        item.rectDirty = true;
        //    });
        //}

        bool dirty = true;

        while (dirty)
        {
            dirty = false;

            for (int i = CriticalItemType.UpToHide; i <= CriticalItemType.DownToShow; i ++)
            {
                if(i <= CriticalItemType.DownToHide) //隐藏离开可见区域的item
                {
                    dirty = dirty || CheckAndHideItem(i);
                }
                else  //显示进入可见区域的item
                {
                    dirty = dirty || CheckAndShowItem(i);
                }
            }
        }
    }

    public void ForceUpdateCriticalItems()
    {
        // Debug.Log("count : "+managedItems.Count);
        //
        // managedItems.ForEach(item =>
        // {
        //     item.rectDirty = true;
        // });
        //
        UpdateCriticalItems();
    }

    private bool CheckAndHideItem(int criticalItemType)
    {
        RectTransform item = GetCriticalItem(criticalItemType);
        int criticalIndex = criticalItemIndex[criticalItemType];
        if (item != null && !ShouldItemSeenAtIndex(criticalIndex))
        {
            RecycleOldItem(item);
            managedItems[criticalIndex].item = null;
            //Debug.Log("回收了 " + criticalIndex);

            if (criticalItemType == CriticalItemType.UpToHide)
            {
                // 最上隐藏了一个
                criticalItemIndex[criticalItemType + 2] = Mathf.Max(criticalIndex, criticalItemIndex[criticalItemType + 2]);
                criticalItemIndex[criticalItemType]++;
            }
            else
            {
                // 最下隐藏了一个
                criticalItemIndex[criticalItemType + 2] = Mathf.Min(criticalIndex, criticalItemIndex[criticalItemType + 2]);
                criticalItemIndex[criticalItemType]--;
            }
            criticalItemIndex[criticalItemType] = Mathf.Clamp(criticalItemIndex[criticalItemType], 0, m_dataCount - 1);
            return true;
        }
        
        return false;
    }


    private bool CheckAndShowItem(int criticalItemType)
    {
        RectTransform item = GetCriticalItem(criticalItemType);
        int criticalIndex = criticalItemIndex[criticalItemType];
        //if (item == null && ShouldItemFullySeenAtIndex(criticalItemIndex[criticalItemType - 2]))

        if (item == null && ShouldItemSeenAtIndex(criticalIndex))
        {
            RectTransform newItem = GetNewItem(criticalIndex);
            OnGetItemForDataIndex(newItem, criticalIndex);
            //Debug.Log("创建了 " + criticalIndex);
            managedItems[criticalIndex].item = newItem;


            if (criticalItemType == CriticalItemType.UpToShow)
            {
                // 最上显示了一个
                criticalItemIndex[criticalItemType - 2] = Mathf.Min(criticalIndex, criticalItemIndex[criticalItemType - 2]);
                criticalItemIndex[criticalItemType]--;
            }
            else
            {
                // 最下显示了一个
                criticalItemIndex[criticalItemType - 2] = Mathf.Max(criticalIndex, criticalItemIndex[criticalItemType - 2]);
                criticalItemIndex[criticalItemType]++;
            }
            criticalItemIndex[criticalItemType] = Mathf.Clamp(criticalItemIndex[criticalItemType], 0, m_dataCount - 1);
            return true;
        }
        return false;
    }
    
    bool ShouldItemSeenAtIndex(int index)
    {
        if(index < 0 || index >= m_dataCount)
        {
            return false;
        }
        EnsureItemRect(index);
        return new Rect(refRect.position - content.anchoredPosition, refRect.size).Overlaps(managedItems[index].rect);
    }

    bool ShouldItemFullySeenAtIndex(int index)
    {
        if (index < 0 || index >= m_dataCount)
        {
            return false;
        }
        EnsureItemRect(index);
        return IsRectContains(new Rect(refRect.position - content.anchoredPosition, refRect.size),(managedItems[index].rect));
    }

    bool IsRectContains(Rect outRect, Rect inRect, bool bothDimensions = false)
    {

        if (bothDimensions)
        {
            bool xContains = (outRect.xMax >= inRect.xMax) && (outRect.xMin <= inRect.xMin);
            bool yContains = (outRect.yMax >= inRect.yMax) && (outRect.yMin <= inRect.yMin);
            return xContains && yContains;
        }
        else
        {
            int dir = (int)layoutType & flagScrollDirection;
            if(dir == 1)
            {
                // 垂直滚动 只计算y向
                return (outRect.yMax >= inRect.yMax) && (outRect.yMin <= inRect.yMin);
            }
            else // = 0
            {
                // 水平滚动 只计算x向
                return (outRect.xMax >= inRect.xMax) && (outRect.xMin <= inRect.xMin);
            }
        }
    }


    void InitPool()
    {
        GameObject poolNode = new GameObject("POOL");
        poolNode.SetActive(false);
        poolNode.transform.SetParent(transform,false);
        itemPool = new SimpleObjPool<RectTransform>(
            poolSize,
            (RectTransform item) => {
                // 回收
                item.transform.SetParent(poolNode.transform,false);
            },
            () => {
                // 构造
                GameObject itemObj = Instantiate(itemTemplate.gameObject);
                
                //设置元素的滚动视图组件(即this)
                if (itemObj.GetComponent<ScrollViewExItem>())
                {
                    itemObj.GetComponent<ScrollViewExItem>().scrollView = this;
                }
                
                RectTransform item = itemObj.GetComponent<RectTransform>();
                itemObj.transform.SetParent(poolNode.transform,false);

                item.anchorMin = Vector2.up;
                item.anchorMax = Vector2.up;
                item.pivot = Vector2.zero;
                //rectTrans.pivot = Vector2.up;

                itemObj.SetActive(true);
                return item;
            });
    }

    void OnGetItemForDataIndex(RectTransform item, int index)
    {
        SetDataForItemAtIndex(item, index);
        item.transform.SetParent(content, false);
    }


    void SetDataForItemAtIndex(RectTransform item, int index)
    {
        if (updateFunc != null)
            updateFunc(index,item);

        SetPosForItemAtIndex(item,index);
    }


    void SetPosForItemAtIndex(RectTransform item, int index)
    {
        EnsureItemRect(index);
        var managedItem = managedItems[index];
        if (managedItem.item != null && managedItem.item.GetComponent<ScrollViewExItem>())
        {
            item.GetComponent<ScrollViewExItem>().itemIndex = index;
        }
        Rect r = managedItem.rect;
        item.localPosition = r.position;
        item.sizeDelta = r.size;
    }


    Vector2 GetItemSize(int index,ScrollItemWithRect item)
    {
        if(index >= 0 && index <= m_dataCount)
        {
            if (itemSizeFunc != null)
            {
                return itemSizeFunc(index);
            }
        }
        return defaultItemSize;
    }

    private RectTransform GetNewItem(int index)
    {
        RectTransform item;
        if(itemGetFunc != null)
        {
            item = itemGetFunc(index);
        }
        else
        {
            item = itemPool.Get();
        }
        return item;
    }

    private void RecycleOldItem(RectTransform item)
    {
        if (itemRecycleFunc != null)
        {
            itemRecycleFunc(item);
        }
        else
        {
            itemPool.Recycle(item);
        }
        if (RecycleFunc != null)
        {
            RecycleFunc(item);
        }
    }

    void InitScrollView()
    {
        initialized = true;

        // 根据设置来控制原ScrollRect的滚动方向
        int dir = (int)layoutType & flagScrollDirection;

        content.pivot = Vector2.up;
        InitPool();
        UpdateRefRect();
    }


    Vector3[] viewWorldConers = new Vector3[4];
    Vector3[] rectCorners = new Vector3[2];
    void UpdateRefRect()
    {
        /*
         *  WorldCorners
         * 
         *    1 ------- 2     
         *    |         |
         *    |         |
         *    0 ------- 3
         * 
         */

        // refRect是在Content节点下的 viewport的 rect
        viewRect.GetWorldCorners(viewWorldConers);
        rectCorners[0] = content.transform.InverseTransformPoint(viewWorldConers[0]);
        rectCorners[1] = content.transform.InverseTransformPoint(viewWorldConers[2]);
        refRect = new Rect((Vector2)rectCorners[0] - content.anchoredPosition, rectCorners[1] - rectCorners[0]);
    }

    void MovePos(ref Vector2 pos, Vector2 size)
    {
        // 注意 所有的rect都是左下角为基准
        switch (layoutType)
        {
            case ItemLayoutType.Vertical:
                // 垂直方向 向下移动
                pos.y -= size.y;
                break;
            case ItemLayoutType.Horizontal:
                // 水平方向 向右移动
                pos.x += size.x;
                break;
            case ItemLayoutType.VerticalThenHorizontal:
                pos.y -= size.y;
                if (pos.y <= -(refRect.height - size.y / 2))
                {
                    pos.y = 0;
                    pos.x += size.x;
                }
                break;
            case ItemLayoutType.HorizontalThenVertical:
                pos.x += size.x;
                if(pos.x >= refRect.width - size.x / 2)
                {
                    pos.x = 0;
                    pos.y -= size.y;
                }
                break;
            default:
                break;
        }
    }

    protected void EnsureItemRect(int index)
    {
        if (!managedItems[index].rectDirty)
        {
            // 已经是干净的了
            return;
        }

        ScrollItemWithRect firstItem = managedItems[0];
        if (firstItem.rectDirty)
        {
            Vector2 firstSize = GetItemSize(0, firstItem);
            firstItem.rect = CreateWithLeftTopAndSize(Vector2.zero, firstSize);
            firstItem.rect.position += defaultItemSpace;
            firstItem.rectDirty = false;
            if (firstItem.item)
            {
                firstItem.item.localPosition = firstItem.rect.position;
            }
        }

        // 当前item之前的最近的已更新的rect
        int nearestClean = 0;
        for (int i = index; i >= 0; --i)
        {
            if (!managedItems[i].rectDirty)
            {
                nearestClean = i;
                break;
            }
        }

        // 需要更新 从 nearestClean 到 index 的尺寸
        Rect nearestCleanRect = managedItems[nearestClean].rect;
        Vector2 curPos = GetLeftTop(nearestCleanRect);
        Vector2 size = nearestCleanRect.size;
        MovePos(ref curPos, size);

        for (int i = nearestClean + 1; i <= index; i++)
        {
            size = GetItemSize(i, managedItems[i]);
            managedItems[i].rect = CreateWithLeftTopAndSize(curPos, size);
            managedItems[i].rect.position += defaultItemSpace;
            managedItems[i].rectDirty = false;
            MovePos(ref curPos, size);
            if (managedItems[i].item)
            {
                managedItems[i].item.localPosition = managedItems[i].rect.position;
            }
        }

        Vector2 range = new Vector2(Mathf.Abs(curPos.x), Mathf.Abs(curPos.y));
        switch (layoutType)
        {
            case ItemLayoutType.VerticalThenHorizontal:
                range.x += size.x;
                range.y = refRect.height;
                break;
            case ItemLayoutType.HorizontalThenVertical:
                range.x = refRect.width;
                if (curPos.x != 0)
                {
                    range.y += size.y;
                }

                break;
            default:
                break;
        }

        content.sizeDelta = range;
    }
    
    //选择Item
    public void SelectItem(int index)
    {
        for (int i = 0; i < managedItems.Count; i++)
        {
            var managedItem = managedItems[i];
            if (managedItem != null && managedItem.item != null && managedItem.item.GetComponent<ScrollViewExItem>())
            {
                ScrollViewExItem item = managedItem.item.GetComponent<ScrollViewExItem>();
                item.SetSelected(item.itemIndex == index);
                if (item.itemIndex == index && selectIndexFunc != null)
                {
                    selectIndexFunc(index, managedItem.item);
                }
            }
        }
    }
    
    public UnityEvent<int, ScrollViewExItem> onClickItem => _onClickItem ?? (_onClickItem = new UnityEvent<int, ScrollViewExItem>());

    private static Vector2 GetLeftTop(Rect rect)
    {
        Vector2 ret = rect.position;
        ret.y += rect.size.y;
        return ret;
    }
    private static Rect CreateWithLeftTopAndSize(Vector2 leftTop, Vector2 size)
    {
        Vector2 leftBottom = leftTop - new Vector2(0,size.y);
        //Debug.Log(" leftBottom : "+leftBottom +" size : "+size );
        return new Rect(leftBottom,size);
    }


    protected override void OnDestroy()
    {
        if (itemPool != null)
        {
            itemPool.Purge();
        }
    }

    protected Rect GetItemLocalRect(int index)
    {
        if(index >= 0 && index < m_dataCount)
        {
            EnsureItemRect(index);
            return managedItems[index].rect;
        }
        return new Rect();
    }

    protected override void Awake()
    {
        base.Awake();
        onValueChanged.AddListener(OnValueChanged);
    }

    private void Update()
    {
        if (Input.GetMouseButtonUp(0) || Input.GetMouseButtonDown(0))
            canNextPage = true;
    }

    bool reloadFlag = false;


    private void OnValueChanged(Vector2 position)
    {
        if (reloadFlag)
        {
            UpdateData(true);
            reloadFlag = false;
        }
        if (Input.GetMouseButton(0) && !canNextPage) return;

        int toShow;
        int critical;
        bool downward;
        int pin;
        if (((int)layoutType & flagScrollDirection) == 1)
        {
            // 垂直滚动 只计算y向
            if (velocity.y > 0)
            {
                // 向上
                toShow = criticalItemIndex[CriticalItemType.DownToShow];
                critical = pageSize - 1;
                if (toShow < critical)
                {
                    return;
                }
                pin = critical - 1;
                downward = false;
            }
            else
            {
                // 向下
                toShow = criticalItemIndex[CriticalItemType.UpToShow];
                critical = 0;
                if (toShow > critical)
                {
                    return;
                }
                pin = critical + 1;
                downward = true;
            }
        }
        else // = 0
        {
            // 水平滚动 只计算x向
            if (velocity.x > 0)
            {
                // 向右
                toShow = criticalItemIndex[CriticalItemType.UpToShow];
                critical = 0;
                if (toShow > critical)
                {
                    return;
                }
                pin = critical + 1;
                downward = true;
            }
            else
            {
                // 向左
                toShow = criticalItemIndex[CriticalItemType.DownToShow];
                critical = pageSize - 1;
                if (toShow < critical)
                {
                    return;
                }
                pin = critical - 1;
                downward = false;
            }
        }

        // 翻页
        int old = startOffset;
        if (downward)
        {
            startOffset -= pageSize / 2;
        }
        else
        {
            startOffset += pageSize / 2;
        }
        canNextPage = false;


        int realDataCount = 0;
        if (realItemCountFunc != null)
        {
            realDataCount = realItemCountFunc();
        }
        startOffset = Mathf.Clamp(startOffset, 0, Mathf.Max(realDataCount - pageSize, 0));

        if (old != startOffset)
        {
            reloadFlag = true;

            // 计算 pin元素的世界坐标
            Rect rect = GetItemLocalRect(pin);
            Vector2 oldWorld = content.TransformPoint(rect.position);
            UpdateData(true);
            int dataCount = 0;
            if (itemCountFunc != null)
            {
                dataCount = itemCountFunc();
            }
            if (dataCount > 0)
            {
                EnsureItemRect(0);
                if (dataCount > 1)
                {
                    EnsureItemRect(dataCount - 1);
                }
            }

            // 根据 pin元素的世界坐标 计算出content的position
            int pin2 = pin + old - startOffset;
            Rect rect2 = GetItemLocalRect(pin2);
            Vector2 newWorld = content.TransformPoint(rect2.position);
            Vector2 deltaWorld = newWorld - oldWorld;

            Vector2 deltaLocal = content.InverseTransformVector(deltaWorld);
            SetContentAnchoredPosition(content.anchoredPosition - deltaLocal);

            UpdateData(true);

            // 减速
            velocity /= 50f;
        }

    }
}

ScrollViewExItem.cs

cs 复制代码
using UnityEngine;

public class ScrollViewExItem : MonoBehaviour
{
    public ScrollViewEx scrollView;
    
    public int itemIndex;
    public bool isSelected;

    public void SetSelected(bool value)
    {
        isSelected = value;
        OnSelected();
    }
    
    //选择监听方法
    public virtual void OnSelected()
    {
        
    }
    
    //点击监听方法
    public virtual void OnClick()
    {
        scrollView.onClickItem.Invoke(itemIndex, this);
    }
}

还有一个工具类脚本,SimpleObjPool.cs。

cs 复制代码
using System;
using System.Collections.Generic;


public class SimpleObjPool<T>
{

    private readonly Stack<T> m_Stack;
    private readonly Func<T> m_ctor;
    private readonly Action<T> m_OnRecycle;
    private int m_Size;
    private int m_UsedCount;


    public SimpleObjPool(int max = 5, Action<T> actionOnReset = null, Func <T> ctor = null)
    {
        m_Stack = new Stack<T>(max);
        m_Size = max;
        m_OnRecycle = actionOnReset;
        m_ctor = ctor;
    }


    public T Get()
    {
        T item;
        if (m_Stack.Count == 0)
        {
            if(null != m_ctor)
            {
                item = m_ctor();
            }
            else
            {
                item = Activator.CreateInstance<T>();
            }
        }
        else
        {
            item = m_Stack.Pop();
        }
        m_UsedCount++;
        return item;
    }

    public void Recycle(T item)
    {
        if(m_OnRecycle!= null)
        {
            m_OnRecycle.Invoke(item);
        }
        if(m_Stack.Count < m_Size)
        {
            m_Stack.Push(item);
        }
        m_UsedCount -- ;
    }


    /*
    public T GetAndAutoRecycle()
    {
        T obj = Get();
        Utils.OnNextFrameCall(()=> { Recycle(obj); });
        return obj;
    }
    */

    public void Purge()
    {
        // TODO
    }


    public override string ToString()
    {
        return string.Format("SimpleObjPool: item=[{0}], inUse=[{1}], restInPool=[{2}/{3}] ", typeof(T), m_UsedCount, m_Stack.Count, m_Size);
    }

}

以上三个脚本的代码就不一一细说了,大家可以参考。

至此,我们的滚动视图复用框架就完成了。

示例

示例代码

接下来贴出使用的组件截图和使用脚本示例代码。

使用的实力代码脚本为UIBoxDetail.cs和UIBoxDetailItem.cs。

cs 复制代码
using System.Collections.Generic;
using Msg;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// 宝箱详情UI
/// </summary>
public class UIBoxDetail : BasePanel
{
    public const string BoxList = "UI_Event_BoxList";// 宝箱列表
    public const string UnlockBox = "UI_Event_UnlockBox";// 解锁宝箱
    public const string ReduceTime = "UI_Event_ReduceTime";// 扣减广告加速时间
    
    public RectTransform coinDiamondRoot;
    public Button btnBack;
    
    /// <summary>
    /// 宝箱背景类型
    /// </summary>
    public enum BgType
    {
        None,// 无宝箱
        Lock,// 未解锁
        SpeedUp,// 加速
        Get,// 领取
        Overflow// 已满
    }

    public ScrollViewEx detailSrEx;

    private void OnEnable()
    {
        EventMgr.GetInstance().AddEventListener<BoxOpenResponse>(BoxEvent.BoxOpenResponse, OnBoxOpenResponse);
    }

    private void OnDisable()
    {
        EventMgr.GetInstance().RemoveEventListener<BoxOpenResponse>(BoxEvent.BoxOpenResponse, OnBoxOpenResponse);
    }

    protected override void Awake()
    {
        detailSrEx.UpdateData(false);
        detailSrEx.SetUpdateFunc((index, rectTransform) =>
        {
            UIBoxDetailItem item = rectTransform.GetComponent<UIBoxDetailItem>();
            item.OnRefresh(BoxModel.BoxList[index]);
        });

        detailSrEx.SetItemCountFunc(() => BoxModel.BoxList.Count);
    }
    private void Start()
    {
        BoxMgr.GetInstance().BoxListReq();

        UIMgr.GetInstance().ShowInnerRes(coinDiamondRoot, new List<TopInnerResDataVo>
        {
            new TopInnerResDataVo(E_TopInnerRes.Coin, PersonalInfoModel.Player.NumGold),
            new TopInnerResDataVo(E_TopInnerRes.Diamond, PersonalInfoModel.Player.NumStone)
        });
        
        txtClose.text = MultilingualUtil.MultilingualText(29);
        
        btnBack.onClick.AddListener(HideMe);
    }
    
    public override void Notify(string msgType, object msgData)
    {
        base.Notify(msgType, msgData);
    
        switch (msgType)
        {
            case BoxList:
            case UnlockBox:
            case ReduceTime:
                RefreshBoxList(msgData as Box[]);
                break;
        }
    }

    private void RefreshBoxList(Box[] boxes)
    {
        detailSrEx.UpdateData(false);
        detailSrEx.SetUpdateFunc((index, rectTransform) =>
        {
            UIBoxDetailItem item = rectTransform.GetComponent<UIBoxDetailItem>();
            item.OnRefresh(boxes[index]);
        });

        detailSrEx.SetItemCountFunc(() => boxes.Length);
    }

    #region response

    private void OnBoxOpenResponse(BoxOpenResponse response)
    {
        detailSrEx.SetUpdateFunc((index, rectTransform) =>
        {
            rectTransform.name = index.ToString();
        });
        detailSrEx.SetItemCountFunc(() => BoxModel.BoxList.Count);
    }

    #endregion
    
    [Header("---- 多语言控件 ----")]
    public Text txtClose;
}
cs 复制代码
using System.Text;
using Msg;
using UnityEngine;
using UnityEngine.UI;

public class UIBoxDetailItem : ScrollViewExItem
{
    public RectTransform timeGroup;
    public Image imgBg;
    public Image imgIcon;
    public Image imgMask;
    public Text txtTime;
    public Text txtTips;
    public Text txtEmpty;
    public Text txtTitle;
    public Button btnTitle;
    
    private StringBuilder _iconPath = new StringBuilder();// icon路径
    private StringBuilder _titleBuilder = new StringBuilder();
    private UIBoxDetail.BgType _selectedType;// 当前选中宝箱
    private Timer _timer;
    private Timer _timerUpdate;
    private long _countdownStamp;// 倒计时时间
    private bool _isTimeGroup;// 是否启用时间组件
    private bool _isTime;// 是否启用时间文本UI
    private bool _isIcon;// 是否启用Icon

    private void Start()
    {
        _isTimeGroup = false;
        _isTime = false;
        _isIcon = false;
        _selectedType = UIBoxDetail.BgType.None;// 默认无
        
        btnTitle.onClick.AddListener(() =>
        {
            BoxModel.SetBox(BoxModel.BoxList[itemIndex]);
            
            switch (_selectedType)
            {
                case UIBoxDetail.BgType.Lock:
                case UIBoxDetail.BgType.SpeedUp:
                    UIMgr.GetInstance().ShowPanel<UIBoxOpen>(UIDef.UI_BOXOPEN, BoxModel.BoxList[itemIndex]);
                    break;
                case UIBoxDetail.BgType.Get:// 直接领取奖励
                    BoxMgr.GetInstance().BoxClaimRewardReq(BoxModel.BoxList[itemIndex].BoxID, BoxModel.BoxList[itemIndex].ID);
                    break;
            }
        });
    }

    private void OnDestroy()
    {
        _timer?.Stop();
        _timerUpdate?.Stop();
    }
    
    public void OnRefresh(Box data)
    {
        _timer?.Stop();
        _timerUpdate?.Stop();
        
        // 创建新角色没匹配时,宝箱列表没有长度
        if (data == null)
        {
            RefreshContent(UIBoxDetail.BgType.None, null);
            return;
        }

        // 新角色匹配后,宝箱列表有长度
        if (data.ID != string.Empty && data.BoxID == 0)
        {
            RefreshContent(UIBoxDetail.BgType.None, data);
            return;
        }
        
        if (data.ID == string.Empty && data.BoxID == 0)
        {
            RefreshContent(UIBoxDetail.BgType.None, data);
            return;
        }
        
        var boxCfg = ConfigManager._BoxCfgMgr.GetDataByID((int)data.BoxID);
        var second = BoxMgr.GetInstance().CalculateSecond(boxCfg.LifeTime);

        if (data.UnlockTimeStamp == 0)// 未解锁
        {
            RefreshContent(UIBoxDetail.BgType.Lock, data);

            txtTime.text = second > 10
                ? second + MultilingualUtil.MultilingualText(426)
                : second + MultilingualUtil.MultilingualText(280);
        }
        else if (data.UnlockTimeStamp > 0 && TimeUtil.GetUnixTimeStamp() < data.UnlockTimeStamp)// 加速
        {
            RefreshContent(UIBoxDetail.BgType.SpeedUp, data);
            _countdownStamp = data.UnlockTimeStamp - TimeUtil.GetUnixTimeStamp() - data.ReduceTime;

            // 当前宝箱时间戳小于
            _timer = new Timer(1f, true, () =>
            {
                _countdownStamp--;
                
                if (txtTime != null)
                    txtTime.text = TimeUtil.FormatTime(_countdownStamp);
            });
            _timer.Start();
            _timerUpdate = new Timer(Time.deltaTime, true, () =>
            {
                if (_countdownStamp <= 0)
                {
                    BoxMgr.GetInstance().BoxListReq();// 重新请求宝箱列表
                    BoxModel.SetHasSpeedUp(false);
                    _timer?.Stop();
                    _timerUpdate?.Stop();
                }
            });
            _timerUpdate.Start();
        }
        else if (data.UnlockTimeStamp > 0 && TimeUtil.GetUnixTimeStamp() > data.UnlockTimeStamp)// 可领取
        {
            RefreshContent(UIBoxDetail.BgType.Get, data);
        }

        if (data.UnlockTimeStamp == 0)
            txtTime.text = second > 10
                ? second + MultilingualUtil.MultilingualText(426)
                : second + MultilingualUtil.MultilingualText(280);
        else
            txtTime.text = TimeUtil.FormatTime(_countdownStamp);
        
        imgIcon.sprite = AssetBundleMgr.GetInstance().LoadUISprite(data.BoxID != 0 ? boxCfg.Icon : "");
    }

    /// <summary>
    /// 刷新内容
    /// </summary>
    /// <param name="type">类型</param>
    /// <param name="data">宝箱数据</param>
    private void RefreshContent(UIBoxDetail.BgType type, Box data)
    {
        _iconPath.Clear();
        _titleBuilder.Clear();

        switch (type)
        {
            case UIBoxDetail.BgType.None: // 无宝箱
                _isTime = false;
                _isTimeGroup = false;
                _isIcon = false;
                _selectedType = UIBoxDetail.BgType.None;
                _iconPath.Append("UIAtlas/Box/empty_btn");
                break;
            case UIBoxDetail.BgType.Lock: // 未解锁
                _isTime = true;
                _isTimeGroup = false;
                _isIcon = true;
                _selectedType = UIBoxDetail.BgType.Lock;
                _titleBuilder.Append(MultilingualUtil.MultilingualText(85));
                _iconPath.Append("UIAtlas/Box/treasure02_btn");
                break;
            case UIBoxDetail.BgType.SpeedUp: // 加速
                _isTime = true;
                _isTimeGroup = true;
                _isIcon = true;
                _selectedType = UIBoxDetail.BgType.SpeedUp;
                _titleBuilder.Append(MultilingualUtil.MultilingualText(86));
                _iconPath.Append("UIAtlas/Box/treasure01_btn");
                break;
            case UIBoxDetail.BgType.Get: // 领取奖励
                _isTime = false;
                _isTimeGroup = false;
                _isIcon = true;
                _selectedType = UIBoxDetail.BgType.Get;
                _titleBuilder.Append(MultilingualUtil.MultilingualText(87));
                _iconPath.Append("UIAtlas/Box/open_btn");
                break;
        }

        var boxCfg = ConfigManager._BoxCfgMgr.GetDataByID((int)data.BoxID);
        if (boxCfg != null)
            txtTips.text = BoxCfgMgr.Instance.GetMultiLangName(boxCfg);

        txtTitle.text = _titleBuilder.ToString();
        txtEmpty.text = MultilingualUtil.MultilingualText(84);
        imgBg.sprite = AssetBundleMgr.GetInstance().LoadUISprite(_iconPath.ToString());

        imgIcon.gameObject.SetActive(_isIcon);
        txtTime.gameObject.SetActive(data.BoxID != 0 && _isTime);
        timeGroup.gameObject.SetActive(_isTimeGroup);

        imgMask.gameObject.SetActive(data.BoxID != 0 && data.ReduceTime != 0);
        txtTips.gameObject.SetActive(data.BoxID != 0);
        txtEmpty.gameObject.SetActive(data.BoxID == 0);
        btnTitle.gameObject.SetActive(data.BoxID != 0);
    }
}
示例组件截图

itemTemplate需要指定一个有UIBoxDetailItem脚本的显示对象,如下图所示。

最后

其中还有更多的细节,就未能一一提及。

当然还有更多有待优化的逻辑,需要大家来指出。

相关推荐
与火星的孩子对话1 小时前
Unity3D开发AI桌面精灵/宠物系列 【三】 语音识别 ASR 技术、语音转文本多平台 - 支持科大讯飞、百度等 C# 开发
人工智能·unity·c#·游戏引擎·语音识别·宠物
向宇it2 小时前
【零基础入门unity游戏开发——2D篇】2D 游戏场景地形编辑器——TileMap的使用介绍
开发语言·游戏·unity·c#·编辑器·游戏引擎
牙膏上的小苏打233318 小时前
Unity Surround开关后导致获取主显示器分辨率错误
unity·主屏幕
Unity大海20 小时前
诠视科技Unity SDK开发环境配置、项目设置、apk打包。
科技·unity·游戏引擎
浅陌sss1 天前
Unity中 粒子系统使用整理(一)
unity·游戏引擎
维度攻城狮1 天前
实现在Unity3D中仿真汽车,而且还能使用ros2控制
python·unity·docker·汽车·ros2·rviz2
为你写首诗ge1 天前
【Unity网络编程知识】FTP学习
网络·unity
神码编程1 天前
【Unity】 HTFramework框架(六十四)SaveDataRuntime运行时保存组件参数、预制体
unity·编辑器·游戏引擎
菲fay2 天前
Unity 单例模式写法
unity·单例模式
火一线2 天前
【Framework-Client系列】UIGenerate介绍
游戏·unity