Unity UI 循环列表 UIScrollViewContent 实现
一个自研的 UI 循环(无限)列表组件,基于 NGUI 的
UIScrollView/UIPanel/UIWidget。本文只讲 C# 层实现。
背景
大量条目(好友、排行榜、背包、商店)需要高性能滚动列表。Unity 原生 ScrollRect 与 NGUI 自带 UIWrapContent 只支持单列且无数据增删/定位/尺寸自适应能力,故自研了 UIScrollViewContent。
核心类
| 类 | 定位 |
|---|---|
UIScrollViewContent(主力) |
自研循环列表,全工程 几百处使用 |
UIWrapContent(NGUI 原版) |
NGUI 自带无限滚动,备用 |
UIWrapGridContent(NGUI 原版) |
网格版循环滚动,备用 |
总体架构
[UIPanel]
└─ [UIScrollView] (movement = Vertical)
└─ [UIScrollViewContent] ← 本组件挂在 content 节点上
├─ item × N ← 复用的 UIWidget 池
├─ Snap-to-Top Helper ← 2×2 隐形 widget,铺不满屏时保证 bounce
└─ Contents region ← bottomMargin > 0 时创建的底部留白区域
设计思路:对象池复用 + 位置换位(wrap)。不是"数据驱动逐条实例化",而是预生成"刚好铺满一屏 + 上下各缓冲一行"的 item;滚动时把超出边界的 item 整段平移到另一端,再由位置反推应显示第几条数据,通知上层刷新内容。
核心字段
| 字段 | 含义 |
|---|---|
itemPrefab |
item 原型(prefab 或场景对象;场景对象克隆后删原体) |
itemCountPerRow |
每行 item 数(支持网格布局) |
spacing |
间距:x 为滚动区宽度的百分比,y 为绝对值,两者相加为像素间距 |
adjustItemWidth |
item 宽度是否随滚动区宽度自适应拉伸 |
itemCount |
真实数据条数 (与池大小 items.Count 不同) |
bottomMargin |
列表底部额外留白 |
topPivot |
是否以顶部居中为基准排布(否则顶部居左) |
verticalScrollBar |
垂直滚动条(可选) |
items |
复用的 UIWidget 池 |
ItemUpdated |
数据刷新回调事件 Action<GameObject, int> |
OnCreateItemFinished |
池创建完成回调 |
初始化流程
Awake() → FindView() // 缓存 panel/scrollView,校验只支持 Vertical
Update() → CreateItems() // 首帧生成对象池
→ WrapContent() // 首次排布 + 填充
→ updateAnchor = false
CreateItems() 要点:
- 算间距:
spacingPx = panel.width * spacing.x + spacing.y - 算可见行数(含上下各 1 行缓冲):
visibleRowCount = ceil(panel.height / (itemH + spacingPx)) + 2 - 预生成池:
while (items.Count < visibleRowCount * itemCountPerRow) Instantiate(itemPrefab) - 记录
heightAllItems(池总高,用于 wrap 判定) - 创建
snapHelperWidget:右下角 2×2 隐形 widget,解决"item 数不足铺满屏时 NGUI 不 bounce"问题 - 创建
scrollViewContentsRegion:bottomMargin > 0时用于底部留白 - 触发
OnCreateItemFinished
循环滚动核心:WrapContent()
每次 OnMove(面板裁剪区移动)调用:
- 取滚动区中心
center(面板 4 角 → content 局部坐标 → Lerp 中点) - 遍历所有激活 item:
dist = item.y - center.y- 若
|dist| > heightAllItems * 0.5(item 已滚出缓冲带),朝反方向整段平移:
do { pos.y -= sign(dist) * heightAllItems; } while (仍超出范围) index = GetItemIndex(pos)反推新位置对应的数据索引- 索引在
[0, itemCount)内 →item.localPosition = pos+UpdateItem(item, index) - 否则 →
allWithinRange = false(触发边缘 bounce)
- item 数铺不满一屏 → 强制
allWithinRange = false scrollView.restrictWithinPanel = !allWithinRange
位置 ↔ 索引互转
索引 → 位置 GetItemPositionByIndex():
xIndex = index % itemCountPerRow
yIndex = index / itemCountPerRow
pos = firstItemPosition + (xIndex*(itemW+spacing), -yIndex*(itemH+spacing))
位置 → 索引 GetItemIndex():
yIndex = round(-pos.y / (itemH + spacing)) // 负号:往下滚 y 变小,索引变大
xIndex = (奇偶取 floor/round)(pos.x / (itemW+spacing)) + itemCountPerRow/2
return yIndex * itemCountPerRow + xIndex
firstItemPosition 在首次 SetItemsPosition 时缓存,是所有坐标推算的锚点。
对外 API(业务层常用)
| 方法 | 作用 |
|---|---|
ItemCount setter |
改数据条数,自动激活/隐藏 item 并刷新滚动条 |
InsertAt(index) / RemoveAt(index) |
插入/删除一条,只刷新受影响区间的 item |
UpdateAll() |
强制刷新当前所有可见 item |
ResetScroll() |
回滚到顶部(清 momentum、关 spring) |
Justify(pos) |
item 卡在滚动区边缘时平滑滚到合适位置 |
SetScrollByIndex(i) / SetScrollUpByIndex(i) |
跳转到指定索引(居中 / 置顶) |
ScrollByIndexAnimation(i, dur) |
带动画滚到指定索引 |
GetScrollAmount() |
返回 0~1 滚动进度 |
GetItemTransform(i) |
取某索引 item 的 Transform |
尺寸自适应(UpdateAnchors → ResetAnchors)
UpdateAnchors()每帧去重执行(updateFrame),先scrollViewPanel.UpdateAnchors()- 组件未激活时标记
isResetAnchors,等OnEnable再重置 ResetAnchors()对比finalClipRegion宽高变化:- 宽度变 → 重算
spacingPx与itemSize.x(若adjustItemWidth) - 高度变 → item 归零、
ResetPosition()、整体重排 - 最后
SetItemsPosition(..., resetAnchors: true)重建布局
- 宽度变 → 重算
滚动条同步(UpdateScrollbars)
滚动条数值手动按真实内容高度推算,非 NGUI 默认:
contentHeight = 行数 * (itemH + spacing) - spacing
再反推 slider.value 与 UIScrollBar.barSize。ShouldMoveVertically 判断内容是否高过视口,决定滚动条前景是否显示。
与 NGUI UIWrapContent 的差异
| 维度 | NGUI UIWrapContent |
自研 UIScrollViewContent |
|---|---|---|
| 布局 | 单列 | 多列网格(itemCountPerRow + 二维索引) |
| 数据操作 | 无 | InsertAt/RemoveAt/UpdateAll |
| 定位 | 无 | SetScrollByIndex/Justify/ScrollByIndexAnimation |
| 尺寸自适应 | 无 | UpdateAnchors/ResetAnchors |
| 底部留白 | 无 | bottomMargin + scrollViewContentsRegion |
| bounce 兜底 | 无 | snapHelperWidget |
| 剪裁优化 | cullContent |
item 激活/隐藏 + restrictWithinPanel |
实战示例:好友列表
纯 C# 使用 UIScrollViewContent 的完整链路。两个类:
- 容器
FriendListMenu:持有UIScrollViewContent,管数据源 - item
FriendUserItem:挂在 item prefab 上,负责单条 UI 填充
数据流
拉取好友数据 → List<FriendInfo> friendList
→ SetFriendInfos(): 排序后 scrollViewContent.ItemCount = friendList.Count
→ scrollViewContent.UpdateAll() // 触发 ItemUpdated 刷新可见 item
→ scrollViewContent.ResetScroll() // 回顶部
→ ItemUpdated → UpdateFriendItem(go, index)
→ go.GetComponent<FriendUserItem>().OnUpdateItem(friendList[index], ...)
订阅与解绑
csharp
private void Awake()
{
// 订阅数据刷新事件
scrollViewContent.ItemUpdated += UpdateFriendItem;
// 池创建完成后,批量给每个 item 绑定点击事件
scrollViewContent.OnCreateItemFinished += () =>
{
var items = scrollViewContent.GetComponentsInChildren<FriendUserItem>();
foreach (var item in items) item.OnClicked += OnItemClick;
};
}
private void OnDestroy()
{
scrollViewContent.ItemUpdated -= UpdateFriendItem;
var items = scrollViewContent.GetComponentsInChildren<FriendUserItem>();
foreach (var item in items) item.OnClicked -= OnItemClick;
}
填充数据
csharp
scrollViewContent.ItemCount = friendList.Count; // 触发激活/隐藏 + 滚动条刷新
scrollViewContent.UpdateAll(); // 刷新所有可见 item
scrollViewContent.ResetScroll(); // 回顶部
emptyWidget.cachedGameObject.SetActive(friendList.Count == 0); // 空态
ItemUpdated 回调
csharp
private void UpdateFriendItem(GameObject go, int index)
{
if (index >= friendList.Count || index < 0) return; // 越界保护
var listItem = go.GetComponent<FriendUserItem>();
listItem.OnUpdateItem(friendList[index], lastChecked, SetGiftAllButton, UpdateFriendList);
}
index 由 UIScrollViewContent 依据 item 当前位置反推,业务层只用它取数据、填到 go 上,不关心 item 对象是谁、被复用了多少次。
Item 自身填充
csharp
public void OnUpdateItem(FriendInfo info, long? lastChecked = null, ...)
{
friendInfo = info;
if (info != null)
{
portraitBox.SetPortrait(info.Avatar.GetPortraitAssetName(info.IsMe) ?? "");
portraitBox.SetPortraitFrame(info.Avatar.FrameId);
userName.text = $"[5D6977]{info.Name}[-] [8F9EB8]#{info.HashTag}[-]";
SetOnLineState(info.Online); // 在线/离线状态着色
CheckLabelWidth(); // 自适应 label 宽度
giftComponent?.SetUI(info, giftCallback, refreshListAction);
}
}
复用要点(这个例子里体现的约束)
- 越界保护 :
UpdateFriendItem首行判index越界,因为UIScrollViewContent在滚动边缘可能回传范围外索引。 - 事件在 Awake 订阅 / OnDestroy 解绑:避免 item 池复用后事件叠加或泄漏。
- Item 无状态依赖 :
OnUpdateItem每次全量重填,不缓存"上一次是谁",保证复用时显示正确。