当前位置: 首页 > article >正文

Unity引擎UI滚动列表——滚动复用基础介绍

  大家好,我是阿赵。

一、滚动复用的介绍

  在制作游戏的过程中,经常会遇到一些需要显示数量比较大的数据的情况。比如说,一个排行榜,需要展示当前服务器前一千个玩家的排名。或者游戏的背包容量特别大,可以有几千个格子。
  在早期的游戏里面,这种情况一般会采取分页的做法,比如一页只显示几十条信息,通过翻页和跳转页,逐页浏览信息。但现在的游戏很少采取分页的做法,而是采用滚动列表的方式,玩家可以上下滑动在同一页里面看完所有的信息。
  以Unity引擎为例,通过Scroll View组件,就能轻松的实现上下滑动的效果。
在这里插入图片描述

  ScrollView的基本用法很简单,把需要展示的Item,都放在Content节点下,然后在Content上挂自动布局的组件和自动适配大小的组件,列表就可以滚动起来了。
在这里插入图片描述

  回到最开始的例子,假如需要展示的内容非常多,比如有1万个,那我们是不是就要生成1万个item放在Content下面呢?这样做显然是不行的。如果我们同时生成1万个item,而且item很复杂,那么生成的时候会很卡,也会很占内存。虽然实际显示的数量可能只有几个,但剩下的九千多个item还是需要通过Mask计算裁剪范围。
  实际的情况是,我们需要同时看到的item,可能只有几个,所以我们没有必要生成这么大数量的item,而只是生成观察范围内的有限几个item就可以了,通过上下滑动,把移除出范围的item移动到新进入范围的位置,并且把数据刷新成刚进入范围的数据显示就可以了。我习惯上把这种做法叫做滚动复用。

Unity引擎UI滚动复用1

  从上面的视频可以看到,我这里真的有1万个数据,而且可以任意的滑动,甚至任意的跳动到某个为止,从视觉上是完全看不出来有什么破绽。然后从Unity的Hierarchy窗口可以看出,实际上在Content节点下面的item数量就只有11个而已。
在这里插入图片描述

  为了方便观察,我把同时显示的数量减少一些,并且把Mask去掉

Unity引擎UI滚动复用6

  从视频的Scene视图可以看到,消失的Item会立刻移动到进入的位置,并且刷新显示。

二、 滚动复用的实现分析

1、计算可以滚动的范围

  滚动复用还是可以使用Unity自带的Scroll View组件。只是在Content节点上面,不需要再挂自动布局和自动大小的组件了。
  首先需要明白的一点是,Scroll View组件可以滚动的范围,是通过Content节点的大小来决定的
在这里插入图片描述

  在不使用滚动复用技术的时候,我们需要加自动布局和自动大小的组件,就是为了自动的计算出这个Content里面的内容总共需要多大的范围来显示,从而决定滚动的实际范围。
既然我们现在不需要自动大小了,所以我们可以通过计算,得出Content的大小。举个例子:
  假如我现在是一个竖向滚动的列表,每个item的宽度是200,高度是60,需要同时显示1000个item,item中间没有空隙。那么要刚好放下这1000个item,Content需要的大小就是宽度200,高度是60x1000=60000。
  假如在item中间需要有5个像素的间隙,那么Content的大小,宽度还是200,高度会变成了60x1000+5x(1000-1) = 64995。
  是不是很简单?只要这样算一下,把Content的大小算出来,并设置。这时候列表已经可以滚动起来了。虽然上面一个item也没有,但实际上它滚动的范围就是1000个item的高度。

2、 规定数据来源

  对于一个滚动显示的列表来说,它必须是有一个输入的数据源,并且是以一维数组的形式表现的。
  我这个例子里面,只是简单的输入了一个字符串的数组,而数组的内容就是“内容{序号}”。在实际的使用中,数据的来源会更复杂,比如是某个数据结构体的数组,结构体里面可以包含很多数据,需要在item上面显示很多内容。
  不过数据是否复杂其实并不影响滚动复用的实现,我这里只是为了说明原理。所以我准备了一个字符串数组,每一个数据对应数组里面的一个Index。在接下来的实现里面,每一个数据其实就是对应一个显示的item了。item的UI预设是预先做好的,对应需要显示的数据内容。

3、 计算每个item的范围

  在计算item范围之前,先要定一个对齐方式。比如我们都以左上角作为对齐
在这里插入图片描述

  然后item也是左上角对齐,并且Pivot的x是0,y是1。
在这里插入图片描述

  这样设置的好处是,当posX和posY都是0的时候,item刚好对齐了Content的左上角。然后随着posX增大item会一直往右移动,随着posY的一直减少(因为画布的Y轴是向上的,所以负数才是向下),item会一直往下移动。
  假如现在所有item已经按照正确的位置排列好了,那么每个item应该有一个相对于Content的坐标。这里需要记录item的左上角和右下角的坐标:
在这里插入图片描述

  如果用Vector4来表示这个item的最大最小值坐标,x和y就是左上角开始点的xy坐标。而z和w,就是右下角结束点的xy坐标。这种记录方法还是看个人习惯的,有些人喜欢记录开始点的xy坐标,还有记录item的宽高,也都是可以的,毕竟结束点的坐标其实就是开始点坐标加上宽度和高度而已。
  不过我为了下面的步骤能快速的得到起点和终点的实际坐标来计算item是否在范围内,所以记录了结束点坐标而没有记录宽高。
  这里需要注意一点,虽然Unity的Y轴是朝向上的,也就是说数据越往下就越小。但我们计算的时候,其实不需要硬要这样算的,我们就正常的算越往后的item坐标越大就行了,包括下面的计算范围也是,把计算的Y轴朝下,计算起来的思维就方便很多。只要在最后给item的坐标赋值的时候,把Y坐标取个负数就行。

4、 计算每个item是否在可以显示的范围内

  假设下图的黑框就是现在Content的实际范围,然后红框就是现在ScrollView的遮罩显示范围:
在这里插入图片描述

  由于Content的y坐标为0的时候,是刚好和遮罩的左上角重合的,所以可以认为,现在黑色的框往上移动的距离,也就是红框的顶部坐标,其实就是Content的posY坐标。然后红框的底部坐标,其实就是顶部坐标加上遮罩的高度。
  得到了当前需要显示范围(也就是红框)的顶部和底部坐标之后,通过之前初始化的时候已经计算好的每个item的开始坐标和结束坐标,就能很简单的对比出item是否在红框的显示范围呢了。

5、 刷新显示

  当知道了哪些index对应的item是在显示范围内的,接下来就很好办了,我们需要记录一个当前正在显示的index列表,然后和新计算出来的正要显示的index列表做对比,就可以知道,有哪些index对应的item是需要隐藏,哪些index对应的item是新显示出来的。对于没有变化的index,我们不需要处理,只需要先把需要隐藏的item隐藏掉,再把新增的item显示出来,然后通过index,得到他们的坐标和数据,把item摆在正确的位置,并且根据数据显示item的内容就行了。
  这里我的做法是维护一个对象池。当item不需要显示的时候,把它们存放到对象池里面,并且隐藏。当item需要新增的时候,从对象池里面取出来,并且显示。

6、 根据滚动事件触发刷新显示

  知道了怎样刷新显示,但在什么时候需要刷新呢?在拖动列表的过程中,按道理我们就需要不停的去检查item是否在显示范围内。
  为了达到拖动的过程中触发刷新,所以需要在Scroll View的OnValueChange回调里面注册一个方法,当值变化的时候,我们就重新计算并刷新显示。不过由于OnValueChange触发得很频繁,所以我们需要降低一下调用的频率:

  1. OnValueChange回调会传入一个Vector2参数,代表当前滑动的方向。因为我们做的例子是上下滑动,所以参数的y坐标为0时,就说明没有滑动,所以不需要刷新。
  2. 给一个调用的频率间隔,当回调的频率过快时也不需要立刻刷新。

7、 跳转到某个item

  在使用滚动列表的需求里面,很多时候会有需要定位的情况。比如在显示1000个玩家信息的排行榜里面,需要定位到自己所在的排名。
  所以在做这个滚动复用的时候,也需要加上一个定位的功能。

Unity引擎UI滚动复用2

  这个功能实现的思路很简单,因为每个item的坐标之前都已经记录了,所以需要跳转到某个item,其实直接去它的坐标,然后加上item的一半高度就可以了。
  不过这里有一种情况,假如index对应的item在最上面或者最下面的一段,而列表是会自动回弹的(比如第一个item不能低于显示范围左上角,如果把第一个item居中,列表就会回弹到左上角),那么就要计算一下,当出现会回弹的情况,直接把y坐标变成0,或者在最下面的就要用Content高度减去mask的高度。

三、 源码

  根据上面的思路,简单写了一个例子,是对应竖向滚动的。各位有兴趣可以自己思考一下,怎样改为横向滚动,或者可以用参数切换横竖向滚动。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class ScrollViewLoopCtrlBase : MonoBehaviour
{
    public ScrollRect scrollRect;
    public RectTransform content;
    protected float viewWidth;
    protected float viewHeight;
    private float itemWidth = 200;
    private float itemHeight = 60;
    protected float spaceTime = 0.02f;
    protected float lastTime = 0;
    protected List<string> dataList;
    protected float spacing = 0;
    protected List<int> currentShowIndexList;
    protected float contentHeight = 0;
    protected Dictionary<int, TestItem> showList;
    protected List<Vector4> itemPosList;
    protected Dictionary<int, List<TestItem>> poolDict;
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    #region 公共方法
    /// <summary>
    /// 设置滚动复用的数据
    /// </summary>
    /// <param name="dataList">数据源</param>
    /// <param name="spacing">item之间的间隔</param>
    public void SetData(List<string> dataList, float spacing = 0)
    {
        this.dataList = dataList;
        this.spacing = spacing;
        currentShowIndexList = new List<int>();
        RectTransform selfRect = GetComponent<RectTransform>();
        viewWidth = selfRect.rect.width;
        viewHeight = selfRect.rect.height;
        itemPosList = new List<Vector4>();
        InitData();
        UpdateView();
    }

    public void OnPosChange(Vector2 vec)
    {
        if(vec.y == 0)
        {
            return;
        }
        if (Time.time - lastTime < spaceTime)
        {
            return;
        }
        lastTime = Time.time;
        UpdateView();
    }
    /// <summary>
    /// 停止滚动
    /// </summary>
    public virtual void StopMove()
    {
        if (scrollRect)
        {
            scrollRect.StopMovement();
        }
    }
    /// <summary>
    /// 设置列表停留在某个index的item居中
    /// </summary>
    /// <param name="ind"></param>
    public void SetIndexMiddle(int ind)
    {
        StopMove();
        SetMiddleFun(ind);
        UpdateView();
    }

    #endregion

    /// <summary>
    /// 根据传入的数据初始化滚动列表
    /// </summary>
    protected virtual void InitData()
    {
        int count = dataList.Count;


        for (int i = 0; i < count; i++)
        {
            float startPos = i * itemHeight;
            if (i > 0)
            {
                startPos += spacing * i;
            }

            itemPosList.Add(new Vector4(0, startPos, 0, startPos + itemHeight));
        }

        contentHeight = itemHeight * count;
        if (count > 1)
        {
            contentHeight += spacing * (count - 1);
        }
        content.sizeDelta = new Vector2(viewWidth, contentHeight);
    }


    /// <summary>
    /// 设置index居中的具体实现
    /// </summary>
    /// <param name="ind"></param>
    protected virtual void SetMiddleFun(int ind)
    {
        if (dataList == null || dataList.Count == 0)
        {
            return;
        }
        if (ind < 0)
        {
            ind = 0;
        }
        else if (ind >= dataList.Count)
        {
            ind = dataList.Count - 1;
        }
        float halfScreenHeight = viewHeight / 2;
        float posY = itemPosList[ind].y - halfScreenHeight + (itemPosList[ind].w - itemPosList[ind].y) / 2;

        if (posY < 0)
        {
            posY = 0;
        }
        else if (contentHeight - posY < viewHeight)
        {
            posY = contentHeight - viewHeight;
        }
        content.anchoredPosition = new Vector2(0, posY);
    }

    /// <summary>
    /// 刷新列表显示
    /// </summary>
    protected virtual void UpdateView()
    {
        List<int> newShowList = new List<int>();
        float contentStartY = content.anchoredPosition.y;
        float contentEndY = contentStartY + viewHeight;
        for (int i = 0; i < dataList.Count; i++)
        {
            if (CheckItemIsInArea(contentStartY, contentEndY,i))
            {
                newShowList.Add(i);
            }
        }
        if (currentShowIndexList == null)
        {
            AddItemsToShow(newShowList);
            currentShowIndexList = newShowList;
            return;
        }
        List<int> removeList = new List<int>();
        for (int i = 0; i < currentShowIndexList.Count; i++)
        {
            if (newShowList.IndexOf(currentShowIndexList[i]) < 0)
            {
                removeList.Add(currentShowIndexList[i]);
            }
        }
        if (removeList.Count > 0)
        {
            RemoveItemsFromShowList(removeList);
        }
        List<int> newList = new List<int>();
        for (int i = 0; i < newShowList.Count; i++)
        {
            if (currentShowIndexList.IndexOf(newShowList[i]) < 0)
            {
                newList.Add(newShowList[i]);
            }
        }
        if (newList.Count > 0)
        {
            AddItemsToShow(newList);
        }
        currentShowIndexList = newShowList;
    }
    /// <summary>
    /// 把对应index的item移除显示
    /// </summary>
    /// <param name="indList"></param>
    protected void RemoveItemsFromShowList(List<int> indList)
    {
        if (showList == null || showList.Count == 0)
        {
            return;
        }
        for (int i = 0; i < indList.Count; i++)
        {
            if (showList.ContainsKey(indList[i]))
            {
                TestItem item = showList[indList[i]];
                showList.Remove(indList[i]);
                ReturnToPool(item);
            }
        }
    }
    /// <summary>
    /// 把对应index的item添加到显示
    /// </summary>
    /// <param name="indList"></param>
    protected void AddItemsToShow(List<int> indList)
    {
        if (showList == null)
        {
            showList = new Dictionary<int, TestItem>();
        }
        for (int i = 0; i < indList.Count; i++)
        {
            int id = indList[i];
            if (showList.ContainsKey(id) == false)
            {
                TestItem item = GetFromPoolById(id);
                showList.Add(id, item);
                item.SetData(id, dataList[id]);
                item.rect.anchoredPosition = new Vector2(itemPosList[id].x, -itemPosList[id].y);
            }
        }
    }

    /// <summary>
    /// 检查某个序号的item是否在显示范围内
    /// </summary>
    /// <param name="contentStartY"></param>
    /// <param name="contentEndY"></param>
    /// <param name="itemIndex"></param>
    /// <returns></returns>
    protected bool CheckItemIsInArea(float contentStartY, float contentEndY, int itemIndex)
    {
        Vector4 itemPos = itemPosList[itemIndex];
        if (itemPos.y <= contentStartY && itemPos.w >= contentStartY)
        {
            return true;
        }
        if (itemPos.y >= contentStartY && itemPos.w <= contentEndY)
        {
            return true;
        }
        if (itemPos.y <= contentEndY && itemPos.w >= contentEndY)
        {
            return true;
        }
        return false;
    }


    #region 对象池
    /// <summary>
    /// 通过item类型从对象池获取对象
    /// </summary>
    /// <param name="itemType"></param>
    /// <returns></returns>
    protected TestItem GetFromPool(int itemType)
    {
        if (poolDict == null || poolDict.ContainsKey(itemType) == false || poolDict[itemType].Count == 0)
        {
            string itemName = GetItemNameByItemType(itemType);
            Object obj = Resources.Load(itemName);
            GameObject go = (GameObject)GameObject.Instantiate(obj, content.transform);
            TestItem item = go.GetComponent<TestItem>();
            item.type = itemType;
            return item;
        }
        else
        {
            List<TestItem> poolList = poolDict[itemType];
            TestItem item = poolList[0];
            poolList.RemoveAt(0);
            item.gameObject.SetActive(true);
            return item;
        }

    }
    /// <summary>
    /// 这里是临时测试资源,写死了几个item类型对应的item名字,用于加载
    /// </summary>
    /// <param name="itemType"></param>
    /// <returns></returns>
    private string GetItemNameByItemType(int itemType)
    {
        string itemName = "";
        switch (itemType)
        {
            case 1:
                itemName = "testItem";
                break;
            case 2:
                itemName = "testItem2";
                break;
            case 3:
                itemName = "testItem3";
                break;
        }
        return itemName;
    }
    /// <summary>
    /// 把Item回收到对象池
    /// </summary>
    /// <param name="item"></param>
    protected void ReturnToPool(TestItem item)
    {
        item.gameObject.SetActive(false);
        if (poolDict == null)
        {
            poolDict = new Dictionary<int, List<TestItem>>();
        }
        if (poolDict.ContainsKey(item.type) == false)
        {
            poolDict.Add(item.type, new List<TestItem>());
        }
        List<TestItem> poolList = poolDict[item.type];
        if (poolList.IndexOf(item) < 0)
        {
            poolList.Add(item);
        }
    }
    /// <summary>
    /// 正常的滚动复用可能会使用到不同的Item,这里定义一个通过id获取item的方法,用于在不同需求下重写
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    protected virtual TestItem GetFromPoolById(int id)
    {
        return GetFromPool(1);
    }
    #endregion



}

http://www.kler.cn/a/428914.html

相关文章:

  • Javascript Clipper library, v6(介绍目录)
  • 六、隐语PIR功能及使用介绍
  • 漫画之家:基于Spring Boot的漫画社交网络平台
  • C# WPF抽奖程序
  • 如何在UI自动化测试中创建稳定的定位器?
  • 笔记:在WPF中BitmapSource都有哪些派生类,他们主要功能,使用方法,使用场景
  • SpringBoot实现前后端传输加密设计
  • php项目的sdk封装成composer包的创建与发版
  • 【光电融合集成电路制造与封测】第四讲:扩散工艺,扩散的类型,恒定表面源扩散,限定表面源扩散,硼扩散
  • 分享一个您在汽车软件安全性测试中发现严重漏洞的案例,以及如何处理
  • 30天学会Go--第6天 GO语言 RESTful API 学习与实践
  • Tomcat(基础篇)
  • <router-view> 中key和name属性的用法详解以及案例
  • 试题转excel;pdf转excel;试卷转Excel,word试题转excel
  • 力扣 对称二叉树-101
  • 如何利用Python爬虫获得商品类目
  • ARM寄存器简介
  • 基于单片机的书写坐姿规范提醒器设计(论文+源码)
  • 粉丝生产力与开源 AI 智能名片 2+1 链动模式商城小程序的融合创新与价值拓展
  • PyTorch 本地安装指南:全面支持 macOS 、 Linux 和 Windows 系统