【Unity】使用UGUI實現ListView

使用UGUI實現ListView


List View功能列表:

  • 自動控制元素佈局(水平或豎直)
  • 設置外邊距、行間距
  • 在指定位置添加元素
  • 從指定位置移除元素
  • 查找元素
  • 元素排序
  • 將視圖定位到指定位置(索引或百分比)
  • 元素的添加和移除動畫
  • 自定義元素移除方法

元素佈局控制使用Unity內置的HorizontalOrVerticalLayoutGroupScroll View組件實現。其中,Scroll View實現了進度條區域遮罩功能,HorizontalOrVerticalLayoutGroup實現了對子元素的拉伸對齊內邊距行間距的控制。List View中的垂直列表選用了VerticalLayoutGroup,而水平列表選用了HorizontalLayoutGroup

ListView類對HorizontalOrVerticalLayoutGroup進行了一次封裝,直接操作ListView對象的Inspector面板即可設置HorizontalOrVerticalLayoutGroup對象的屬性。

ListView類內部使用List<GameObject>來記錄列表元素,因此對元素的增加刪除查找排序操作本質上是對List<GameObject>元素的增加、刪除、查找和排序操作,只需在操作完成後根據需要額外地處理一下GameObject或者Transform即可(例如銷燬或者取消激活Game Object,設置父物體等)。

定位元素是指讓ListView顯示到指定的元素的位置或者指定百分比的位置。根據目標元素的索引和元素總數計算出目標元素的位置百分比後,設置ScrollRect對象的verticalNormalizedPosition或者horizontalNormalizedPosition屬性即可是List View顯示到指定位置。


ListView類的源代碼有600多行,不過其中很多都是註釋。在這裏可以下載到原始工程導入即用的UnityPackage

  • CSDN下載(CSDN下載好像不允許設置免費資源了,統一5分)
  • Github

源代碼:

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

/// <summary>
/// List View組件,提供垂直UI列表和水平UI列表功能。
/// </summary>
[RequireComponent(typeof(ScrollRect))]
public class ListView : MonoBehaviour
{
    // Todo 已知問題:
    // 在沒有勾選FixedElementLength的情況下,
    // 添加元素後在添加動畫協程執行完之前將其移除,
    // 會導致Layout Group的Content長度計算錯誤,
    // 因爲當添加元素時按照元素的全尺寸增長Content長度,
    // 當移除元素時,按照元素的當前尺寸減小Content長度,
    // 而元素的當前尺寸是小於其全尺寸的(協程未完成)。

    #region 屬性

    /// <summary>
    /// 內邊距。
    /// </summary>
    public RectOffset Padding
    {
        get { return _padding; }
        set
        {
            _padding = value;
            _content.padding = _padding;
            CalcContentLength();
        }
    }

    /// <summary>
    /// 行間距。
    /// </summary>
    public float Spacing
    {
        get { return _spacing; }
        set
        {
            _spacing = value;
            _content.spacing = _spacing;
            CalcContentLength();
        }
    }

    /// <summary>
    /// ListView中的元素數量。
    /// </summary>
    public int ItemCount
    {
        get { return _items.Count; }
    }

    /// <summary>
    /// 移除元素的方法,默認銷燬元素。
    /// 爲此Action賦值來實現自定義的元素移除方法。
    /// </summary>
    public Action<GameObject> RemoveMethod = item => Destroy(item);

    #endregion


    #region Inspector屬性

    [Tooltip("內部組件,請勿修改。")]
    [SerializeField]
    private ScrollRect _scrollRect;
    [Tooltip("內部組件,請勿修改。")]
    [SerializeField]
    private HorizontalOrVerticalLayoutGroup _content;

    [Header("列表佈局")]
    [Tooltip("使用垂直List View還是水平List View?默認爲垂直List View。")]
    [SerializeField]
    private Layout _layout = Layout.Vertical;
    [Tooltip("List View的內邊距。")]
    [SerializeField]
    private RectOffset _padding;
    [Tooltip("List View的行間距。")]
    [Range(0, 5000)]
    [SerializeField]
    private float _spacing = 0.0f;

    [Header("元素屬性")]
    [Tooltip("新增的元素是否添加到List View的頭部?默認將新增元素添加到List View的尾部。")]
    public bool NewElementOnTop = false;
    [Tooltip("List View中所有元素的尺寸是否相同?如果不是,則每次添加和移除元素時計算元素尺寸。")]
    public bool FixedElementLength = true;
    [Tooltip("添加和移除List View元素時的動畫時長,小於等於0時不播放動畫。")]
    [Range(0, 1)]
    public float AnimationTime = 0.2f;

    #endregion


    #region 私有字段

    // 每個元素的尺寸(高度或寬度)
    private float _itemLenght = -1;
    // ListView中已有的元素
    private List<GameObject> _items = new List<GameObject>();

    #endregion


    #region 初始化和校驗

    private void Awake()
    {
        InitLayout();

        _content.padding = _padding;
        _content.spacing = _spacing;
    }

    private void Start()
    {
        CalcContentLength();
    }

    private void OnValidate()
    {
        InitLayout();

        _content.padding = _padding;
        _content.spacing = _spacing;

        CalcContentLength();
    }

    #endregion


    #region 元素操作

    // 添加

    /// <summary>
    /// 添加ListView元素。若不指定元素位置,則根據 NewElementOnTop 屬性自動選擇新元素的位置:
    /// 如果 NewElementOnTop 屬性爲 false ,則將新元素添加到底部;否則將新元素添加到頂部。
    /// </summary>
    /// <param name="item"></param>
    /// <param name="index"></param>
    public void AddItem(GameObject item, int index = -1)
    {
        item.transform.SetParent(_content.transform);

        if (index < 0)
        {
            // 添加到默認位置
            if (NewElementOnTop)
            {
                _items.Insert(0, item);
                item.transform.SetAsFirstSibling();
            }
            else
            {
                _items.Add(item);
                item.transform.SetAsLastSibling();
            }
        }
        else
        {
            if (index > ItemCount)
            {
                index = ItemCount;
                UnityEngine.Debug.LogWarningFormat("ListView.AddItem():給定索引超出ListView元素數量,被自動裁剪爲【{0}】。", index);
            }

            // 需要改變列表元素順序
            _items.Insert(index, item);
            item.transform.SetSiblingIndex(index);
        }

        AdjustContentLength(item.transform, 1);

        if (AnimationTime > 0)
        {
            StartCoroutine(IEAddItemAnim(item));
        }
    }

    /// <summary>
    /// 將元素添加到ListView頂部。
    /// </summary>
    /// <param name="item"></param>
    public void AddItemToTop(GameObject item)
    {
        AddItem(item, 0);
    }

    /// <summary>
    /// 將元素添加到ListView底部。
    /// </summary>
    /// <param name="item"></param>
    public void AddItemToBottom(GameObject item)
    {
        AddItem(item, ItemCount);
    }

    // 移除

    /// <summary>
    /// 移除ListView元素。
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    public bool RemoveItem(GameObject item)
    {
        bool ret = _items.Contains(item);

        if (ret)
        {
            _items.Remove(item);

            AdjustContentLength(item.transform, -1);
            RemoveListItem(item);
        }

        return ret;
    }

    /// <summary>
    /// 移除ListView元素。
    /// </summary>
    /// <param name="index"></param>
    /// <returns></returns>
    public bool RemoveItem(int index)
    {
        if (index < 0 || index >= _items.Count)
        {
            UnityEngine.Debug.LogWarningFormat("ListView.RemoveListItem():沒有索引爲【{0}】的元素,移除失敗。", index);
            return false;
        }

        GameObject obj = _items[index];
        _items.RemoveAt(index);

        AdjustContentLength(obj.transform, -1);
        RemoveListItem(obj);

        return true;
    }

    /// <summary>
    /// 從ListView頂部移除元素。
    /// </summary>
    /// <param name="count"></param>
    public void RemoveTop(int count = 1)
    {
        for (int i = 0; i < count; i++)
        {
            RemoveItem(0);
        }
    }

    /// <summary>
    /// 從ListView底部移除元素。
    /// </summary>
    /// <param name="count"></param>
    public void RemoveBottom(int count = 1)
    {
        for (int i = 0; i < count; i++)
        {
            RemoveItem(ItemCount - 1);
        }
    }

    /// <summary>
    /// 移除ListView的所有元素。
    /// </summary>
    /// <returns></returns>
    public int RemoveAllItems()
    {
        int count = _items.Count;

        for (int i = count - 1; i >= 0; i--)
        {
            GameObject obj = _items[i];
            _items.RemoveAt(i);

            AdjustContentLength(obj.transform, -1);
            RemoveListItem(obj);
        }

        return count;
    }

    // 獲取

    /// <summary>
    /// 根據索引獲取ListView元素。
    /// </summary>
    /// <param name="index"></param>
    /// <returns></returns>
    public GameObject GetItem(int index)
    {
        return _items[index];
    }

    /// <summary>
    /// 在ListView中查找符合條件的元素,並返回找到的第一個元素。
    /// </summary>
    /// <param name="check">用於判斷元素是否符合要求的方法,如果符合要求則返回true,否則返回false</param>
    /// <returns></returns>
    public GameObject FindItem(Func<GameObject, bool> check)
    {
        GameObject target = null;

        foreach (GameObject item in _items)
        {
            if (check(item))
            {
                target = item;
                break;
            }
        }

        return target;
    }

    /// <summary>
    /// 在ListView中查找符合條件的元素,並返回找到的所有元素。
    /// </summary>
    /// <param name="check">用於判斷元素是否符合要求的方法,如果符合要求則返回true,否則返回false</param>
    /// <returns></returns>
    public GameObject[] FindItems(Func<GameObject, bool> check)
    {
        List<GameObject> targets = new List<GameObject>();

        foreach (GameObject item in _items)
        {
            if (check(item))
            {
                targets.Add(item);
            }
        }

        return targets.ToArray();
    }

    // 定位

    /// <summary>
    /// 將ListView視圖定位到指定索引的元素的位置。
    /// </summary>
    /// <param name="index">目標元素索引</param>
    public void LocateTo(int index)
    {
        if (index <= 0)
        {
            LocateTo(0.0f);
        }
        else if (index >= ItemCount)
        {
            LocateTo(1.0f);
        }
        else
        {
            LocateTo((index + 1.0f) / ItemCount);
        }
    }

    /// <summary>
    /// 將ListView視圖定位到指定百分比位置。
    /// </summary>
    /// <param name="percent">百分比</param>
    public void LocateTo(float percent)
    {
        percent = Mathf.Clamp01(percent);

        if (_layout == Layout.Vertical)
        {
            _scrollRect.verticalNormalizedPosition = percent;
        }
        else
        {
            _scrollRect.horizontalNormalizedPosition = percent;
        }
    }

    /// <summary>
    /// 根據給定的規則對ListView元素進行排序。
    /// </summary>
    /// <param name="comparison"></param>
    public void Sort(Comparison<GameObject> comparison)
    {
        _items.Sort(comparison);

        for (int i = 0; i < _items.Count; i++)
        {
            _items[i].transform.SetSiblingIndex(i);
        }
    }

    #endregion


    #region 私有輔助方法

    // 初始化佈局
    private void InitLayout()
    {
        var contentV = GetComponentInChildren<VerticalLayoutGroup>(true);
        var contentH = GetComponentInChildren<HorizontalLayoutGroup>(true);

        _scrollRect = GetComponent<ScrollRect>();

        if (_layout == Layout.Vertical)
        {
            _scrollRect.vertical = true;
            _scrollRect.horizontal = false;
            _scrollRect.content = contentV.transform as RectTransform;

            contentV.gameObject.SetActive(true);
            contentH.gameObject.SetActive(false);
            _content = contentV;
        }
        else
        {
            _scrollRect.vertical = false;
            _scrollRect.horizontal = true;
            _scrollRect.content = contentH.transform as RectTransform;

            contentV.gameObject.SetActive(false);
            contentH.gameObject.SetActive(true);
            _content = contentH;
        }
    }

    // 計算Content區域的初始長度
    private void CalcContentLength()
    {
        float contentLength = 0;

        int itemCount = _content.transform.childCount;
        contentLength += (itemCount - 1) * _spacing;

        foreach (var obj in _content.transform)
        {
            RectTransform rect = obj as RectTransform;

            if (_layout == Layout.Vertical)
            {
                contentLength += rect.sizeDelta.y;
            }
            else
            {
                contentLength += rect.sizeDelta.x;
            }
        }

        if (_layout == Layout.Vertical)
        {
            contentLength += _padding.top + _padding.bottom;
        }
        else
        {
            contentLength += _padding.left + _padding.right;
        }

        RectTransform contentRect = _content.transform as RectTransform;
        Vector2 contentSizeDelta = contentRect.sizeDelta;
        if (_layout == Layout.Vertical)
        {
            contentSizeDelta.y = contentLength;
        }
        else
        {
            contentSizeDelta.x = contentLength;
        }
        contentRect.sizeDelta = contentSizeDelta;
    }

    // 在添加或移除元素時計算調整Content區域的高度或寬度
    private void AdjustContentLength(Transform itemTrans, int power)
    {
        if (!FixedElementLength || _itemLenght < 0)
        {
            // 需要手動計算新增項高度
            RectTransform rect = itemTrans as RectTransform;
            if (_layout == Layout.Vertical)
            {
                _itemLenght = rect.sizeDelta.y;
            }
            else
            {
                _itemLenght = rect.sizeDelta.x;
            }
        }

        power = power < 0 ? -1 : 1;

        RectTransform contentRect = _content.transform as RectTransform;
        Vector2 contentSizeDelta = contentRect.sizeDelta;
        if (_layout == Layout.Vertical)
        {
            contentSizeDelta.y += (_itemLenght + _spacing) * power;
        }
        else
        {
            contentSizeDelta.x += (_itemLenght + _spacing) * power;
        }
        contentRect.sizeDelta = contentSizeDelta;
    }

    // 判斷是否需要動畫,並移除列表元素
    private void RemoveListItem(GameObject item)
    {
        if (AnimationTime > 0)
        {
            StartCoroutine(IERemoveItemAnim(item));
        }
        else
        {
            item.transform.SetParent(null);
            RemoveMethod(item);
        }
    }

    // 逐漸放大列表元素Y軸
    private IEnumerator IEAddItemAnim(GameObject item)
    {
        // HorizontalOrVerticalLayoutGroup 以元素的 Width 或 Height 屬性計算位置,
        // 不以 Scale.x 或 Scale.y 計算位置,因此要同時修改 sizeDelta 。
        RectTransform rect = item.transform as RectTransform;
        float originLength;
        Vector2 currSize, currScale;

        if (_layout == Layout.Vertical)
        {
            originLength = rect.sizeDelta.y;
            currSize = new Vector2(rect.sizeDelta.x, 0);
            currScale = new Vector3(1, 0, 1);
        }
        else
        {
            originLength = rect.sizeDelta.x;
            currSize = new Vector2(0, rect.sizeDelta.y);
            currScale = new Vector3(0, 1, 1);
        }

        float timer = 0;
        rect.sizeDelta = currSize;
        rect.localScale = currSize;

        while (timer < AnimationTime)
        {
            timer += Time.deltaTime;

            if (_layout == Layout.Vertical)
            {
                currScale.y = timer / AnimationTime;
                currSize.y = originLength * currScale.y;
            }
            else
            {
                currScale.x = timer / AnimationTime;
                currSize.x = originLength * currScale.x;
            }

            rect.localScale = currScale;
            rect.sizeDelta = currSize;

            yield return null;
        }

        if (_layout == Layout.Vertical)
        {
            currScale.y = 1;
            currSize.y = originLength;
        }
        else
        {
            currScale.x = 1;
            currSize.x = originLength;
        }
        rect.localScale = currScale;
        rect.sizeDelta = currSize;
    }

    // 逐漸縮小列表元素Y軸,最終銷燬元素
    private IEnumerator IERemoveItemAnim(GameObject item)
    {
        // HorizontalOrVerticalLayoutGroup 以元素的 Width 或 Height 屬性計算位置,
        // 不以 Scale.x 或 Scale.y 計算位置,因此要同時修改 sizeDelta 。
        RectTransform rect = item.transform as RectTransform;
        float originLength;
        Vector2 currSize, currScale;

        if (_layout == Layout.Vertical)
        {
            originLength = rect.sizeDelta.y;
            currSize = rect.sizeDelta;
            currScale = rect.localScale;
        }
        else
        {
            originLength = rect.sizeDelta.x;
            currSize = rect.sizeDelta;
            currScale = rect.localScale;
        }

        float timer = 0;
        while (timer < AnimationTime)
        {
            timer += Time.deltaTime;

            if (_layout == Layout.Vertical)
            {
                currScale.y = 1 - timer / AnimationTime;
                currSize.y = originLength * currScale.y;
            }
            else
            {
                currScale.x = 1 - timer / AnimationTime;
                currSize.x = originLength * currScale.x;
            }

            rect.localScale = currScale;
            rect.sizeDelta = currSize;

            yield return null;
        }

        rect.SetParent(null);
        RemoveMethod(item);
    }

    #endregion


    private enum Layout
    {
        Vertical, // 使用VerticalLayoutGroup
        Horizontal, // 使用HorizontalLayoutGroup
    }

}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章