B站测验视频
一、RectTransform 的 Anchor / Pivot 基础
1.1 坐标系区分
Unity UI 里有两套容易混淆的坐标系:
- Anchor / Pivot 的坐标系(0~1 范围,归一化)
- 相对于父 RectTransform 的宽高做归一化:
(0, 0):左下角(0, 1):左上角(1, 0):右下角(1, 1):右上角
- 相对于父 RectTransform 的宽高做归一化:
- anchoredPosition 的坐标系(像素)
- 相对于锚点位置的 2D 偏移(单位:像素)
(0, 0)表示「锚点 + pivot 那个点」正好对齐在一起
1.2 Anchor 的作用
- AnchorMin / AnchorMax 决定子物体如何「附着」在父 Rect 上:
anchorMin = (0, 1); anchorMax = (0, 1)→ 左上角一个点anchorMin = (0, 1); anchorMax = (1, 1)→ 水平方向拉伸到父节点的整个上边
- 当父 Rect 大小变化时:
- 如果
Min和Max不同,子 Rect 会跟着缩放 / 拉伸 - 如果
Min == Max,子 Rect 当成一个固定大小的"点",只跟着移动,不跟着缩放
- 如果
1.3 Pivot 的作用
- Pivot 是自身矩形内部 的一个归一化点:
(0, 1):自身左上角(0.5, 0.5):自身中心(1, 0):自身右下角
- 影响:
anchoredPosition是针对 pivot 来偏移的- 旋转、缩放都会绕 pivot 旋转 / 缩放
1.4 典型布局:左上对齐的列表项
排行榜每一行 Item 最常见的设置:
Content:AnchorMin = (0, 1)AnchorMax = (1, 1)(左右拉伸,靠上)Pivot = (0.5, 1)
RankItem(单个条目):AnchorMin = (0, 1)AnchorMax = (0, 1)Pivot = (0, 1)anchoredPosition = (0, y)
含义:
Content的上边永远贴着父节点上边,宽度随父节点变化- 每个
RankItem的「左上角」作为参考点:x = 0→ 靠Content左边对齐y = 0→ 顶部第一行y = -cellSize * index→ 从上往下均匀排布
二、无限列表(虚拟列表)的整体思想
2.1 问题背景
- 排行榜可能有大量数据(几十、几百、几千条)
- 如果对每一条都
Instantiate一个 UI:- 内存占用大
- 生成瞬间卡顿
- 滚动时也会更重
需求:只创建视口内能看到的那几屏 Item,但看起来像是有完整的长列表。
2.2 核心思路:复用少量「演员」
概念类比:
- 数据 :后台的
List<RankItemClass>,比如长度 100 - UI 物体 :显示一行的
RankItem预制体 - 对象池 / 演员池:只创建「屏幕能显示的数量 + 少量缓冲」
算法上做两件事:
- 根据滚动位置,计算当前需要显示的是第几条数据(start index)
- 把有限个 UI 物体重新定位并绑定到对应的数据上
用户眼里看到的是「列表在滚」,实际上是「同一批 UI 物体在换数据、换位置」。
2.3 关键参数
itemHeight:单个条目的高度spacing:条目之间的间距cellSize = itemHeight + spacingviewPortHeight:可视区域的高度(通常是 ScrollRect 或 Viewport 的rect.height)visibleCount ≈ viewPortHeight / cellSize + bufferCountbufferCount:为了避免边缘刚出现就闪现的缓冲数量
2.4 Content 高度与滚动
-
撑开 Content 的高度:
csharptotalHeight = cellSize * mockDataList.Count; content.sizeDelta = new Vector2(content.sizeDelta.x, totalHeight);- 这样 ScrollRect 认为「有这么高的内容可以滚」
- 滚动条比例、可滚动范围就都正确了
-
监听 ScrollRect 滚动事件:
csharpscrollRect.onValueChanged.AddListener(OnScroll); void OnScroll(Vector2 pos) { Refresh(content.anchoredPosition.y); }- 在 UGUI 里,Content 向上滚,
anchoredPosition.y会变大
- 在 UGUI 里,Content 向上滚,
2.5 计算当前起始数据索引(start index)
csharp
float cellSize = itemHeight + spacing;
int startIdx = Mathf.FloorToInt(Mathf.Max(0, scrollY) / cellSize);
startIdx = Mathf.Clamp(startIdx, 0, Mathf.Max(0, mockDataList.Count - 1));
含义:
scrollY / cellSize≈「已经滚走了多少行」FloorToInt→ 向下取整,得到当前应当显示的第一条的索引
为了防止频繁刷新,在起始索引没变的时候直接跳过:
csharp
if (startIdx == prevStartIdx) return;
prevStartIdx = startIdx;
2.6 更新对象池里的「演员」
csharp
for (int i = 0; i < visibleCount; i++)
{
int dataIdx = startIdx + i;
var itemRT = _pool[i];
if (dataIdx < mockDataList.Count)
{
itemRT.gameObject.SetActive(true);
float y = -dataIdx * cellSize; // 从上往下排
itemRT.anchoredPosition = new Vector2(0, y); // 左上对齐
UpdateItem(itemRT, dataIdx); // 绑定数据
}
else
{
itemRT.gameObject.SetActive(false); // 超出范围就隐藏
}
}
UpdateItem 只负责把「某条数据」刷到「某个 RankItem UI」上:
csharp
void UpdateItem(RectTransform item, int index)
{
if (index < 0 || index >= mockDataList.Count) return;
RankItem ui = item.GetComponent<RankItem>();
RankItemClass data = mockDataList[index];
ui.Refresh(
data.RankNumber,
data.PlayerName,
data.PlayerImagePath,
data.CountryImagePath,
data.WinNumber
);
}
2.7 对象池初始化
csharp
visibleCount = Mathf.CeilToInt(viewPortHeight / cellSize) + bufferCount;
_pool.Clear();
for (int i = 0; i < visibleCount; i++)
{
var _item = Instantiate(rankItem, content);
var rt = _item.GetComponent<RectTransform>();
// 可选:在代码里强制做一次左上锚点
// rt.anchorMin = new Vector2(0, 1);
// rt.anchorMax = new Vector2(0, 1);
// rt.pivot = new Vector2(0, 1);
_item.gameObject.SetActive(false);
_pool.Add(rt);
}
三、普通列表 vs 无限列表
3.1 普通列表(当前 RankPanel)
- 做法:
- 拿到 100 条数据
for循环里Instantiate100 个RankItem,依次Refresh
- 优点:
- 实现简单、调试直观
- 适合数据量不太大、UI 复杂度不高的界面
- 缺点:
- 数据很多时会有内存 / 性能问题
- 一次性生成时可能有卡顿
3.2 无限列表(RankPanelInfinite)
- 做法:
- 仍然准备 100 条数据,但只生成「几屏范围内」的
RankItem物体 - 滚动时复用这些物体,并根据位置换数据、换坐标
- 仍然准备 100 条数据,但只生成「几屏范围内」的
- 优点:
- UI 实例数基本恒定(几十个),内存占用更稳定
- 初次加载快、不容易掉帧
- 缺点:
- 实现复杂度更高,特别是:
- 锚点 / pivot
- Content 高度
- 滚动方向
- 复用逻辑
- 实现复杂度更高,特别是:
四、实现过程里踩过的典型坑(可作为注意事项)
- 1. itemHeight 在使用前没赋值
- 用它算
visibleCount之前必须先从rankItem.rect.height拿到真实值。
- 用它算
- 2. 把总像素高度当成「数量」来比较
totalHeight = cellSize * count;- 判断是否越界时要用
dataIdx < count,不能用dataIdx < totalHeight。
- 3. 锚点 / pivot 导致 Item 出现在左上角,只能看到一半
- Prefab 默认中心锚点,父节点左上对齐 → 中心点在左上 → 左半被裁掉。
- 4. ScrollRect 结构与 viewport 变量不一致
- 实际层级没有
Viewport节点时,可以直接把ScrollRect自己拖给viewport用。
- 实际层级没有
csharp
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class RankPanelInfinite : BasePanel
{
public RankItem rankItem;
public RectTransform content;
public RectTransform viewport;
public ScrollRect scrollRect;
private List<RankItemClass> mockDataList = new List<RankItemClass>();
public int spacing = 10;
public int bufferCount = 3; //缓冲数量
private List<RectTransform> _pool = new List<RectTransform>();
private float itemHeight;
private int visibleCount;
private float totalDataCount; //总高度, 撑开到总高度
private int prevStartIdx = -1;
private void Start()
{
SetButtonListener();
// GenerateMockData();
mockDataList = RankMockDataGenerator.GenerateMockRankData(100);
itemHeight = rankItem.GetComponent<RectTransform>().rect.height;
float cellSize = itemHeight + spacing;
// 1. 计算需要多少个"演员" (可见数量 + 缓冲)
float viewPortHeight = viewport.rect.height;
visibleCount = Mathf.CeilToInt(viewPortHeight / cellSize) + bufferCount;
totalDataCount = cellSize * mockDataList.Count;
// 2. 核心操作:手动设置 Content 总高度
// totalDataCount 是 所有数据的总高度,itemHeight 是 160,这里直接撑开到 最多需要显示的像素
// 这样 ScrollRect 的滚动条比例才会显示正确,且不需要 ContentSizeFitter
//ScrollRect 组件是根据 Content 的高度来决定滚动条(Handle)的大小和滑动范围的。
content.sizeDelta = new Vector2(content.sizeDelta.x, totalDataCount);
_pool.Clear();
// 3. 实例化对象池 (Object Pooling)
for (int i = 0; i < visibleCount; i++)
{
var _item = Instantiate(rankItem, content);
_item.gameObject.SetActive(false);
_pool.Add(_item.GetComponent<RectTransform>());
}
// 4. 监听 ScrollRect 的滚动事件
scrollRect.onValueChanged.AddListener(OnScroll);
// 5. 初次刷新一次,显示初始区域
Refresh(0f);
// CreateRankList();
}
private void CreateRankList()
{
for (int i = content.childCount - 1; i >= 0; i--)
{
Destroy(content.GetChild(i).gameObject);
}
foreach (var data in mockDataList)
{
RankItem item = Instantiate(rankItem, content);
item.gameObject.SetActive(true);
item.Refresh(
data.RankNumber,
data.PlayerName,
data.PlayerImagePath,
data.CountryImagePath,
data.WinNumber
);
}
}
void OnScroll(Vector2 pos)
{
// Unity UGUI 中,当 Content 向上滑时,其 anchoredPosition.y 会逐渐增大
Refresh(content.anchoredPosition.y);
}
//根据当前滚动的距离(scrollY)来决定:现在屏幕上应该显示哪几条数据,并把对象池里的 Item 排好位置、刷好数据。
void Refresh(float scrollY)
{
//空列表保护:
// 如果没有数据,直接返回,避免后面各种越界。
if (mockDataList == null || mockDataList.Count == 0) return;
//一行所占的「总高度」:单个 Item 高度 + 行间距。
float cellSize = itemHeight + spacing;
// 1. 确定当前视口中第一条数据的索引 (Data Index)
// 使用 Mathf.Max 确保不会出现负数索引
//Mathf.Max(0, scrollY):防止因为精度之类出现负数。
// / cellSize:滚了多少像素 / 每行像素 = 大概滚过了多少行。
// FloorToInt:往下取整 → 比如滚了 1.2 行,也当作「起点是第 1 行」。
int startIdx = Mathf.FloorToInt(Mathf.Max(0, scrollY) / cellSize);
// Clamp:再把 startIdx 限制在 [0, mockDataList.Count - 1] 之间,防止越界。
startIdx = Mathf.Clamp(startIdx, 0, Mathf.Max(0, mockDataList.Count - 1));
//startIdx = 当前屏幕顶部刚好或刚滚过去的那行数据的索引。
// 2. 帧过滤:如果起始索引没有变化,说明没滑出一个 Item 的距离,跳过刷新
if (startIdx == prevStartIdx) return;
prevStartIdx = startIdx;
// 3. 更新所有"演员"的坐标和数据绑定
for (int i = 0; i < visibleCount; i++)
{
int dataIdx = startIdx + i;
// 使用取模运算循环复用 Pool 里的 UI 物体
var itemRT = _pool[i];
if (dataIdx < mockDataList.Count)
{
itemRT.gameObject.SetActive(true);
// 计算每个 Item 的位置( 从上往下排)
float y = -dataIdx * cellSize;
itemRT.anchoredPosition = new Vector2(0, y);
// 关键点:根据数据索引计算该条目的绝对位置 (Top Alignment)
itemRT.anchoredPosition = new Vector2(0, y);
// 触发数据绑定逻辑
UpdateItem(itemRT, dataIdx);
}
else
{
// 超出总数据量范围,隐藏多余的"演员"
itemRT.gameObject.SetActive(false);
}
}
void UpdateItem(RectTransform item, int index)
{
if (index < 0 || index >= mockDataList.Count) return;
RankItem ui = item.GetComponent<RankItem>();
RankItemClass data = mockDataList[index];
ui.Refresh(
data.RankNumber,
data.PlayerName,
data.PlayerImagePath,
data.CountryImagePath,
data.WinNumber
);
}
}
void SetButtonListener()
{
GetUiContro<Button>("Button_Back").onClick.AddListener(() =>
{
UiManager.GetInstance().HidePanel("RankPanel");
});
GetUiContro<Button>("Button_Home").onClick.AddListener(() =>
{
UiManager.GetInstance().HidePanel("RankPanel");
});
}
}