Unity UI循环列表UIScrollViewContent实现

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() 要点:

  1. 算间距:spacingPx = panel.width * spacing.x + spacing.y
  2. 算可见行数(含上下各 1 行缓冲):visibleRowCount = ceil(panel.height / (itemH + spacingPx)) + 2
  3. 预生成池:while (items.Count < visibleRowCount * itemCountPerRow) Instantiate(itemPrefab)
  4. 记录 heightAllItems(池总高,用于 wrap 判定)
  5. 创建 snapHelperWidget:右下角 2×2 隐形 widget,解决"item 数不足铺满屏时 NGUI 不 bounce"问题
  6. 创建 scrollViewContentsRegionbottomMargin > 0 时用于底部留白
  7. 触发 OnCreateItemFinished

循环滚动核心:WrapContent()

每次 OnMove(面板裁剪区移动)调用:

  1. 取滚动区中心 center(面板 4 角 → content 局部坐标 → Lerp 中点)
  2. 遍历所有激活 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)
  3. item 数铺不满一屏 → 强制 allWithinRange = false
  4. 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 宽高变化:
    • 宽度变 → 重算 spacingPxitemSize.x(若 adjustItemWidth
    • 高度变 → item 归零、ResetPosition()、整体重排
    • 最后 SetItemsPosition(..., resetAnchors: true) 重建布局

滚动条同步(UpdateScrollbars)

滚动条数值手动按真实内容高度推算,非 NGUI 默认:

复制代码
contentHeight = 行数 * (itemH + spacing) - spacing

再反推 slider.valueUIScrollBar.barSizeShouldMoveVertically 判断内容是否高过视口,决定滚动条前景是否显示。

与 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);
}

indexUIScrollViewContent 依据 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);
    }
}

复用要点(这个例子里体现的约束)

  1. 越界保护UpdateFriendItem 首行判 index 越界,因为 UIScrollViewContent 在滚动边缘可能回传范围外索引。
  2. 事件在 Awake 订阅 / OnDestroy 解绑:避免 item 池复用后事件叠加或泄漏。
  3. Item 无状态依赖OnUpdateItem 每次全量重填,不缓存"上一次是谁",保证复用时显示正确。
相关推荐
xcLeigh13 分钟前
Unity基础:使用Transform控制物体旋转——Rotate与LookAt详解
unity·教程·transform·rotate·lookat
xy34532 小时前
Axure 9.0 中继器创建与设置步骤
前端·ui·html·axure·原型·产品设计
我是苏苏2 小时前
Agent 开发实战 :C#实现语义检索!向量数据库Qdrant的下载安装及调试步骤
c#·ai编程
光泽雨2 小时前
IEnumerable<T> 详细讲解
c#·c
平行云3 小时前
3D应用推流太贵太慢?ImmerShare Lite:利用本地算力实现极简一键分享
unity·ue5·实时云渲染·云桌面·像素流·云游戏·串流
Lost of 程序猿3 小时前
.NET 10 船端单机部署与离线升级实战:从闪退事故到无感回滚
c#·asp.net·.net
淡海水4 小时前
11-04-Unity-Span-foreach-LINQ的分配与热路径边界
unity·c#·linq·foreach·span
狂人开飞机12 小时前
23、实战平台跳跃游戏
游戏·游戏引擎·godot
大蓝头14 小时前
安装包UI美化之路-借助AI快速使用nsNiuniuSkin制作安装包
ui·安装包·nsis·安装包美化·nsniuniuskin
唐青枫17 小时前
对象到底住在哪里?C#.NET 托管堆从分配、GC 到内存排查
c#·.net