UGUI 自定义滚动选择列表 ListView

Wesley13
• 阅读 930

列表在游戏的UI中是非常常见的,例如选服页面,商城页面,奖励页面等等都会有列表的存在。文中我们将这些列表称为ListView(类似于fgui的GList),而列表中的每项称作Item。

首先我们来分析下,我们的ListView需要实现哪些功能,以及如何实现

功能

解决思路

可以通过滑动来显示ListView中的Item

可以使用UGUI的ScrollView(ScrollRect)来实现,Item放在Content当中

ListView中的Item可以是单选或者多选

UGUI的Toggle可以为我们实现Item的选中和未选中状态,但是由于当ListView是单选的时候,我们希望同一个Item被多次点击的时候都保持选中状态,而不是选中、未选中来回切。因此我们通过拓展Button来实现。

Item的增加删除

使用对象池来管理Item,防止频繁的增删导致不断的添加或销毁GameObject造成的内存碎片化

ListView中的Item的排列,以及整体大小

UGUI的GridLayoutGroup可以为我们实现排列,ContentSizeFitter可以为我们实现大小的适配。不过由于一些局限性(不适应于虚拟列表),以及本身实现起来并不是很难,因此这两个我们都不使用,通过自己计算来实现。

ListView的数据切换,例如显示服装变为显示宠物

刷新所有的Item,重置选中的Item

虚拟列表

通常情况Item可能只有十几二十项,甚至更少,我们可以每个Item都对应一个GameObject。但是对于商城或者背包等,我们的Item可能是上百项的,如果按照传统的做法,就会有上百个GameObject,造成性能的消耗。

因此我们引用虚拟列表的概念,简单来说就是只渲染玩家看的见的Item,然后在滑动过程中通过刷新Item的内容,来使用户看起来是在浏览上百个Item(后续会详细介绍)。

当然除了虚拟列表的做法外,我们也可以使用分页的做法,每次滑动触发“翻页”的时候,刷新所有Item数据,达到跳转到下一页的效果(可后续拓展)。

Demo:https://github.com/luckyWjr/Demo (ListView目录)。

当然了,具体的功能还得看具体的需求,大家可以自行进行相应的变更。

简单的看下实现后的效果:

单选模式:                                                            多选模式:

UGUI 自定义滚动选择列表 ListView                    UGUI 自定义滚动选择列表 ListView

虚拟列表:(可以看见一共有40个Item,但是并没有生成40个GameObject)

UGUI 自定义滚动选择列表 ListView


前面思路大致理清后,接着就是上手来具体实现了。(代码量比较多,因此下面的介绍不会涉及到全部的代码,基本就是一个思路的介绍。想看完整代码的可以自行去看Demo)

首先我们先新建一个ScrollView,来替我们实现滚动的功能。ScrollView中的Content即是我们的Item容器,也就是ListView。

我们新建一个脚本组件,ListView.cs用于实现列表相关功能,将其挂载在Content上。然后为其添加一些需要设置的属性,例如:

UGUI 自定义滚动选择列表 ListView

Is Virtual

是否是虚拟列表

Select Type

单选or多选

Flow Type

垂直滚动or水平滚动

Constraint Count

行数或列数的限制,若小于等于0则不限制。(例如,在垂直滚动下,假设我们的容器每行可以容下4个Item,若设置为0,即按照每行4个排列,若设置为大于0的其他值,则按照每行设置的值排列)

Item Space

每个Item的上下间隔

大多数情况下,无论垂直滚动还是水平滚动,都是从左上角开始的,因此案例中我们将ListView的pivotanchorMaxanchorMin都设置为**(0,1)**。(要是有其他角做起点的奇葩设计,可以自行修改下代码)


接着,我们来创建一个简单的Item模板,当做该ListView要显示的Item,例如:

UGUI 自定义滚动选择列表 ListView

我们再创建一个脚本组件,ListViewItem.cs用来处理我们的Item,具体代码如下:

[RequireComponent(typeof(Button))]
public abstract class ListViewItem : MonoBehaviour
{
    [SerializeField] GameObject m_selectedGameObject;
    
    public ListView.ESelectType selectType { get; private set; }
    Action<ListViewItem> m_onValueChanged;
    Action<ListViewItem> m_onClicked;//适用于只在Item被单击时做操作的情况
    RectTransform m_rectTransform;
    Button m_button;
    bool m_isSelected;

    public bool isSelected
    {
        get => m_isSelected;
        set
        {
            if (m_isSelected != value)
            {
                m_isSelected = value;
                UpdateSelectedUI();
            }
        }
    }
    void Awake()
    {
        m_button = GetComponent<Button>();
        isSelected = false;
        m_button.onClick.AddListener(OnClicked);

        m_rectTransform = GetComponent<RectTransform>();
        m_rectTransform.anchorMin = Vector2.up;
        m_rectTransform.anchorMax = Vector2.up;
        m_rectTransform.pivot = new Vector2(0.5f, 0.5f);
    }
    public void Init(ListView.ESelectType type, Action<ListViewItem> onValueChanged, Action<ListViewItem> onClicked)
    {
        selectType = type;
        m_onValueChanged = onValueChanged;
        m_onClicked = onClicked;
    }
    void OnClicked()
    {
        bool isValueChange = false;
        if (selectType == ListView.ESelectType.Single)
        {
            if (!isSelected)
                isValueChange = true;
            isSelected = true;
        }
        else
        {
            isValueChange = true;
            isSelected = !isSelected;
        }
        if(isValueChange)
            m_onValueChanged?.Invoke(this);
        m_onClicked?.Invoke(this);
    }
    protected virtual void UpdateSelectedUI()
    {
        if (m_selectedGameObject != null)
            m_selectedGameObject.SetActive(isSelected);
    }
}

首先这是一个抽象类,因为不同功能下的Item的内容都是不一样的,例如服务器列表的Item可能只显示一个服务器名称,而商城列表的Item需要显示Icon,名称,价格等等。因此针对具体的Item需要具体的继承实现,例如例子中的GoodsItem,同时挂载在模板上的组件也是继承后的Item类:

public class GoodsItem : ListViewItem
{
    [SerializeField] Text m_nameText;
    [SerializeField] Text m_priceText;

    public GoodsData goodsData { get; private set; }

    public void Init(GoodsData data)
    {
        goodsData = data;
        m_nameText.text = data.name;
        m_priceText.text = data.price.ToString();
    }
}

接着回到ListViewItem中,在里面我们添加一个bool值isSelected用来管理Item是否选中的状态,同时为了方便计算pivot设置为**(0.5,0.5)anchorMaxanchorMin设置为(0,1)。在按钮被点击时,处理点击事件以及是否选中的状态改变事件**。


然后我们需要根据我们的Item模板以及数据,生成一个个我们需要的Item,放在ListView中,同时Item也要显示相对应的数据。

通常情况,在显示显示Item之前,我们都生成好了相应的数据。而ListView只需要关心我有几个Item,而不需要关心具体的数据,因为有关Item显示数据的逻辑会在ListView外(也就是设置ListView的类中)去实现。因此我们只需要通过设置ListView中Item的数量即可,每次设置即会显示对应数量的Item,以及刷新所有Item,重置选中的Item(具体要求可以视需求做修改)

public int itemCount
{
    get => m_itemList.Count;
    set
    {
        ResetPosition();
        ClearAllSelectedItem();
        int oldCount = m_itemList.Count;
        if (value > oldCount)
        {
            for (int i = oldCount; i < value; i++)
                AddItem();
        }
        else
            RemoveItem(value, oldCount - 1);
        Refresh();
    }
}

public void Refresh()
{
    for (int i = 0, count = m_itemList.Count; i < count; i++)
    {
        ListViewItem item = m_itemList[i];
        m_onItemRefresh?.Invoke(i, item);
    }
}

我们使用m_itemList(List)来存放我们的Item,每次数量更新时,多的丢到对象池中,少的从对象池中取出来。然后调用m_onItemRefresh的委托,该委托的作用就是外部根据对应数据刷新Item。

void OnItemRefresh(int index, ListViewItem item)
{
    GoodsItem goodsItem = item as GoodsItem;
    goodsItem.Init(currentList[index]);
}

需要的Item都生成好了后,我们还需要将他们进行位置的摆放,否则都挤在一起了。

首先,我们需要知道显示的窗口大小,以及每个Item的大小。同时如果是垂直滚动,我们需要知道每行显示多少个Item,如果是水平滚动,那就需要知道每列显示多少个Item:

void GetLayoutAttribute()
{
    m_itemSize = itemPrefab.GetComponent<RectTransform>().rect.size;
    m_initialSize = m_rectTransform.parent.GetComponent<RectTransform>().rect.size;//Viewport Size
    //计算行或列
    if (m_flowType == EFlowType.Horizontal)
    {
        m_rowCount = m_constraintCount;
        if (m_rowCount <= 0)
            m_rowCount = Mathf.FloorToInt((m_initialSize.y + m_itemSpace.y) / (m_itemSize.y + m_itemSpace.y));
        if (m_rowCount == 0)
            m_rowCount = 1;
    }
    else
    {
        m_columnCount = m_constraintCount;
        if (m_columnCount <= 0)
            m_columnCount = Mathf.FloorToInt((m_initialSize.x + m_itemSpace.x) / (m_itemSize.x + m_itemSpace.x));
        if (m_columnCount == 0)
            m_columnCount = 1;
    }
}

知道这些后,我们就可以根据Item的数量,计算出整个ListView的大小,根据下标知道每个Item在第几行第几列,设置其位置。我们新增一个bool值 m_isBoundDirty ,当item的数量发生变化时,将该值设为true,然后在Update中,检测到其值为true时,刷新ListView的大小以及每个Item的位置(此时左上角对齐的好处,简化了我们的运算)。

void UpdateBounds()
{
    SetSize();
    for (int i = 0, count = itemCount; i < count; i++)
        m_itemList[i].transform.localPosition = CalculatePosition(i);
    
    m_isBoundsDirty = false;
}

Vector2 CalculatePosition(int index)
{
    int row, column;
    if (m_flowType == EFlowType.Horizontal)
    {
        row = index % m_rowCount;
        column = index / m_rowCount;
    }
    else
    {
        row = index / m_columnCount;
        column = index % m_columnCount;
    }
    
    float x = column * (m_itemSize.x + m_itemSpace.x) + m_itemSize.x / 2;
    float y = row * (m_itemSize.y + m_itemSpace.y) + m_itemSize.y / 2;
    
    return new Vector2(x, -y);
}

现在只剩下Item选中状态的处理了,单个Item的选中状态我们在ListViewItem中按钮的点击事件进行了处理,但是多个Item(例如选中另一个时,要取消之前选中的Item)以及状态改变时的委托,我们在ListView中进行处理(m_selectedItemList即存放选中的Item):

void OnValueChanged(ListViewItem item)
{
    if (item.isSelected)
    {
        if (m_selectType == ESelectType.Single)
        {
            if (m_selectedItemList.Count > 0)
            {
                //取消之前的选中状态
                m_selectedItemList[0].isSelected = false;
                int lastSelectedIndex = m_itemList.IndexOf(m_selectedItemList[0]);
                m_onItemValueChanged?.Invoke(lastSelectedIndex, false);
                m_selectedItemList.Clear();
            }
            m_selectedItemList.Add(item);
        }
        else
            m_selectedItemList.Add(item);
    }
    else
    {
        m_selectedItemList.Remove(item);
    }
    int index = m_itemList.IndexOf(item);
    m_onItemValueChanged?.Invoke(index, item.isSelected);
}

m_onItemValueChanged即是选中状态修改的委托,在外部可以用其做一些选中时的逻辑处理,例如:

void OnItemValueChange(int index, bool isSelected)
{
    GoodsData data = currentList[index];
    m_amount = m_amount + (isSelected ? 1 : -1) * data.price;
    m_amountText.text = $"总额:{m_amount}";
}

这样一个简单的ListView基本就实现了,我们可以通过初始化方法,来进行一些参数的设置,类似Item模板,委托等:

public void Init(GameObject prefab, Action<int, ListViewItem> refresh, Action<int, bool> valueChanged, Action<ListViewItem> clicked)
{
    if (prefab.GetComponent<ListViewItem>() == null)
    {
        Debug.LogError("ListView prefab dont have ListViewItem Component");
        return;
    }
    itemPrefab = prefab;
    m_pool = new GameObjectPool(m_rectTransform, itemPrefab);
    m_onItemRefresh = refresh;
    m_onItemValueChanged = valueChanged;
    m_onItemClicked = clicked;
    
    GetLayoutAttribute();
}

在外部我们只需要调用Init方法,然后设置itemCount即可显示我们的ListView

m_listView.Init(m_goodsItemPrefab, OnItemRefresh, OnItemValueChange, OnItemClick);
m_listView.onSelectedItemCleared = OnListViewSelectedItemCleared;
m_listView.itemCount = m_shenbingDataList.Count;

接下来就是我们虚拟列表的处理了。按照上面的做法,假设我们有上百个Item,那么就会生成上百个GameObject,这显然是很不好的。一种处理方法就是分页(Demo中暂未添加该逻辑),例如我们一次可以看见12个Item,每次滑动的时候将这12个Item都进行刷新,类似翻页的效果,这样就可以保证几百个Item的时候只有12个GameObject,但是浏览起来就没有正常滚动时舒服。因此此时就可以使用我们虚拟列表的方法来完美的实现。

首先我们知道,即使有几百个Item,但是我们每次能看见的始终是窗口中显示的那几个,其余都是看不见的。而我们每次滚动的时候,无论如何都是会滚进来一行或一列新的Item,同时滚出去一行或一列旧的Item。那么我们如果将这些滚出去的旧的Item当做新的Item滚进来,像形成一个圈这样,然后刷新这部分Item所对应的数据,就可以实现以少量Item实现大量Item滚动的效果。(具体效果可以看文章最前面的GIF)


首先虽然我们的Item只有几个,但是仍然需要纪录真实每项的一些数据信息,我们称之为ItemInfo,ItemInfo的数量和我们数据的数量相等。

class ItemInfo
{
    public ListViewItem item;
    public bool isSelected;
}

例如假设我们下标2的Item被选中了(ListViewItem.isSelected = true),然后我们往后滚,当之前下标2对应的Item被复用时(此时的它可能对应着下标12),肯定不能还是之前的选中状态,而应该是未选中状态(ListViewItem.isSelected = false)。当我们再次显示下标2的Item的时候,那么怎么知道它之前是被选中的呢?那就是通过ItemInfo来处理,修改状态的时候修改对应下标的ItemInfo的isSelected的值,显示的时候读取对应下标ItemInfo的值即可。

又比如,在滚动刷新Item的时候,因为Item一直是被复用的,我们如果知道该下标下是否已经有对应的Item了,同样通过设置读取ItemInfo可以来解决。

和之前一样,我们同样通过设置ItemCount来刷新我们的ListView,我们在设置ItemCount时,对存储ItemInfo的List进行相应的处理:

public int itemCount
{
    get => m_isVirtual ? m_itemRealCount : m_itemList.Count;
    set
    {
        if (m_isVirtual)
        {
            m_itemRealCount = value;
            SetSize();
            int oldCount = m_itemInfoList.Count;
            if (m_itemRealCount > oldCount)
            {
                for (int i = oldCount; i < m_itemRealCount; i++)
                {
                    ItemInfo info = new ItemInfo();
                    m_itemInfoList.Add(info);
                }
            }
            else
            {
                for (int i = m_itemRealCount; i < oldCount; i++)
                {
                    if (m_itemInfoList[i].item != null)
                    {
                        RemoveItem(m_itemInfoList[i].item);
                        m_itemInfoList[i].item = null;
                    }
                }
            }
        }
    }
}

接着就是最重要的,如何实现Item的复用的问题了。我的思路是,我们的ListView大小依旧按照真实数量来设置,这样在滚动ListView的时候,我们就可以根据ListView的偏移来计算出当前窗口应该显示的第一个Item所对应的下标m_startIndex

int GetCurrentIndex(float position)
{
    if (m_flowType == EFlowType.Horizontal)
    {
        position = -position;
        if (position < m_itemSize.x) return 0;
        position -= m_itemSize.x;
        return (Mathf.FloorToInt(position / (m_itemSize.x + m_itemSpace.x)) + 1) * m_rowCount;
    }
    else
    {
        if (position < m_itemSize.y) return 0;
        position -= m_itemSize.y;
        return (Mathf.FloorToInt(position / (m_itemSize.y + m_itemSpace.y)) + 1) * m_columnCount;
    }
}

再根据窗口的大小,Item的大小以及Item的间距,计算出Item的结束下标m_endIndex,也就是从这个下标开始的Item是玩家看不到的。

int oldStartIndex = m_startIndex, oldEndIndex = m_endIndex;
if (m_flowType == EFlowType.Horizontal)
{
    float currentX = m_rectTransform.localPosition.x;// <0
    m_startIndex = GetCurrentIndex(currentX);
    float endX = currentX - m_initialSize.x - m_itemSize.x - m_itemSpace.x;
    m_endIndex = GetCurrentIndex(endX);
}
else
{
    float currentY = m_rectTransform.localPosition.y;// >0
    //上下滑动,根据listview的y值计算当前视图中第一个item的下标
    m_startIndex = GetCurrentIndex(currentY);
    //根据视图高度,item高度,间距的y,计算出结束行的下标
    float endY = currentY + m_initialSize.y + m_itemSize.y + m_itemSpace.y;
    m_endIndex = GetCurrentIndex(endY);
}
if(oldStartIndex == m_startIndex && oldEndIndex == m_endIndex)
    return;

如果两个下标没变,那就说明用户看见的还是这些Item,就不需要改变。但是当有新的Item移入,或者旧的Item移出时,m_startIndex或者m_endIndex就会发生改变。当新的Index大于旧的说明列表是在往右或者往下滚动,我们可以从前面寻找可复用的Item,反之则从后寻找。若找不到可复用的Item,则生成新的Item。具体逻辑如下:

//渲染当前视图内需要显示的item
for (int i = m_startIndex; i < itemCount && i < m_endIndex; i++)
{
    bool needRender = false;//是否需要刷新item ui
    ItemInfo info = m_itemInfoList[i];
    if (info.item == null)
    {
        int j, jEnd;
        if (oldStartIndex < m_startIndex || oldEndIndex < m_endIndex)
        {
            //说明是往下或者往右滚动,即要从前面找复用的Item
            j = 0;
            jEnd = m_startIndex;
        }
        else
        {
            j = m_endIndex;
            jEnd = itemCount;
        }
        for (;j < jEnd; j++)
        {
            if (m_itemInfoList[j].item != null)
            {
                info.item = m_itemInfoList[j].item;
                m_itemInfoList[j].item = null;
                needRender = true;
                break;
            }
        }
    }
    
    //前后找不到的话,添加新的item
    if (info.item == null)
    {
        info.item = AddItem();
        needRender = true;
    }
    //更新位置,是否选中状态,以及数据
    if (isForceRender || needRender)
    {
        info.item.transform.localPosition = CalculatePosition(i);
        info.item.isSelected = info.isSelected;
        m_onItemRefresh?.Invoke(i, info.item);
    }
}

这样就可以实现我们虚拟列表的功能了。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
2年前
java将前端的json数组字符串转换为列表
记录下在前端通过ajax提交了一个json数组的字符串,在后端如何转换为列表。前端数据转化与请求varcontracts{id:'1',name:'yanggb合同1'},{id:'2',name:'yanggb合同2'},{id:'3',name:'yang
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Stella981 Stella981
2年前
PhoneGap设置Icon
参考:http://cordova.apache.org/docs/en/latest/config\_ref/images.html通过config.xml中的<icon标签来设置Icon<iconsrc"res/ios/icon.png"platform"ios"width"57"height"57"densi
Stella981 Stella981
2年前
Android如何实现一个上拉刷新下拉加载的ListView
20191220关键字:自定义上下拉ListView在APK开发中,一个具备在列表顶部下拉刷新、在列表尾部上拉加载功能的ListView的需求还是比较多的。具备这种功能的优秀开源代码同样也有很多。但今天,笔者就非要自己实现一个这样的控件不可。以下是成品效果图:!(https://oscimg.oschin
Easter79 Easter79
2年前
SwipeRefreshLayout下拉刷新冲突解决
使用SwipeRefreshLayout,网上资料copy了一个OnScrollListener给ListView,结果当第一个item长度超过一屏,明明还没有到达列表顶部,Scroll事件就被拦截,列表无法滚动,同时启动了刷新。修正代码后,自定义的OnScrollListener如下:/ 由于Listview与下拉刷新的Scroll
Stella981 Stella981
2年前
ListView的属性详解和探究
在我们的日常开发中,ListView是一个最常用的组件,所以我们非常有必要对它的属性进行全面的了解。现在就以一个简单的实例,对ListView的属性做一个简单的讲解。          首先我们给出简单的布局文件,就一个简单的ListView列表    :         <LinearLayout x
Wesley13 Wesley13
2年前
Android开发之列表控件
一、基础知识:ListView是一个经常用到的控件,ListView里面的每个子项Item可以使一个字符串,也可以是一个组合控件。先说说ListView的实现:1.准备ListView要显示的数据;2.使用一维或多维动态数组保存数据;3.构建适配器,简单地来说,适配器就是Item数组,动态数组有多少元素就生成多少个Item;4.把适配器添
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable