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 每次全量重填,不缓存"上一次是谁",保证复用时显示正确。
相关推荐
c#上位机1 小时前
C#上位机项目实战——C#的dll项目编译时拷贝到指定目录下
开发语言·c#
曹牧2 小时前
C#:数组与列表的差异
开发语言·c#
何以解忧唯有撸码2 小时前
Winform上位机也能写出媲美Avalonia的界面
c#·源码·自定义控件
格林威5 小时前
C#图像快速剪切:使用OpenCvSharp和Halcon优化图像剪切和CPU占用
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
小小龙学IT7 小时前
Dear ImGui 开源即时模式 GUI 库深度解析
c++·ui·开源
xy34538 小时前
Axure9.0让条形图实现动态交互(鼠标悬停高亮 + 显示详情卡片)
ui·产品经理·原型·axure9.0
格林威8 小时前
C#图像分块处理:图像按行或按块(Tile)切分,多个 CPU 核心同时处理不同的区域
开发语言·图像处理·人工智能·机器学习·计算机视觉·c#·工业相机
雪隐9 小时前
WPF + MVVM 实战系列04-我摔了 5 次,你看着绕
c#
消费知多少10 小时前
解析勤策签约大西洋焊接费用核销实践案例
开发语言·c#