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脚本的显示对象,如下图所示。

最后

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

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

相关推荐
异次元的归来2 小时前
Unity DOTS中的share component
unity·游戏引擎
向宇it5 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎
_oP_i6 小时前
unity webgl部署到iis报错
unity
Go_Accepted7 小时前
Unity全局雾效
unity
向宇it7 小时前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
每日出拳老爷子10 小时前
【图形渲染】【Unity Shader】【Nvidia CG】有用的参考资料链接
unity·游戏引擎·图形渲染
北海651611 小时前
Dots 常用操作
unity
YY-nb18 小时前
Unity Apple Vision Pro 开发教程:物体识别跟踪
unity·游戏引擎·apple vision pro
Cool-浩18 小时前
Unity 开发Apple Vision Pro物体识别追踪ObjectTracking
unity·ar·apple vision pro·mr·物体识别·vision pro教程·objecttracking
向宇it1 天前
【从零开始入门unity游戏开发之——C#篇23】C#面向对象继承——`as`类型转化和`is`类型检查、向上转型和向下转型、里氏替换原则(LSP)
java·开发语言·unity·c#·游戏引擎·里氏替换原则