无限滚动的ScrollRect
2种布局方式,水平或者垂直
支持跳转索引
支持设置内容的pivot和anchor,从上面开始或者下面开始都可以
原理
创建与数据总数对应的矩形
每帧遍历矩形是否与遮罩矩形相交
如果相交添加到索引列表
遍历格子索引
如果索引存在索引列表里面,列表移除该索引
如果不在索引列表里面,把格子索引设置为-1
遍历索引列表
找一个没有绘制的格子对索引绘制
布局
水平布局:
格子坐标:
cellPos = new Vector2(index / row,index % row);
垂直布局:
格子坐标:
cellPos = new Vector2(index % column,index / column);
从公式可以看出水平和垂直只是2个坐标轴调换了一下
已知 Vector2 可以使用this索引访问数据
所以只需要添加一个轴变量即可转换布局
var pos = Vector2.zero;
pos[asix] = index / layoutValue;
pos[(asix + 1) % 2] = index % layoutValue;
显示
首先创建最大可显示数量的格子
只需要 遮罩大小 / 格子大小 +1 就可获得在遮罩内可显示格子数量
200/50 = 4,但是当你拖动列表的时候,就会需要5个格子显示
var xCount = (Mathf.CeilToInt(m_ViewRect.width / m_CellSize.x) + 1);
var yCount = (Mathf.CeilToInt(m_ViewRect.height / m_CellSize.y) + 1);
xCount = !isHorizontal ? Math.Min(xCount, layoutValue) : xCount;
yCount = isHorizontal ? Math.Min(yCount, layoutValue) : yCount;
m_RenderCount = xCount * yCount;
渲染
更新遮罩区域坐标,通过计算当前内容的坐标,描点,中心轴,得到遮罩的坐标
m_ViewRect.x = m_ContentPivotOffset.x - m_ContentRect.anchoredPosition.x - m_ContentAnchorOffset.x;
m_ViewRect.y = m_ContentPivotOffset.y - m_ViewRect.height - (m_ContentRect.anchoredPosition.y) + m_CellSize.y + m_ContentAnchorOffset.y;
获取与遮罩区域相交的格子
var count = itemRects.Count;
for (var i = 0; i < count; i++)
{
if (m_ViewRect.Overlaps(itemRects[i]))
{
indexs.Add(i);
}
}
移除indexs列表中正在显示的格子
foreach (var item in items)
{
if (!indexs.Contains(item.index))
{
item.index = -1;
item.transform.gameObject.SetActive(false);
}
else
{
indexs.Remove(item.index);
}
}
设置格子坐标并渲染
foreach (var index in indexs)
{
var renderRect = GetNullRenderRect();
RenderItem(renderRect, index);
renderRect.transform.gameObject.SetActive(true);
renderRect.index = index;
}
void RenderItem(RenderRect rect,int index)
{
rect.transform.anchoredPosition = GetRenderItemPos(index);
onRenderItem?.Invoke(index, rect.transform.gameObject);
}
在格子上按下
使用OnPointerDown事件接口获取当前点击的对象
向上访问父亲链,如果与格子相等就是点击的索引
void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
{
if (eventData.pointerEnter == null)
return;
var pointer = eventData.pointerEnter.transform as RectTransform;
if (pointer == m_ScrollRect.viewport || pointer == m_ScrollRect.transform)
return;
while (pointer != m_ScrollRect.transform)
{
foreach (var item in items)
{
if (item.transform == pointer)
{
onClickItem?.Invoke(item.index);
return;
}
}
pointer = pointer.parent as RectTransform;
}
}
跳转索引
根据索引获取跳转坐标,在LateUpdate插值坐标
坐标应设置边界,例如滑动最大值为100,格子坐标为150
当设置为150的时候永远也无法到达,这是不允许的
如果内容比遮罩还小,没必要跳转
假如开启了插值跳转,并且正在跳转中,你滑动了列表,此时应停止跳转
public void MoveToIndex(int index)
{
if (index < 0 || index >= itemRects.Count)
return;
m_TargetPos = itemRects[index].position * new Vector2(-1, -1) ;
m_TargetPos += m_ContentAnchorOffset * new Vector2(-1, 1);
m_TargetPos += m_ContentPivotOffset;
m_TargetPos.x = Mathf.Max(m_TargetPos.x, -m_ContentRect.sizeDelta.x + m_ViewRect.width - m_ContentAnchorOffset.x + m_ContentPivotOffset.x);
m_TargetPos.y = Math.Min(m_TargetPos.y, m_ContentRect.sizeDelta.y - m_ViewRect.height + m_ContentAnchorOffset.y + m_ContentPivotOffset.y);
m_TargetPos.x = m_ContentRect.sizeDelta.x - m_ViewRect.width < 0 ? 0 : m_TargetPos.x;
m_TargetPos.y = m_ContentRect.sizeDelta.y - m_ViewRect.height < 0 ? 0 : m_TargetPos.y;
if (m_ScrollRect.inertia)
{
m_IsMoveToTarget = true;
}
else
{
m_ContentRect.anchoredPosition = m_TargetPos;
}
}
private void LateUpdate()
{
if (!m_IsMoveToTarget)
return;
m_ContentRect.anchoredPosition = Vector2.Lerp(m_ContentRect.anchoredPosition, m_TargetPos, m_ScrollRect.decelerationRate);
if ((m_ContentRect.anchoredPosition - m_TargetPos).sqrMagnitude < 0.05f)
{
m_ContentRect.anchoredPosition = m_TargetPos;
m_IsMoveToTarget = false;
}
}
void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
{
m_IsMoveToTarget = false;
}
demo
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class Test : MonoBehaviour
{
public Button addData;
public Button gotoIndex;
public Button changedLayout;
public ListView listView;
private void Awake()
{
listView.onRenderItem = OnRenderItem;
listView.onClickItem = OnClickItem;
addData.onClick.AddListener(OnAddData);
gotoIndex.onClick.AddListener(OnMoveToIndex);
bool isHorizontal = true;
changedLayout.onClick.AddListener(()=> {
listView.UpdateLayout(isHorizontal);
isHorizontal = !isHorizontal;
});
}
void OnRenderItem(int index,GameObject item)
{
item.GetComponentInChildren<Text>().text = index.ToString();
}
void OnClickItem(int index)
{
Debug.Log("click "+index);
}
void OnAddData()
{
listView.Count = Random.Range(0,100);
Debug.Log($"添加数据:"+listView.Count);
}
void OnMoveToIndex()
{
var index = Random.Range(0, listView.Count);
listView.MoveToIndex(index);
Debug.Log($"moveTo {index}");
}
}
完整源码
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public class ListView : MonoBehaviour, IBeginDragHandler, IPointerDownHandler
{
class RenderRect
{
public RectTransform transform;
public int index = -1;
}
/// <summary>
/// 生成模板
/// </summary>
public GameObject itemTemplate;
/// <summary>
/// 是否水平布局
/// </summary>
[SerializeField] private bool isHorizontal = true;
/// <summary>
/// 如果是水平布局,该值为垂直数量,如果是垂直布局,该值为水平数量
/// </summary>
public int layoutValue;
/// <summary>
/// 间隔
/// </summary>
public Vector2 spacing;
/// <summary>
/// 渲染回调
/// </summary>
public Action<int, GameObject> onRenderItem;
/// <summary>
/// 点击回调
/// </summary>
public Action<int> onClickItem;
private bool m_isInitialize = false;
private bool m_IsMoveToTarget = false;
private int m_Asix;
private int m_Count;
private int m_RenderCount;
private Vector2 m_SpacingPivot;
private Vector2 m_CellPivotOffset;
private Vector2 m_TargetPos;
private Vector2 m_CellSize;
private Vector2 m_ContentAnchor;
private Vector2 m_ItemAnchor;
private Vector2 m_CellAnchorOffset;
private Vector2 m_ContentAnchorOffset;
private Vector2 m_ContentPivotOffset;
private Rect m_ViewRect;
private ScrollRect m_ScrollRect;
private RectTransform m_ContentRect;
private List<Rect> itemRects = new List<Rect>();
private List<RenderRect> items = new List<RenderRect>();
private List<int> indexs = new List<int>();
/// <summary>
/// 数据数量
/// 生成与数据数量相等的格子矩形
/// </summary>
public int Count
{
get
{
return m_Count;
}
set
{
var v = value - m_Count;
m_Count = value;
if (v > 0)
{
for (var i = 0; i < v; i++)
{
itemRects.Add(GetLayout(itemRects.Count, m_Asix));
}
}
else
{
v = Mathf.Abs(v);
itemRects.RemoveRange(itemRects.Count - v, v);
}
UpdateContentSize(m_Asix);
RefreshList();
}
}
/// <summary>
/// 移动至目标索引
/// </summary>
/// <param name="index"></param>
public void MoveToIndex(int index)
{
if (index < 0 || index >= itemRects.Count)
return;
m_TargetPos = itemRects[index].position * new Vector2(-1, -1) ;
m_TargetPos += m_ContentAnchorOffset * new Vector2(-1, 1);
m_TargetPos += m_ContentPivotOffset;
m_TargetPos.x = Mathf.Max(m_TargetPos.x, -m_ContentRect.sizeDelta.x + m_ViewRect.width - m_ContentAnchorOffset.x + m_ContentPivotOffset.x);
m_TargetPos.y = Math.Min(m_TargetPos.y, m_ContentRect.sizeDelta.y - m_ViewRect.height + m_ContentAnchorOffset.y + m_ContentPivotOffset.y);
m_TargetPos.x = m_ContentRect.sizeDelta.x - m_ViewRect.width < 0 ? 0 : m_TargetPos.x;
m_TargetPos.y = m_ContentRect.sizeDelta.y - m_ViewRect.height < 0 ? 0 : m_TargetPos.y;
if (m_ScrollRect.inertia)
{
m_IsMoveToTarget = true;
}
else
{
m_ContentRect.anchoredPosition = m_TargetPos;
}
}
/// <summary>
/// 初始化
/// </summary>
public void Initialize()
{
if (m_isInitialize)
return;
m_ScrollRect = GetComponent<ScrollRect>();
m_ViewRect = new Rect(Vector2.zero, (m_ScrollRect.transform as RectTransform).sizeDelta);
m_ContentRect = m_ScrollRect.content;
m_ContentAnchor = (m_ContentRect.anchorMax + m_ContentRect.anchorMin) * 0.5f;
var itemRect = itemTemplate.transform as RectTransform;
m_CellSize = itemRect.rect.size + spacing;
m_ItemAnchor = (itemRect.anchorMax + itemRect.anchorMin) * 0.5f;
m_CellPivotOffset = m_CellSize * (itemRect.pivot - Vector2.up);
m_ContentAnchorOffset = new Vector2(m_ContentAnchor.x, (1 - m_ContentAnchor.y)) * m_ViewRect.size;
m_SpacingPivot = spacing * new Vector2(-itemRect.pivot.x, 1 - itemRect.pivot.y);
if (Mathf.Approximately(m_ScrollRect.decelerationRate, 0))
{
m_ScrollRect.decelerationRate = 1;
}
UpdateLayout(isHorizontal);
m_isInitialize = true;
}
/// <summary>
/// 更换布局
/// </summary>
/// <param name="isHorizontal"></param>
public void UpdateLayout(bool isHorizontal)
{
if(m_isInitialize && this.isHorizontal==isHorizontal)
return;
layoutValue = Mathf.Max(layoutValue, 1);
m_Asix = isHorizontal ? 0 : 1;
var xCount = (Mathf.CeilToInt(m_ViewRect.width / m_CellSize.x) + 1);
var yCount = (Mathf.CeilToInt(m_ViewRect.height / m_CellSize.y) + 1);
xCount = !isHorizontal ? Math.Min(xCount, layoutValue) : xCount;
yCount = isHorizontal ? Math.Min(yCount, layoutValue) : yCount;
m_RenderCount = xCount * yCount;
var v = m_RenderCount - items.Count;
if (v > 0)
{
for (var i = 0; i < v; i++)
{
var go = GameObject.Instantiate(itemTemplate);
go.transform.SetParent(itemTemplate.transform.parent, false);
items.Add(new RenderRect
{
transform = go.transform as RectTransform
});
}
}
foreach (var item in items)
{
item.index = -1;
}
itemRects.Clear();
var count = m_Count;
m_Count = 0;
Count = count;
this.isHorizontal = isHorizontal;
}
/// <summary>
/// 数据更新后手动刷新列表
/// </summary>
public void RefreshList()
{
foreach (var item in items)
{
if(item.index!=-1)
{
RenderItem(item, item.index);
}
}
}
private void Awake()
{
Initialize();
}
/// <summary>
/// 移动到目标
/// </summary>
private void LateUpdate()
{
if (!m_IsMoveToTarget)
return;
m_ContentRect.anchoredPosition = Vector2.Lerp(m_ContentRect.anchoredPosition, m_TargetPos, m_ScrollRect.decelerationRate);
if ((m_ContentRect.anchoredPosition - m_TargetPos).sqrMagnitude < 0.05f)
{
m_ContentRect.anchoredPosition = m_TargetPos;
m_IsMoveToTarget = false;
}
}
/// <summary>
/// 渲染格子
/// </summary>
private void Update()
{
m_ViewRect.x = m_ContentPivotOffset.x - m_ContentRect.anchoredPosition.x - m_ContentAnchorOffset.x;
m_ViewRect.y = m_ContentPivotOffset.y - m_ViewRect.height - (m_ContentRect.anchoredPosition.y) + m_CellSize.y + m_ContentAnchorOffset.y;
var count = itemRects.Count;
for (var i = 0; i < count; i++)
{
if (m_ViewRect.Overlaps(itemRects[i]))
{
indexs.Add(i);
}
}
foreach (var item in items)
{
if (!indexs.Contains(item.index))
{
item.index = -1;
item.transform.gameObject.SetActive(false);
}
else
{
indexs.Remove(item.index);
}
}
foreach (var index in indexs)
{
var renderRect = GetNullRenderRect();
RenderItem(renderRect, index);
renderRect.transform.gameObject.SetActive(true);
renderRect.index = index;
}
indexs.Clear();
}
void RenderItem(RenderRect rect,int index)
{
rect.transform.anchoredPosition = GetRenderItemPos(index);
onRenderItem?.Invoke(index, rect.transform.gameObject);
}
Vector2 GetRenderItemPos(int index)
{
return itemRects[index].position + m_CellPivotOffset + m_CellAnchorOffset + m_SpacingPivot;
}
/// <summary>
/// 找一个空闲的格子绘制
/// </summary>
/// <returns></returns>
RenderRect GetNullRenderRect()
{
foreach (var item in items)
{
if (item.index == -1)
{
return item;
}
}
throw new System.Exception("Error");
}
/// <summary>
/// 获取当前布局下,index的矩形
/// </summary>
/// <param name="index"></param>
/// <param name="asix"></param>
/// <returns></returns>
Rect GetLayout(int index, int asix)
{
var pos = Vector2.zero;
pos[asix] = index / layoutValue;
pos[(asix + 1) % 2] = index % layoutValue;
return new Rect(pos * new Vector2(1, -1) * m_CellSize, m_CellSize);
}
/// <summary>
/// 更新内容大小
/// </summary>
/// <param name="asix"></param>
void UpdateContentSize(int asix)
{
var count = Vector2.zero;
count[asix] = Mathf.CeilToInt((float)Count / layoutValue);
count[(asix + 1) % 2] = layoutValue;
m_ContentRect.sizeDelta = count * m_CellSize - spacing;
m_CellAnchorOffset = m_ContentRect.rect.size * new Vector2(-m_ItemAnchor.x, 1 - m_ItemAnchor.y);
m_ContentPivotOffset = m_ContentRect.rect.size * new Vector2(m_ContentRect.pivot.x, -(1 - m_ContentRect.pivot.y));
}
void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
{
m_IsMoveToTarget = false;
}
void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
{
if (eventData.pointerEnter == null)
return;
var pointer = eventData.pointerEnter.transform as RectTransform;
if (pointer == m_ScrollRect.viewport || pointer == m_ScrollRect.transform)
return;
while (pointer != m_ScrollRect.transform)
{
foreach (var item in items)
{
if (item.transform == pointer)
{
onClickItem?.Invoke(item.index);
return;
}
}
pointer = pointer.parent as RectTransform;
}
}
}