AlicizaX/Client/Assets/Books/UIExtension/RecyclerView.md
2026-04-01 13:20:06 +08:00

2403 lines
89 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RecyclerView 专业手册
> 📌 本文档基于 `Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView` 的运行时 API 编写,重点说明业务侧可直接调用的能力;文中提到的 `private` / `internal` 成员仅用于解释内部机制,不属于业务层 API。
>
> ⚠️ `SetAdapter()`、`Reset()`、`Refresh()`、`RequestLayout()` 已收敛为框架内部流程;业务层请通过 `UGList`、`UGListCreateHelper`、`UGList.Data`、`Adapter.SetList()`、`Notify*()` 等正式入口驱动列表更新,不要手动调用这些底层方法。所有 UI 相关调用仍应统一放在 Unity 主线程。
>
> 💡 `RecyclerView` 的 `LayoutManager` 与 `Scroller` 通过 Inspector 序列化引用配置,业务代码通常只“读取与使用”,不建议在运行中直接替换类型。
## 目录
- [模块概览](#overview)
- [基础概念与共享示例类型](#basics)
- [容器RecyclerView](#container)
- [API 说明](#container-api)
- [初始化与刷新](#container-init)
- [定位与焦点控制](#container-focus)
- [适配器Adapter / GroupAdapter / LoopAdapter / MixedAdapter](#adapter)
- [API 说明](#adapter-api)
- [基础数据更新](#adapter-data)
- [多模板、分组与循环](#adapter-advanced)
- [布局LayoutManager 系列)](#layout)
- [API 说明](#layout-api)
- [线性与网格布局](#layout-basic)
- [分页、圆环与异构长度布局](#layout-advanced)
- [滚动器Scroller / CircleScroller](#scroller)
- [API 说明](#scroller-api)
- [程序化滚动](#scroller-programmatic)
- [惯性、吸附与自动播放](#scroller-advanced)
- [视图池ViewProvider / ObjectPool](#pool)
- [API 说明](#pool-api)
- [预热与复用](#pool-basic)
- [多模板对象池配置](#pool-mixed)
- [导航RecyclerNavigation](#navigation)
- [API 说明](#navigation-api)
- [列表内导航](#navigation-inner)
- [列表入口与焦点恢复](#navigation-entry)
- [业务包装层UGList / UGListExtensions](#uglist)
- [API 说明](#uglist-api)
- [快速创建与滚动扩展](#uglist-basic)
- [业务封装示例](#uglist-advanced)
- [场景专题](#scenarios)
- [大量列表项复用(含对象池配置)](#scenario-massive)
- [多模板混排](#scenario-mixed)
- [分页加载(加载态、空态、错误态)](#scenario-paging)
- [轮播与循环滚动(自动播放、手动切换)](#scenario-carousel)
- [手柄/键盘导航结合列表滚动](#scenario-nav-scroll)
- [FAQ](#faq)
- [Anti-patterns](#anti-patterns)
- [性能优化建议](#performance)
- [交付前检查清单](#checklist)
<a id="overview"></a>
## 模块概览
`RecyclerView` 是一套面向 Unity UGUI 的高性能列表组件,职责拆分如下:
- 容器:`RecyclerView` 负责模板、可见区、滚动同步、滚动条、焦点恢复。
- 适配器:`Adapter<T>` 家族负责数据量、模板选择、视图绑定、局部刷新。
- 布局:`LayoutManager` 家族负责位置计算、可见区间与索引转换。
- 滚动器:`Scroller` 家族负责拖拽、滚轮、惯性、吸附与平滑滚动。
- 视图池:`ViewProvider` + `ObjectPool` 负责创建、回收、预热与统计。
- 导航:`RecyclerNavigationController` 家族负责手柄/键盘在列表内的可达性。
- 业务包装层:`UGList` 家族负责降低泛型与注册样板代码。
> 💡 一般调用顺序是:准备模板与布局 → 创建业务包装层/适配器包装 → 注册 `ItemRender` → 赋值数据;后续布局计算、池预热与首屏刷新由框架自动完成。
> ⚠️ 如果 `Templates` 为空、`LayoutManager` 未配置或 `Content` 解析失败,`RecyclerView` 会在首次运行时抛出错误或记录错误日志。
<a id="basics"></a>
## 基础概念与共享示例类型
本节给出后续示例默认共用的最小类型。除非某个示例额外声明了自己的 `Data` / `Holder` / `Render`,否则都可以直接与以下代码一起编译。
### 示例基础类型
#### 共享类型代码
```csharp
using AlicizaX.UI;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace RecyclerViewBookSamples
{
[System.Serializable]
public sealed class DemoSimpleData : ISimpleViewData
{
public string Title;
public string Subtitle;
public Sprite Icon;
}
[System.Serializable]
public sealed class DemoMixedData : IMixedViewData
{
public string TemplateName { get; set; } // 必须与模板名完全一致
public string Title;
public string Subtitle;
public Sprite Icon;
}
[System.Serializable]
public sealed class DemoGroupData : IGroupViewData
{
public string TemplateName { get; set; } // 组头与普通项共用此字段
public bool Expanded { get; set; } // 组头展开状态
public int Type { get; set; } // 分组键
public string Title;
}
public sealed class DemoItemHolder : ViewHolder
{
public Text title;
public Text subtitle;
public Image icon;
public Button actionButton;
public GameObject selectedMarker;
}
public sealed class DemoStateHolder : ViewHolder
{
public Text message;
public Button retryButton;
}
public sealed class DemoSimpleRender : ItemRender<DemoSimpleData, DemoItemHolder>
{
protected override void OnBind(DemoSimpleData data, int index)
{
Holder.title.text = $"{index + 1}. {data.Title}"; // 绑定标题
Holder.subtitle.text = data.Subtitle; // 绑定副标题
Holder.icon.sprite = data.Icon; // 绑定图标
}
protected override void OnSelectionChanged(bool selected)
{
if (Holder.selectedMarker != null)
{
Holder.selectedMarker.SetActive(selected); // 显示选中态
}
}
}
public sealed class DemoMixedRender : ItemRender<DemoMixedData, DemoItemHolder>
{
protected override void OnBind(DemoMixedData data, int index)
{
Holder.title.text = $"{index + 1}. {data.Title}"; // 绑定标题
Holder.subtitle.text = data.Subtitle; // 绑定副标题
Holder.icon.sprite = data.Icon; // 绑定图标
}
}
}
```
> 📌 所有示例都默认你已经在 Inspector 中把模板预制体上的 `Text`、`Image`、`Button` 等引用拖好。
<a id="container"></a>
## 容器RecyclerView
`RecyclerView` 是系统的中心节点:它管理模板、内容区域、当前适配器、布局、滚动器、滚动条以及焦点导航。
<a id="container-api"></a>
### API 说明
#### 类型:`RecyclerView`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/RecyclerView.cs`
**属性**
| 成员名 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Direction` | `Direction` | `Direction.Vertical`(未序列化时) | 主滚动方向。 | 与 `LayoutManager`、`Scroller` 的方向必须一致。 |
| `Alignment` | `Alignment` | `Alignment.Left`(未序列化时) | 交叉轴对齐方式。 | `Alignment.Center` 会影响内容偏移计算。 |
| `Spacing` | `Vector2` | `Vector2.zero` | Item 间距。 | 竖向列表通常使用 `y`;横向列表通常使用 `x`。 |
| `Padding` | `Vector2` | `Vector2.zero` | 内容内边距。 | 只影响布局计算,不会自动改模板尺寸。 |
| `Scroll` | `bool` | `false` | 是否启用滚动。 | 关闭后 `ScrollTo*()` 直接失效。 |
| `Snap` | `bool` | `false` | 是否在停下后吸附到最近项。 | 只有 `Scroll == true` 时才会真正生效。 |
| `ScrollSpeed` | `float` | `7f` | 平滑滚动速度。 | 建议保持在 `0.5f ~ 50f`。 |
| `WheelSpeed` | `float` | `30f` | 滚轮速度。 | 建议保持在 `1f ~ 50f`。 |
| `ShowScrollBarOnlyWhenScrollable` | `bool` | `false` | 是否仅在内容溢出时显示滚动条。 | 仅对横向/纵向方向生效。 |
| `Templates` | `ViewHolder[]` | `null` | 视图模板集合。 | 不能为空;多模板时模板名必须唯一。 |
| `Content` | `RectTransform` | `null`,首次访问时尝试取第一个子节点 | 内容容器。 | 第一个子节点必须有 `RectTransform`。 |
| `Scrollbar` | `Scrollbar` | `null` | 关联滚动条。 | 仅在启用滚动条时使用。 |
| `Scroller` | `Scroller` | `null` | 当前滚动器实例。 | 通常由 Inspector 指定为 `Scroller``CircleScroller`。 |
| `ViewProvider` | `ViewProvider` | 懒加载 | 模板创建/回收提供器。 | 依赖 `Templates` 正常初始化。 |
| `PoolStats` | `string` | `string.Empty` | 当前视图池统计文本。 | 仅用于诊断,格式不保证长期稳定。 |
| `LayoutManager` | `LayoutManager` | `null` | 当前布局实例。 | 必须先配置,否则列表初始化时无法完成绑定。 |
| `NavigationController` | `RecyclerNavigationController` | 懒加载 | 列表导航控制器。 | 仅在导航场景下用到。 |
**事件**
| 事件名 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `OnIndexChanged` | `Action<int>` | `null` | 当前逻辑索引变化时触发。 | 对循环列表返回的是“真实索引”。 |
| `OnScrollValueChanged` | `Action` | `null` | 滚动位置变化时触发。 | 高频事件,不要在回调里做重活。 |
> 💡 业务层如需感知当前焦点项,请监听 `OnIndexChanged`;如需读写业务选中态,请使用 `Adapter.ChoiceIndex`。
**方法**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `TryFocusIndex(int index, bool smooth = false, ScrollAlignment alignment = ScrollAlignment.Center)` | `bool` | `smooth=false`, `alignment=Center` | 滚动并聚焦目标项。 | 目标索引必须在适配器合法范围内。 |
| `TryFocusEntry(MoveDirection entryDirection)` | `bool` | 无 | 从外部把焦点“送入”列表。 | 空列表返回 `false`。 |
| `GetScrollPosition()` | `float` | 无 | 获取当前滚动偏移。 | 没有滚动器时返回 `0`。 |
| `ScrollTo(int index, bool smooth = false)` | `void` | `smooth=false` | 滚到指定项。 | 依赖 `Scroll == true``Scroller != null`。 |
| `ScrollToWithAlignment(int index, ScrollAlignment alignment, float offset = 0f, bool smooth = false, float duration = 0.3f)` | `void` | `offset=0f`, `smooth=false`, `duration=0.3f` | 以指定对齐方式滚动到目标项。 | `duration` 仅在 `smooth=true` 时有意义。 |
#### 参数:`TryFocusIndex(int index, bool smooth = false, ScrollAlignment alignment = ScrollAlignment.Center)`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `index` | `int` | 无 | 目标数据索引。 |
| `smooth` | `bool` | `false` | 是否先平滑滚动到可见区再聚焦。 |
| `alignment` | `ScrollAlignment` | `ScrollAlignment.Center` | 聚焦时目标项的对齐方式。 |
- 返回值:`bool`,成功定位并设置焦点时为 `true`
- 使用限制:空列表、越界索引或没有可聚焦对象都会返回 `false`
#### 参数:`ScrollToWithAlignment(...)`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `index` | `int` | 无 | 目标索引。 |
| `alignment` | `ScrollAlignment` | 无 | 目标项停靠位置。 |
| `offset` | `float` | `0f` | 在对齐结果基础上的附加偏移。 |
| `smooth` | `bool` | `false` | 是否平滑滚动。 |
| `duration` | `float` | `0.3f` | 平滑滚动时长。 |
- 返回值:无。
- 使用限制:`Scroll == false`、`Scroller == null` 或目标索引非法时无效果。
> ⚠️ `ScrollTo()` / `TryFocusIndex()` 依赖列表已通过 `UGList.Data`、`Adapter.SetList()` 或 `Notify*()` 完成一次正式更新;布局与刷新会随这些入口自动完成,业务层不要额外手动补 `RequestLayout()` / `Refresh()`。
<a id="container-init"></a>
### 初始化与刷新
#### 示例 1基础纵向列表初始化
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class BasicRecyclerViewDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: 绑定 LinearLayoutManager + Scroller
private UGList<DemoSimpleData> list;
private void Awake()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>(); // 注册默认模板渲染器
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "邮件", Subtitle = "今天 09:00" },
new DemoSimpleData { Title = "任务", Subtitle = "今天 10:30" },
new DemoSimpleData { Title = "公告", Subtitle = "今天 14:00" },
};
}
}
```
#### 示例 2异步拉取后再刷新容器
```csharp
using System.Collections;
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class AsyncRecyclerViewDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: 模板与 LayoutManager 已配置
private UGList<DemoSimpleData> list;
private Adapter<DemoSimpleData> adapter;
private IEnumerator Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
adapter = list.Adapter;
yield return new WaitForSeconds(0.5f); // 模拟网络请求
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "远程数据 A", Subtitle = "加载完成" },
new DemoSimpleData { Title = "远程数据 B", Subtitle = "加载完成" },
}; // 赋值后框架自动布局并刷新
}
}
```
#### 示例 3空数据与安全刷新
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class EmptyStateRecyclerViewDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private void OnEnable()
{
list ??= UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>(); // 空列表也是合法输入
bool focused = recyclerView.TryFocusIndex(0); // 空列表会返回 false
Debug.Log($"Focus result: {focused}");
}
}
```
<a id="container-focus"></a>
### 定位与焦点控制
#### 示例 1按钮驱动滚动到指定项
```csharp
using AlicizaX.UI;
using UnityEngine;
using UnityEngine.UI;
public sealed class ScrollToIndexDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
[SerializeField] private Button jumpButton;
private void Awake()
{
jumpButton.onClick.AddListener(() =>
{
recyclerView.ScrollToWithAlignment(
index: 10,
alignment: ScrollAlignment.Center,
offset: 0f,
smooth: true,
duration: 0.25f); // 以 0.25 秒平滑滚到中间
});
}
}
```
#### 示例 2打开面板时恢复焦点
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class FocusRecoveryDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
[SerializeField] private int lastSelectedIndex = 4;
private void OnEnable()
{
recyclerView.TryFocusIndex(lastSelectedIndex, true, ScrollAlignment.Center);
}
}
```
#### 示例 3监听焦点索引变化
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class FocusTraceDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private void Awake()
{
recyclerView.OnIndexChanged += index =>
Debug.Log($"Focused data index: {index}");
}
}
```
<a id="adapter"></a>
## 适配器Adapter / GroupAdapter / LoopAdapter / MixedAdapter
适配器负责把数据对象映射为模板名、绑定流程和局部刷新操作,是业务层最常直接扩展的模块。
<a id="adapter-api"></a>
### API 说明
#### 类型:`Adapter<T>``where T : ISimpleViewData`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/Adapter/Adapter.cs`
**构造与状态**
| 成员名 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Adapter(RecyclerView recyclerView)` | 构造函数 | `list = new List<T>()` | 创建空适配器。 | `recyclerView` 不能为空。 |
| `Adapter(RecyclerView recyclerView, List<T> list)` | 构造函数 | `list ?? new List<T>()` | 创建带初始数据的适配器。 | 传入 `null` 会被替换为空列表。 |
| `ChoiceIndex` | `int` | `-1` | 当前选择索引。 | 超出范围会被自动收敛。 |
**查询与绑定**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GetItemCount()` | `int` | 无 | 当前布局项数量。 | 普通列表等于数据量;循环列表可能不是。 |
| `GetRealCount()` | `int` | 无 | 真实数据量。 | 循环列表场景优先用它。 |
| `GetViewName(int index)` | `string` | 无 | 返回模板名。 | 单模板默认返回空字符串。 |
| `OnBindViewHolder(ViewHolder viewHolder, int index)` | `void` | 无 | 绑定 Holder。 | 必须先注册与模板匹配的 `ItemRender`。 |
| `OnRecycleViewHolder(ViewHolder viewHolder)` | `void` | 无 | 回收前清理绑定。 | 业务层通常无需手动调用。 |
| `GetData(int index)` | `T` | 无 | 返回数据项。 | 越界返回 `default`。 |
**刷新通知**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `NotifyDataChanged()` | `void` | 无 | 全量刷新。 | 代价最高。 |
| `SetList(List<T> list)` | `void` | 无 | 替换整个数据源。 | 会触发 `RecyclerView.Reset()`。 |
| `NotifyItemChanged(int index, bool relayout = false)` | `void` | `relayout=false` | 刷新单项。 | 仅当项尺寸不变时推荐 `relayout=false`。 |
| `NotifyItemRangeChanged(int index, int count, bool relayout = false)` | `void` | `relayout=false` | 刷新区间。 | `count <= 0` 直接返回。 |
| `NotifyItemInserted(int index)` | `void` | 无 | 通知插入。 | 当前实现会重新请求布局并刷新。 |
| `NotifyItemRangeInserted(int index, int count)` | `void` | 无 | 通知批量插入。 | `count <= 0` 直接返回。 |
| `NotifyItemRemoved(int index)` | `void` | 无 | 通知删除。 | 当前实现会重新请求布局并刷新。 |
| `NotifyItemRangeRemoved(int index, int count)` | `void` | 无 | 通知批量删除。 | `count <= 0` 直接返回。 |
**渲染器注册**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `RegisterItemRender<TItemRender>(string viewName = "")` | `void` | `viewName=""` | 以泛型注册渲染器。 | 空字符串表示默认模板。 |
| `RegisterItemRender(Type itemRenderType, string viewName = "")` | `void` | `viewName=""` | 以运行时类型注册渲染器。 | 类型必须继承 `ItemRenderBase`。 |
| `UnregisterItemRender(string viewName = "")` | `bool` | `viewName=""` | 注销渲染器。 | 注销后相关缓存会被释放。 |
| `ClearItemRenderRegistrations()` | `void` | 无 | 清空所有注册。 | 适合切换整套模板时使用。 |
**集合操作**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Add(T item)` | `void` | 无 | 末尾追加。 | 自动触发插入通知。 |
| `AddRange(IEnumerable<T> collection)` | `void` | 无 | 批量追加。 | `collection == null` 时忽略。 |
| `Insert(int index, T item)` | `void` | 无 | 指定位置插入。 | 越界由 `List<T>` 自己抛错。 |
| `InsertRange(int index, IEnumerable<T> collection)` | `void` | 无 | 批量插入。 | `collection == null` 时忽略。 |
| `Remove(T item)` | `void` | 无 | 删除首个匹配项。 | 找不到时等价于 `RemoveAt(-1)`,最终无效果。 |
| `RemoveAt(int index)` | `void` | 无 | 删除指定索引。 | 越界直接返回。 |
| `RemoveRange(int index, int count)` | `void` | 无 | 删除区间。 | 参数必须满足 `List<T>.RemoveRange` 要求。 |
| `RemoveAll(Predicate<T> match)` | `void` | 无 | 条件删除。 | 始终触发全量刷新。 |
| `Clear()` | `void` | 无 | 清空列表。 | 空列表时直接返回。 |
| `Reverse()` / `Reverse(int index, int count)` | `void` | 无 | 反转顺序。 | 最终触发全量刷新。 |
| `Sort(Comparison<T> comparison)` | `void` | 无 | 排序。 | 最终触发全量刷新。 |
#### 类型:`GroupAdapter<TData>``where TData : IGroupViewData, new()`
| 方法/成员 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GroupAdapter(RecyclerView recyclerView, string groupViewName)` | 构造函数 | 无 | 指定组头模板名。 | `NotifyDataChanged()` 前必须传非空模板名。 |
| `Expand(int index)` | `void` | 无 | 展开组头后的子项。 | `index` 必须指向组头。 |
| `Collapse(int index)` | `void` | 无 | 收起某组。 | 不会改原始 `list`,只改展示列表。 |
| `Activate(int index)` | `void` | 无 | 组头时切换展开;普通项时切换选中。 | 常用于点击事件。 |
#### 类型:`LoopAdapter<T>`
| 方法/成员 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GetItemCount()` | `int` | 无 | 有数据时返回 `int.MaxValue`。 | 只适合循环/轮播场景。 |
| `GetRealCount()` | `int` | 无 | 返回真实数据量。 | 业务逻辑应优先使用此值。 |
| `OnBindViewHolder(...)` | `void` | 无 | 绑定时对索引做取模。 | 数据为空时不绑定。 |
#### 类型:`MixedAdapter<TData>``where TData : IMixedViewData`
| 方法/成员 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GetViewName(int index)` | `string` | 无 | 返回 `TemplateName`。 | 模板名必须与 `Templates[].name` 一致。 |
#### 参数:`RegisterItemRender(Type itemRenderType, string viewName = "")`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `itemRenderType` | `Type` | 无 | 渲染器运行时类型。 |
| `viewName` | `string` | `""` | 关联模板名;空字符串表示默认模板。 |
- 返回值:无。
- 使用限制:类型必须可实例化,且继承自 `ItemRenderBase`
#### 参数:`NotifyItemChanged(int index, bool relayout = false)`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `index` | `int` | 无 | 目标数据索引。 |
| `relayout` | `bool` | `false` | 是否重新计算布局。 |
- 返回值:无。
- 使用限制:如果文本高度、图片尺寸、模板类型发生变化,应该传 `relayout=true`
<a id="adapter-data"></a>
### 基础数据更新
#### 示例 1通过 `UGList.Adapter` 进行增删改
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class AdapterCrudDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private Adapter<DemoSimpleData> adapter;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>();
adapter = list.Adapter;
adapter.Add(new DemoSimpleData { Title = "第一条", Subtitle = "Add()" }); // 自动触发插入
adapter.Add(new DemoSimpleData { Title = "第二条", Subtitle = "Add()" });
adapter.Insert(1, new DemoSimpleData { Title = "插入项", Subtitle = "Insert()" });
}
}
```
#### 示例 2差量刷新单项与区间
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class AdapterPartialRefreshDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private Adapter<DemoSimpleData> adapter;
private readonly List<DemoSimpleData> data = new();
private void Start()
{
data.Add(new DemoSimpleData { Title = "HP", Subtitle = "100" });
data.Add(new DemoSimpleData { Title = "MP", Subtitle = "60" });
data.Add(new DemoSimpleData { Title = "SP", Subtitle = "30" });
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = data;
adapter = list.Adapter;
}
public void UpdateHud()
{
data[0].Subtitle = "95";
adapter.NotifyItemChanged(0); // 尺寸不变,只重绑可见项
data[1].Subtitle = "58";
data[2].Subtitle = "28";
adapter.NotifyItemRangeChanged(1, 2); // 批量局部刷新
}
}
```
#### 示例 3尺寸变化时强制重布局
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class AdapterRelayoutDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private Adapter<DemoSimpleData> adapter;
private readonly List<DemoSimpleData> data = new();
private void Start()
{
data.Add(new DemoSimpleData { Title = "短文本", Subtitle = "1 行" });
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = data;
adapter = list.Adapter;
}
public void ExpandText()
{
data[0].Subtitle = "这一条文本被拉长后可能导致高度变化,因此需要 relayout=true。";
adapter.NotifyItemChanged(0, relayout: true); // 尺寸变化时必须重布局
}
}
```
<a id="adapter-advanced"></a>
### 多模板、分组与循环
#### 示例 1多模板注册与绑定
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class DemoLargeHolder : ViewHolder
{
public UnityEngine.UI.Text title;
public UnityEngine.UI.Text subtitle;
}
public sealed class DemoLargeRender : ItemRender<DemoMixedData, DemoLargeHolder>
{
protected override void OnBind(DemoMixedData data, int index)
{
Holder.title.text = $"[L] {data.Title}"; // 大卡片模板
Holder.subtitle.text = data.Subtitle;
}
}
public sealed class MixedAdapterDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: Templates 含 DemoItemHolder 与 DemoLargeHolder
private UGMixedList<DemoMixedData> list;
private void Start()
{
list = UGListCreateHelper.CreateMixed<DemoMixedData>(recyclerView);
list.RegisterItemRender<DemoMixedRender>("SmallItem"); // 绑定小模板
list.RegisterItemRender<DemoLargeRender>("LargeItem"); // 绑定大模板
list.Data = new List<DemoMixedData>
{
new DemoMixedData { TemplateName = "LargeItem", Title = "头图", Subtitle = "大卡片" },
new DemoMixedData { TemplateName = "SmallItem", Title = "正文 A", Subtitle = "小卡片" },
new DemoMixedData { TemplateName = "SmallItem", Title = "正文 B", Subtitle = "小卡片" },
};
}
}
```
#### 示例 2分组展开/收起
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class GroupHeaderRender : ItemRender<DemoGroupData, DemoItemHolder>
{
protected override void OnBind(DemoGroupData data, int index)
{
Holder.title.text = data.Expanded ? $"▼ {data.Title}" : $"▶ {data.Title}";
Holder.subtitle.text = $"Type = {data.Type}";
}
}
public sealed class GroupItemRender : ItemRender<DemoGroupData, DemoItemHolder>
{
protected override void OnBind(DemoGroupData data, int index)
{
Holder.title.text = data.Title;
Holder.subtitle.text = $"Group {data.Type}";
}
}
public sealed class GroupAdapterDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGGroupList<DemoGroupData> list;
private void Start()
{
list = UGListCreateHelper.CreateGroup<DemoGroupData>(recyclerView, "GroupHeader");
list.RegisterItemRender<GroupHeaderRender>("GroupHeader");
list.RegisterItemRender<GroupItemRender>("GroupItem");
list.Data = new List<DemoGroupData>
{
new DemoGroupData { TemplateName = "GroupItem", Type = 1, Title = "剑" },
new DemoGroupData { TemplateName = "GroupItem", Type = 1, Title = "盾" },
new DemoGroupData { TemplateName = "GroupItem", Type = 2, Title = "药水" },
};
}
public void ToggleFirstGroup()
{
list.Adapter.Activate(0); // 组头位置会切换 Expanded
}
}
```
#### 示例 3循环适配器的虚拟索引
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class LoopAdapterDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGLoopList<DemoSimpleData> list;
private int virtualIndex;
private void Start()
{
list = UGListCreateHelper.CreateLoop<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "Banner A" },
new DemoSimpleData { Title = "Banner B" },
new DemoSimpleData { Title = "Banner C" },
};
virtualIndex = list.Adapter.GetRealCount() * 100; // 从中间起步,便于双向循环
recyclerView.ScrollToWithAlignment(virtualIndex, ScrollAlignment.Center);
}
public void MoveNext()
{
virtualIndex += 1; // 维护虚拟索引,而不是只用真实索引
recyclerView.ScrollToWithAlignment(virtualIndex, ScrollAlignment.Center, 0f, true, 0.2f);
}
}
```
<a id="layout"></a>
## 布局LayoutManager 系列)
布局器只负责“算位置”,不负责数据绑定。它决定内容尺寸、可见区间、索引到坐标的映射以及吸附基准。
<a id="layout-api"></a>
### API 说明
#### 类型:`LayoutManager`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/Layout/LayoutManager.cs`
**核心属性**
| 成员名 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `ViewportSize` | `Vector2` | 运行时计算 | 视口尺寸。 | 只有执行过 `SetContentSize()` 后才可靠。 |
| `ContentSize` | `Vector2` | 运行时计算 | 内容总尺寸。 | 滚动条与滚动范围都依赖此值。 |
| `ContentOffset` | `Vector2` | 运行时计算 | 内容对齐偏移。 | 与 `Alignment` 有关。 |
| `ViewportOffset` | `Vector2` | 运行时计算 | 视口偏移。 | 分页/居中场景常用。 |
| `Adapter` | `IAdapter` | `null` | 当前适配器。 | 由容器内部绑定流程注入。 |
| `ViewProvider` | `ViewProvider` | `null` | 当前视图提供器。 | 由容器内部绑定流程注入。 |
| `RecyclerView` | `RecyclerView` | `null` | 所属列表。 | 由容器内部绑定流程注入。 |
| `Direction` | `Direction` | `Direction.Vertical` | 主轴方向。 | 应与容器一致。 |
| `Alignment` | `Alignment` | `Alignment.Left` | 对齐方式。 | `Center` 会改变偏移计算。 |
| `Spacing` | `Vector2` | `Vector2.zero` | 间距。 | 按方向使用对应轴。 |
| `Padding` | `Vector2` | `Vector2.zero` | 内边距。 | 直接参与内容尺寸计算。 |
| `Unit` | `int` | `1` | 每次创建/回收的步进量。 | 网格通常等于一行或一列的元素数。 |
| `ScrollPosition` | `float` | 运行时读取 | 当前滚动位置。 | 只读,来自 `RecyclerView.GetScrollPosition()`。 |
**通用方法**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `SetContentSize()` | `void` | 无 | 计算视口、内容、偏移。 | 由容器内部布局流程调用。 |
| `UpdateLayout()` | `void` | 无 | 重新布局当前可见 Holder。 | 仅操作已创建视图。 |
| `Layout(ViewHolder viewHolder, int index)` | `void` | 无 | 将指定 Holder 摆放到目标位置。 | 默认按 `CalculatePosition()` 放置。 |
| `CalculateContentSize()` | `Vector2` | 无 | 计算内容总尺寸。 | 由具体布局实现。 |
| `CalculatePosition(int index)` | `Vector2` | 无 | 计算指定索引位置。 | 由具体布局实现。 |
| `CalculateContentOffset()` | `Vector2` | 无 | 计算内容偏移。 | 与对齐方式强相关。 |
| `CalculateViewportOffset()` | `Vector2` | 无 | 计算视口偏移。 | 分页与居中布局更明显。 |
| `GetStartIndex()` / `GetEndIndex()` | `int` | 无 | 当前滚动位置的可见区间。 | `end < start` 时表示当前无需创建项。 |
| `IndexToPosition(int index)` | `float` | 无 | 索引转滚动位置。 | 吸附与 `ScrollTo` 都依赖它。 |
| `PositionToIndex(float position)` | `int` | 无 | 滚动位置转最近索引。 | 吸附时用来找最近项。 |
| `DoItemAnimation()` | `void` | 无 | 执行布局相关动画。 | `PageLayoutManager`、`CircleLayoutManager` 会覆写。 |
#### 类型:布局派生类
| 类型 | 主要特性 | 默认值 | 使用限制 |
| --- | --- | --- | --- |
| `LinearLayoutManager` | 等尺寸线性布局。 | 无额外字段 | 适合单列/单行列表。 |
| `GridLayoutManager` | 网格布局。 | `cellCount = 1` | `cellCount` 通过 Inspector 配置。 |
| `PageLayoutManager` | 分页式线性布局,带缩放动画。 | `minScale = 0.9f` | 常与 `Snap` 联用。 |
| `CircleLayoutManager` | 圆环布局。 | `circleDirection = Positive` | `intervalAngle` 在运行时按项数重算。 |
| `MixedLayoutManager` | 支持不同模板尺寸的异构布局。 | 内部缓存为空 | 最适合多模板或变高度列表。 |
#### 参数:`IndexToPosition(int index)`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `index` | `int` | 无 | 目标布局索引。 |
- 返回值:`float`,滚动器目标位置。
- 使用限制:越界时通常被实现类收敛到合法范围,但业务层仍应传递有效索引。
<a id="layout-basic"></a>
### 线性与网格布局
#### 示例 1线性列表
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class LinearLayoutDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LayoutManager = LinearLayoutManager
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "线性列表 1" },
new DemoSimpleData { Title = "线性列表 2" },
new DemoSimpleData { Title = "线性列表 3" },
};
}
}
```
#### 示例 2网格背包
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class GridInventoryDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LayoutManager = GridLayoutManager, cellCount = 4
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
var items = new List<DemoSimpleData>();
for (int i = 0; i < 20; i++)
{
items.Add(new DemoSimpleData { Title = $"格子 {i + 1}", Subtitle = $"Index={i}" });
}
list.Data = items;
}
}
```
#### 示例 3空网格的边界结果
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class EmptyGridDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private void Start()
{
var list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>(); // 空网格
}
}
```
<a id="layout-advanced"></a>
### 分页、圆环与异构长度布局
#### 示例 1分页卡片
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class PageLayoutDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LayoutManager = PageLayoutManager, Snap = true
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "Page 1" },
new DemoSimpleData { Title = "Page 2" },
new DemoSimpleData { Title = "Page 3" },
};
}
}
```
#### 示例 2圆环菜单
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class CircleMenuDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LayoutManager = CircleLayoutManager, Scroller = CircleScroller
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "装备" },
new DemoSimpleData { Title = "技能" },
new DemoSimpleData { Title = "任务" },
new DemoSimpleData { Title = "地图" },
};
}
}
```
#### 示例 3异构长度列表
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class MixedLayoutDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LayoutManager = MixedLayoutManager
private UGMixedList<DemoMixedData> list;
private void Start()
{
list = UGListCreateHelper.CreateMixed<DemoMixedData>(recyclerView);
list.RegisterItemRender<DemoMixedRender>("SmallItem");
list.RegisterItemRender<DemoMixedRender>("LargeItem"); // 两个模板尺寸不同
list.Data = new List<DemoMixedData>
{
new DemoMixedData { TemplateName = "LargeItem", Title = "长卡片" },
new DemoMixedData { TemplateName = "SmallItem", Title = "短卡片 A" },
new DemoMixedData { TemplateName = "SmallItem", Title = "短卡片 B" },
};
}
}
```
<a id="scroller"></a>
## 滚动器Scroller / CircleScroller
滚动器负责手势输入与位移变化;布局负责“每个位移意味着什么”。二者解耦后,滚动和显示方式可以灵活组合。
<a id="scroller-api"></a>
### API 说明
#### 类型:`Scroller`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/Scroller/Scroller.cs`
**属性**
| 成员名 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Position` | `float` | `0f` | 当前滚动位置。 | 直接赋值不会自动刷新 UI通常由 `RecyclerView` 驱动。 |
| `Velocity` | `float` | 只读 | 当前速度。 | 仅用于诊断或自定义效果。 |
| `Direction` | `Direction` | `Direction.Vertical` | 滚动方向。 | 应与容器方向一致。 |
| `ContentSize` | `Vector2` | `Vector2.zero` | 内容尺寸。 | 由容器内部布局流程同步更新。 |
| `ViewSize` | `Vector2` | `Vector2.zero` | 视口尺寸。 | 由容器内部布局流程同步更新。 |
| `ScrollSpeed` | `float` | `1f` | `MoveTo` 速度。 | 外部通常通过 `RecyclerView.ScrollSpeed` 配置。 |
| `WheelSpeed` | `float` | `30f` | 滚轮速度。 | 过大容易导致过冲。 |
| `Snap` | `bool` | `false` | 是否启用吸附模式。 | 吸附由容器在停止时完成。 |
| `MaxPosition` | `float` | 只读 | 最大合法滚动位置。 | 由内容和视口尺寸计算。 |
| `ViewLength` | `float` | 只读 | 视口主轴长度。 | 用于边缘阻尼。 |
| `OnValueChanged` | `ScrollerEvent` | 新实例 | 位移变化事件。 | 高频事件。 |
| `OnMoveStoped` | `MoveStopEvent` | 新实例 | 惯性/动画停止事件。 | 拼写保留源码 `Stoped`。 |
| `OnDragging` | `DraggingEvent` | 新实例 | 拖拽开始/结束事件。 | 参数为是否正在拖拽。 |
| `dragStopTime` | `float` | `0f` | 最近一次拖拽时间戳。 | 通常仅供调试。 |
**方法**
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `ScrollTo(float position, bool smooth = false)` | `void` | `smooth=false` | 滚到目标位置。 | 不主动夹取范围,推荐经由 `RecyclerView` 调用。 |
| `ScrollToDuration(float position, float duration)` | `void` | 无 | 按固定时长滚动。 | `duration <= 0` 时立即到位。 |
| `ScrollToRatio(float ratio)` | `void` | 无 | 以归一化滚动条值滚动。 | 通常由滚动条驱动。 |
| `OnBeginDrag(...)` / `OnDrag(...)` / `OnEndDrag(...)` | `void` | 无 | 处理拖拽输入。 | 通常由 Unity 事件系统自动调用。 |
| `OnScroll(PointerEventData eventData)` | `void` | 无 | 处理鼠标滚轮。 | 横向列表读取 `scrollDelta.x`。 |
#### 类型:`CircleScroller`
| 方法/成员 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GetDelta(PointerEventData eventData)` | `float` | 无 | 根据环形中心计算拖拽方向。 | 仅适合圆环布局。 |
| `Elastic()` | `void` | 无 | 被覆写为空实现。 | 圆环一般不需要边界回弹。 |
#### 参数:`ScrollTo(float position, bool smooth = false)`
| 参数名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `position` | `float` | 无 | 目标滚动位置。 |
| `smooth` | `bool` | `false` | 是否使用平滑补间。 |
- 返回值:无。
- 使用限制:该方法不会主动把 `position` 夹到 `0 ~ MaxPosition`,需要上层自行保证。
<a id="scroller-programmatic"></a>
### 程序化滚动
#### 示例 1立即滚动与平滑滚动
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class ProgrammaticScrollDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
public void JumpToTop()
{
recyclerView.ScrollTo(0, smooth: false); // 立即跳到顶部
}
public void SmoothToMiddle()
{
recyclerView.ScrollToWithAlignment(15, ScrollAlignment.Center, 0f, true, 0.35f);
}
}
```
#### 示例 2按固定时长滚动
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class TimedScrollDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
public void ScrollByDuration()
{
if (recyclerView.Scroller == null)
{
return;
}
float target = Mathf.Min(recyclerView.Scroller.MaxPosition, 320f);
recyclerView.Scroller.ScrollToDuration(target, 0.5f); // 0.5 秒到位
}
}
```
#### 示例 3无滚动能力时的防御式调用
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class ScrollGuardDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
public void TryScroll()
{
if (!recyclerView.Scroll || recyclerView.Scroller == null)
{
Debug.Log("Scroll disabled, ignore request."); // 避免空滚动器导致误判
return;
}
recyclerView.ScrollToWithAlignment(2, ScrollAlignment.Start);
}
}
```
<a id="scroller-advanced"></a>
### 惯性、吸附与自动播放
#### 示例 1分页列表的吸附体验
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class SnapPageDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: Snap = true, LayoutManager = PageLayoutManager
private void Awake()
{
recyclerView.Scroll = true; // 启用滚动
recyclerView.Snap = true; // 停止时自动吸附最近项
recyclerView.ScrollSpeed = 10f; // 提高分页吸附速度
}
}
```
#### 示例 2自动播放轮播
```csharp
using System.Collections;
using AlicizaX.UI;
using UnityEngine;
public sealed class AutoPlayDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private int nextIndex;
private Coroutine autoPlayCoroutine;
private void OnEnable()
{
autoPlayCoroutine = StartCoroutine(AutoPlay());
}
private void OnDisable()
{
if (autoPlayCoroutine != null)
{
StopCoroutine(autoPlayCoroutine); // 面板关闭时停止自动滚动
}
}
private IEnumerator AutoPlay()
{
while (true)
{
yield return new WaitForSeconds(3f);
recyclerView.ScrollToWithAlignment(nextIndex, ScrollAlignment.Center, 0f, true, 0.25f);
nextIndex += 1; // 循环列表可持续递增
}
}
}
```
#### 示例 3手动上一页 / 下一页
```csharp
using AlicizaX.UI;
using UnityEngine;
using UnityEngine.UI;
public sealed class PagerButtonsDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
[SerializeField] private Button prevButton;
[SerializeField] private Button nextButton;
private int currentIndex;
private void Awake()
{
recyclerView.OnIndexChanged += index => currentIndex = Mathf.Max(index, 0);
prevButton.onClick.AddListener(() =>
{
recyclerView.ScrollToWithAlignment(Mathf.Max(currentIndex - 1, 0), ScrollAlignment.Center, 0f, true, 0.2f);
});
nextButton.onClick.AddListener(() =>
{
recyclerView.ScrollToWithAlignment(currentIndex + 1, ScrollAlignment.Center, 0f, true, 0.2f);
});
}
}
```
<a id="pool"></a>
## 视图池ViewProvider / ObjectPool
视图池是 `RecyclerView` 性能表现的核心:它让“大列表”只保留可见区对象,其他对象进入可复用池而不是销毁重建。
<a id="pool-api"></a>
### API 说明
#### 类型:`ViewProvider`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/ViewProvider/ViewProvider.cs`
| 方法/成员 | 返回值/类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Adapter` | `IAdapter` | `null` | 当前适配器。 | 由容器注入。 |
| `LayoutManager` | `LayoutManager` | `null` | 当前布局器。 | 由容器注入。 |
| `ViewHolders` | `IReadOnlyList<ViewHolder>` | 空集合 | 当前可见 Holder 集。 | 只读视图。 |
| `PoolStats` | `string` | 抽象成员 | 对象池统计。 | 用于诊断。 |
| `GetTemplate(string viewName)` | `ViewHolder` | 无 | 获取模板。 | 多模板时名字必须命中。 |
| `GetTemplates()` | `ViewHolder[]` | 无 | 获取所有模板。 | 模板不能为空。 |
| `Allocate(string viewName)` | `ViewHolder` | 无 | 从池中取出一个 Holder。 | 通常不手动调用。 |
| `Free(string viewName, ViewHolder viewHolder)` | `void` | 无 | 归还 Holder。 | 通常不手动调用。 |
| `Reset()` | `void` | 无 | 清空可见项并销毁池。 | 常用于彻底重建。 |
| `PreparePool()` | `void` | 无 | 依据可见区预热池。 | 由容器内部布局流程触发。 |
| `CreateViewHolder(int index)` | `void` | 无 | 创建一个 `Unit` 批次的 Holder。 | `index` 为布局起点。 |
| `RemoveViewHolder(int index)` | `void` | 无 | 回收一个 `Unit` 批次的 Holder。 | `index` 为布局起点。 |
| `GetViewHolder(int index)` | `ViewHolder` | 无 | 根据布局索引查找 Holder。 | 不可见时返回 `null`。 |
| `GetViewHolderByDataIndex(int dataIndex)` | `ViewHolder` | 无 | 根据数据索引查找 Holder。 | 循环列表可能存在多个映射。 |
| `TryGetViewHoldersByDataIndex(...)` | `bool` | 无 | 获取某数据索引对应的所有 Holder。 | 适合循环列表。 |
| `CalculateViewSize(int index)` | `Vector2` | 无 | 按模板估算尺寸。 | 依赖 `GetViewName(index)` 正确。 |
#### 类型:`SimpleViewProvider`
| 成员 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| 内部池 | `ObjectPool<ViewHolder>` | `initialSize = 0`, `maxSize = 1` | 单模板池。 | 实际容量会在 `PreparePool()` 中扩容。 |
| `PoolStats` | `string` | 实时统计 | 命中、未命中、销毁、活跃等信息。 | 文本格式仅用于日志。 |
#### 类型:`MixedViewProvider`
| 成员 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| 内部池 | `MixedObjectPool<ViewHolder>` | 每类型默认上限 `10` | 多模板池。 | 模板名必须唯一。 |
| `PoolStats` | `string` | 实时统计 | 当前命中、未命中、销毁信息。 | 细粒度活跃数需看 `MixedObjectPool`。 |
#### 类型:`ObjectPool<T>`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/ObjectPool/ObjectPool.cs`
| 方法/成员 | 返回值/类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `ObjectPool(IObjectFactory<T> factory)` | 构造函数 | `maxSize = Environment.ProcessorCount * 2` | 用 CPU 数量推导上限。 | 初始大小为 `0`。 |
| `ObjectPool(IObjectFactory<T> factory, int maxSize)` | 构造函数 | `initialSize = 0` | 指定最大池容量。 | `maxSize` 需大于等于 `0`。 |
| `ObjectPool(IObjectFactory<T> factory, int initialSize, int maxSize)` | 构造函数 | 无 | 指定预热数量与上限。 | `maxSize < initialSize` 会抛异常。 |
| `Allocate()` | `T` | 无 | 取对象。 | 空池时会创建新对象。 |
| `Free(T obj)` | `void` | 无 | 归还对象。 | 超过上限时会销毁。 |
| `EnsureCapacity(int value)` | `void` | 无 | 调大池上限。 | `value <= 0` 抛异常。 |
| `Warm(int count)` | `void` | 无 | 预热到目标数量。 | 实际不会超过 `maxSize`。 |
| `Dispose()` | `void` | 无 | 销毁所有空闲对象。 | 活跃对象不受影响。 |
| `MaxSize` | `int` | 构造决定 | 当前最大容量。 | 可被 `EnsureCapacity()` 提高。 |
| `ActiveCount` / `InactiveCount` | `int` | `0` | 活跃 / 闲置数量。 | 用于评估是否需要调容量。 |
| `HitCount` / `MissCount` / `DestroyCount` | `int` | `0` | 命中、未命中、销毁统计。 | 用于调优。 |
| `PeakActive` | `int` | `0` | 历史峰值活跃数。 | 可作为容量基线。 |
#### 类型:`MixedObjectPool<T>`
| 方法/成员 | 返回值/类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `MixedObjectPool(IMixedObjectFactory<T> factory)` | 构造函数 | `defaultMaxSizePerType = 10` | 使用默认每类型容量。 | `factory` 不能为空。 |
| `MixedObjectPool(IMixedObjectFactory<T> factory, int defaultMaxSizePerType)` | 构造函数 | 无 | 显式指定每类型默认容量。 | `defaultMaxSizePerType <= 0` 抛异常。 |
| `Allocate(string typeName)` | `T` | 无 | 取指定模板类型对象。 | `typeName` 必须存在。 |
| `Free(string typeName, T obj)` | `void` | 无 | 归还指定类型对象。 | 超出该类型上限时会销毁。 |
| `SetMaxSize(string typeName, int value)` | `void` | 无 | 设置单类型上限。 | 不会自动创建对象。 |
| `EnsureCapacity(string typeName, int value)` | `void` | 无 | 单类型扩容。 | `value <= 0` 抛异常。 |
| `Warm(string typeName, int count)` | `void` | 无 | 单类型预热。 | 实际不会超过该类型上限。 |
| `GetActiveCount(string typeName)` | `int` | `0` | 当前活跃数。 | 适合按模板调优。 |
| `GetPeakActiveCount(string typeName)` | `int` | `0` | 历史峰值活跃数。 | 可用于回写容量。 |
<a id="pool-basic"></a>
### 预热与复用
#### 示例 1依赖内建 `PreparePool()` 自动预热
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class BuiltinPoolWarmDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private void Start()
{
var list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
var data = new List<DemoSimpleData>();
for (int i = 0; i < 200; i++)
{
data.Add(new DemoSimpleData { Title = $"行 {i + 1}" });
}
list.Data = data;
Debug.Log(recyclerView.PoolStats); // 输出当前池统计
}
}
```
#### 示例 2直接使用 `ObjectPool<T>` 自定义容量
```csharp
using AlicizaX.UI;
using UnityEngine;
using UnityEngine.UI;
public sealed class DemoPoolHolder : ViewHolder
{
public Text title;
}
public sealed class DemoPoolFactory : IObjectFactory<DemoPoolHolder>
{
private readonly DemoPoolHolder template;
private readonly Transform parent;
public DemoPoolFactory(DemoPoolHolder template, Transform parent)
{
this.template = template;
this.parent = parent;
}
public DemoPoolHolder Create()
{
return Object.Instantiate(template, parent); // 池空时创建
}
public void Reset(DemoPoolHolder obj)
{
obj.gameObject.SetActive(false); // 归还时隐藏
}
public bool Validate(DemoPoolHolder obj)
{
return obj != null; // 被销毁对象不再回池
}
public void Destroy(DemoPoolHolder obj)
{
Object.Destroy(obj.gameObject); // 超上限时销毁
}
}
public sealed class ObjectPoolDemo : MonoBehaviour
{
[SerializeField] private DemoPoolHolder template;
[SerializeField] private Transform parent;
private ObjectPool<DemoPoolHolder> pool;
private void Awake()
{
pool = new ObjectPool<DemoPoolHolder>(new DemoPoolFactory(template, parent), initialSize: 8, maxSize: 32);
pool.Warm(16); // 预热到 16 个
pool.EnsureCapacity(48); // 动态扩容到 48
}
private void Start()
{
DemoPoolHolder holder = pool.Allocate();
holder.title.text = "From pool";
pool.Free(holder); // 归还后进入闲置栈
Debug.Log($"hit={pool.HitCount}, miss={pool.MissCount}, inactive={pool.InactiveCount}");
}
}
```
#### 示例 3校验失败时自动销毁
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class PoolValidateDemo : MonoBehaviour
{
[SerializeField] private DemoPoolHolder template;
[SerializeField] private Transform parent;
private ObjectPool<DemoPoolHolder> pool;
private void Start()
{
pool = new ObjectPool<DemoPoolHolder>(new DemoPoolFactory(template, parent), 1, 2);
DemoPoolHolder holder = pool.Allocate();
Destroy(holder.gameObject); // 模拟外部错误销毁
pool.Free(holder); // Validate 失败后不会重新入池
}
}
```
<a id="pool-mixed"></a>
### 多模板对象池配置
#### 示例 1按模板类型分别预热
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using UnityEngine;
public sealed class MixedPoolDemo : MonoBehaviour
{
[SerializeField] private DemoPoolHolder smallTemplate;
[SerializeField] private DemoPoolHolder largeTemplate;
[SerializeField] private Transform parent;
private MixedObjectPool<DemoPoolHolder> pool;
private void Awake()
{
var templates = new Dictionary<string, DemoPoolHolder>
{
["SmallItem"] = smallTemplate,
["LargeItem"] = largeTemplate,
};
pool = new MixedObjectPool<DemoPoolHolder>(
new UnityMixedComponentFactory<DemoPoolHolder>(templates, parent),
defaultMaxSizePerType: 6);
pool.EnsureCapacity("SmallItem", 24); // 小卡片出现频率更高
pool.EnsureCapacity("LargeItem", 8); // 大卡片数量更少
pool.Warm("SmallItem", 16);
pool.Warm("LargeItem", 4);
}
}
```
#### 示例 2根据峰值活跃数回写容量
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class MixedPoolTuningDemo : MonoBehaviour
{
public void PrintRecommendation(MixedObjectPool<DemoPoolHolder> pool)
{
int peak = pool.GetPeakActiveCount("SmallItem");
int recommended = peak + 2; // 峰值 + buffer
Debug.Log($"Recommended SmallItem capacity = {recommended}");
}
}
```
<a id="navigation"></a>
## 导航RecyclerNavigation
导航模块负责把 `EventSystem` 的移动事件转成“列表内跳转 + 必要滚动 + 焦点恢复”。
<a id="navigation-api"></a>
### API 说明
#### 类型:`RecyclerNavigationController`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs`
| 方法/成员 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `RecyclerNavigationController(RecyclerView recyclerView)` | 构造函数 | 无 | 创建导航控制器。 | 一般由 `RecyclerView.NavigationController` 懒加载。 |
| `TryMove(ViewHolder currentHolder, MoveDirection direction, RecyclerNavigationOptions options)` | `bool` | 无 | 尝试从当前项移动到下一个项。 | `currentHolder` 不能为空,空列表返回 `false`。 |
#### 类型:`RecyclerNavigationBridge`
| 成员/方法 | 类型/返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `defaultEntryDirection` | `MoveDirection` | `MoveDirection.Down` | 列表首次接收焦点时的进入方向。 | Inspector 配置。 |
| `OnSelect(...)` | `void` | 无 | 当桥节点被选中时尝试进入列表。 | 挂在 `RecyclerView` 同节点上。 |
| `OnMove(...)` | `void` | 无 | 当桥节点收到方向键时尝试进入列表。 | 失败后才回落到原 `Selectable` 行为。 |
| `OnSubmit(...)` | `void` | 无 | 提交键进入列表。 | 常用于确认键。 |
#### 类型:`RecyclerNavigationOptions`
| 成员 | 类型 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Wrap` | `bool` | 构造决定 | 是否允许首尾环绕。 | 对非循环列表通常应关闭。 |
| `SmoothScroll` | `bool` | 构造决定 | 导航时是否平滑滚动。 | 开启后焦点会等待滚动完成。 |
| `Alignment` | `ScrollAlignment` | 构造决定 | 导航触发滚动时的对齐方式。 | 常用 `Center`。 |
| `Clamped` | `RecyclerNavigationOptions` | `wrap=false, smooth=false, alignment=Center` | 不环绕方案。 | 静态只读。 |
| `Circular` | `RecyclerNavigationOptions` | `wrap=true, smooth=false, alignment=Center` | 允许环绕方案。 | 静态只读。 |
#### 类型:`ItemInteractionProxy` / `RecyclerItemSelectable`
| 成员/方法 | 类型/返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `GetSelectable()` | `Selectable` | 无 | 返回真正参与导航的焦点锚点。 | 若节点上没有 `Selectable` 会自动补 `RecyclerItemSelectable`。 |
| `Bind(IItemInteractionHost)` | `void` | 无 | 绑定交互宿主。 | 由 `ItemRender` 内部驱动。 |
| `Clear()` | `void` | 无 | 清理交互宿主与状态。 | 回收时自动调用。 |
<a id="navigation-inner"></a>
### 列表内导航
#### 示例 1ItemRender 启用点击、移动与提交
```csharp
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
using UnityEngine.EventSystems;
public sealed class NavigableItemRender : ItemRender<DemoSimpleData, DemoItemHolder>
{
public override ItemInteractionFlags InteractionFlags =>
ItemInteractionFlags.PointerClick |
ItemInteractionFlags.Select |
ItemInteractionFlags.Move |
ItemInteractionFlags.Submit;
protected override void OnBind(DemoSimpleData data, int index)
{
Holder.title.text = data.Title;
Holder.subtitle.text = $"可导航项 {index}";
}
protected override void OnPointerClick(PointerEventData eventData)
{
Debug.Log($"Clicked {CurrentIndex}"); // 点击时选择当前项
}
protected override void OnSubmit(BaseEventData eventData)
{
Debug.Log($"Submit {CurrentIndex}"); // 回车 / A 键确认
}
}
```
#### 示例 2主动驱动导航器移动
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class ManualNavigationDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private int currentIndex;
private void Awake()
{
recyclerView.OnIndexChanged += index => currentIndex = Mathf.Max(index, 0);
}
private void Update()
{
if (!Input.GetKeyDown(KeyCode.RightArrow))
{
return;
}
var current = EventSystem.current?.currentSelectedGameObject?.GetComponentInParent<ViewHolder>();
if (current == null)
{
recyclerView.TryFocusIndex(currentIndex, false, ScrollAlignment.Center);
return;
}
recyclerView.NavigationController.TryMove(current, MoveDirection.Right, RecyclerNavigationOptions.Clamped);
}
}
```
#### 示例 3环绕导航与钳制导航
```csharp
using AlicizaX.UI;
using RecyclerViewBookSamples;
public sealed class CircularNavigableRender : ItemRender<DemoSimpleData, DemoItemHolder>
{
public override ItemInteractionFlags InteractionFlags =>
ItemInteractionFlags.Select | ItemInteractionFlags.Move;
protected override RecyclerNavigationOptions NavigationOptions =>
RecyclerNavigationOptions.Circular; // 到尾部后回到头部
protected override void OnBind(DemoSimpleData data, int index)
{
Holder.title.text = data.Title;
}
}
```
<a id="navigation-entry"></a>
### 列表入口与焦点恢复
#### 示例 1外部按钮把焦点送入列表
```csharp
using AlicizaX.UI;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public sealed class EntryBridgeDemo : MonoBehaviour
{
[SerializeField] private Button entryButton;
[SerializeField] private RecyclerView recyclerView;
private void Awake()
{
entryButton.onClick.AddListener(() =>
{
EventSystem.current.SetSelectedGameObject(recyclerView.gameObject); // 选中桥节点
recyclerView.TryFocusEntry(MoveDirection.Down); // 然后进入列表
});
}
}
```
#### 示例 2重新打开面板时恢复上次项
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class NavigationRestoreDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private int lastIndex;
private void Awake()
{
recyclerView.OnIndexChanged += index => lastIndex = index; // 记录上次选中索引
}
private void OnEnable()
{
recyclerView.TryFocusIndex(lastIndex, smooth: true); // 平滑恢复焦点
}
}
```
#### 示例 3空列表时避免错误进入
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class EmptyNavigationGuardDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
public void TryEnter()
{
if (!recyclerView.TryFocusEntry(MoveDirection.Down))
{
Debug.Log("List is empty or not ready."); // 空列表不强行进入
}
}
}
```
<a id="uglist"></a>
## 业务包装层UGList / UGListExtensions
`UGList` 家族是官方提供的“业务友好包装层”,适合绝大多数游戏页面。它把适配器创建、内部绑定流程和常见滚动操作做了简化。
<a id="uglist-api"></a>
### API 说明
#### 类型:`UGListBase<TData, TAdapter>`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/UGList.cs`
| 成员/方法 | 类型/返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `RecyclerView` | `RecyclerView` | 构造注入 | 关联容器。 | 只读。 |
| `Adapter` | `TAdapter` | 构造注入 | 关联适配器。 | 只读。 |
| `Data` | `List<TData>` | `null` | 当前数据集合。 | 赋值时会调用 `_adapter.SetList()`。 |
| `RegisterItemRender<TItemRender>(string viewName = "")` | `void` | `viewName=""` | 注册渲染器。 | 空字符串表示默认模板。 |
| `RegisterItemRender(Type itemRenderType, string viewName = "")` | `void` | `viewName=""` | 运行时注册渲染器。 | 类型需继承 `ItemRenderBase`。 |
| `UnregisterItemRender(string viewName = "")` | `bool` | `viewName=""` | 注销渲染器。 | 成功返回 `true`。 |
| `ClearItemRenderRegistrations()` | `void` | 无 | 清空注册。 | 适合切换整套模板。 |
#### 类型:具体包装类
| 类型 | 适配器类型 | 说明 | 使用限制 |
| --- | --- | --- | --- |
| `UGList<TData>` | `Adapter<TData>` | 单模板普通列表。 | `TData : ISimpleViewData` |
| `UGGroupList<TData>` | `GroupAdapter<TData>` | 分组列表。 | `TData : class, IGroupViewData, new()` |
| `UGLoopList<TData>` | `LoopAdapter<TData>` | 循环列表。 | `TData : ISimpleViewData, new()` |
| `UGMixedList<TData>` | `MixedAdapter<TData>` | 多模板列表。 | `TData : IMixedViewData` |
#### 类型:`UGListCreateHelper`
| 方法 | 返回值 | 默认参数 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `Create<TData>(RecyclerView recyclerView)` | `UGList<TData>` | 无 | 创建普通列表包装。 | `TData : ISimpleViewData` |
| `CreateGroup<TData>(RecyclerView recyclerView, string groupViewName)` | `UGGroupList<TData>` | 无 | 创建分组列表包装。 | 必须提供组头模板名。 |
| `CreateLoop<TData>(RecyclerView recyclerView)` | `UGLoopList<TData>` | 无 | 创建循环列表包装。 | `TData : ISimpleViewData, new()` |
| `CreateMixed<TData>(RecyclerView recyclerView)` | `UGMixedList<TData>` | 无 | 创建多模板列表包装。 | `TData : IMixedViewData` |
#### 类型:`UGListExtensions`
源码位置:`Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/UGListExtensions.cs`
| 成员/方法 | 返回值 | 默认值 | 说明 | 使用限制 |
| --- | --- | --- | --- | --- |
| `DebugScrollTo` | `bool` | `false` | 输出滚动定位日志。 | 仅用于调试。 |
| `ScrollTo(...)` | `void` | `alignment=Start, offset=0f, smooth=false, duration=0.3f` | 包装 `RecyclerView.ScrollToWithAlignment()`。 | `ugList.RecyclerView` 为空时打印警告。 |
| `ScrollToStart(...)` | `void` | `offset=0f, smooth=false, duration=0.3f` | 滚到起始端。 | 同上。 |
| `ScrollToCenter(...)` | `void` | `offset=0f, smooth=false, duration=0.3f` | 滚到中间。 | 同上。 |
| `ScrollToEnd(...)` | `void` | `offset=0f, smooth=false, duration=0.3f` | 滚到末端。 | 同上。 |
<a id="uglist-basic"></a>
### 快速创建与滚动扩展
#### 示例 1`UGList` 最简创建
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class QuickUgListDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "快速创建 1" },
new DemoSimpleData { Title = "快速创建 2" },
};
}
}
```
#### 示例 2使用扩展方法滚动
```csharp
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class UgListExtensionsDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
}
public void CenterItem(int index)
{
list.ScrollToCenter(index, offset: 0f, smooth: true, duration: 0.2f); // 使用扩展方法
}
}
```
#### 示例 3打开滚动定位调试日志
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class UgListDebugDemo : MonoBehaviour
{
private void Awake()
{
UGListExtensions.DebugScrollTo = true; // 打开定位日志
}
}
```
<a id="uglist-advanced"></a>
### 业务封装示例
#### 示例 1业务侧封装统一入口
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class MailListFacade
{
private readonly UGList<DemoSimpleData> list;
public MailListFacade(RecyclerView recyclerView)
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
}
public void SetMails(List<DemoSimpleData> mails)
{
list.Data = mails; // 统一数据入口
}
public void FocusFirstUnread()
{
list.RecyclerView.TryFocusIndex(0, true, ScrollAlignment.Center);
}
}
```
#### 示例 2封装“刷新 + 保留焦点”
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
public sealed class FocusAwareFacade
{
private readonly UGList<DemoSimpleData> list;
private int lastIndex;
public FocusAwareFacade(RecyclerView recyclerView)
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
recyclerView.OnIndexChanged += index => lastIndex = index;
}
public void Replace(List<DemoSimpleData> data)
{
list.Data = data;
list.RecyclerView.TryFocusIndex(lastIndex, false); // 数据刷新后恢复焦点
}
}
```
<a id="scenarios"></a>
## 场景专题
本节给出更贴近业务的完整场景脚本,重点覆盖性能与交互要求较高的页面。
<a id="scenario-massive"></a>
### 大量列表项复用(含对象池配置)
#### 示例 1一万条数据的大列表
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class MassiveListDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: LinearLayoutManager + Scroller
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
var rows = new List<DemoSimpleData>(10000);
for (int i = 0; i < 10000; i++)
{
rows.Add(new DemoSimpleData
{
Title = $"战利品 {i + 1}",
Subtitle = $"ID = {100000 + i}",
});
}
list.Data = rows;
Debug.Log(recyclerView.PoolStats); // 观察命中、未命中和活跃数
}
}
```
#### 示例 2手工调对象池容量
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class MassiveListPoolConfigDemo : MonoBehaviour
{
[SerializeField] private DemoPoolHolder rowTemplate;
[SerializeField] private Transform poolRoot;
private ObjectPool<DemoPoolHolder> pool;
private void Awake()
{
pool = new ObjectPool<DemoPoolHolder>(
new DemoPoolFactory(rowTemplate, poolRoot),
initialSize: 24,
maxSize: 96); // 明确给出大列表池容量
pool.Warm(48); // 提前创建一部分
}
private void Start()
{
Debug.Log($"active={pool.ActiveCount}, inactive={pool.InactiveCount}, peak={pool.PeakActive}");
}
}
```
> 💡 大列表调优的首要目标不是“把所有项都放进池里”,而是让“可见项 + 一屏缓冲”命中率足够高。
<a id="scenario-mixed"></a>
### 多模板混排
#### 示例 1信息流混排
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class FeedCardHolder : ViewHolder
{
public UnityEngine.UI.Text title;
}
public sealed class FeedCardRender : ItemRender<DemoMixedData, FeedCardHolder>
{
protected override void OnBind(DemoMixedData data, int index)
{
Holder.title.text = $"[Card] {data.Title}";
}
}
public sealed class MixedFeedDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: MixedLayoutManager + 2 templates
private UGMixedList<DemoMixedData> list;
private void Start()
{
list = UGListCreateHelper.CreateMixed<DemoMixedData>(recyclerView);
list.RegisterItemRender<DemoMixedRender>("FeedText");
list.RegisterItemRender<FeedCardRender>("FeedCard");
list.Data = new List<DemoMixedData>
{
new DemoMixedData { TemplateName = "FeedCard", Title = "头图 Banner" },
new DemoMixedData { TemplateName = "FeedText", Title = "正文 1", Subtitle = "文本块" },
new DemoMixedData { TemplateName = "FeedText", Title = "正文 2", Subtitle = "文本块" },
new DemoMixedData { TemplateName = "FeedCard", Title = "推荐卡片" },
};
}
}
```
#### 示例 2未知模板名的保护策略
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class MixedTemplateGuardDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
public void BindSafely(List<DemoMixedData> rows)
{
foreach (DemoMixedData row in rows)
{
if (string.IsNullOrEmpty(row.TemplateName))
{
row.TemplateName = "FeedText"; // 回退到一个可用模板
}
}
var list = UGListCreateHelper.CreateMixed<DemoMixedData>(recyclerView);
list.RegisterItemRender<DemoMixedRender>("FeedText");
list.Data = rows;
}
}
```
> ⚠️ `MixedViewProvider.GetTemplate()` 找不到模板名时会抛 `KeyNotFoundException`;不要把兜底逻辑留给运行时异常。
<a id="scenario-paging"></a>
### 分页加载(加载态、空态、错误态)
#### 示例 1统一状态流
```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public abstract class PagedEntryData : IMixedViewData
{
public string TemplateName { get; set; }
}
public sealed class PagedContentData : PagedEntryData
{
public string Title;
}
public sealed class PagedStateData : PagedEntryData
{
public string Message;
public Action Retry;
}
public sealed class PagedContentRender : ItemRender<PagedContentData, DemoItemHolder>
{
protected override void OnBind(PagedContentData data, int index)
{
Holder.title.text = data.Title;
Holder.subtitle.text = $"Item {index}";
}
}
public sealed class PagedStateRender : ItemRender<PagedStateData, DemoStateHolder>
{
private Action retry;
protected override void OnHolderAttached()
{
base.OnHolderAttached();
Holder.retryButton.onClick.AddListener(OnRetryClicked); // 只注册一次
}
protected override void OnHolderDetached()
{
Holder.retryButton.onClick.RemoveListener(OnRetryClicked);
base.OnHolderDetached();
}
protected override void OnBind(PagedStateData data, int index)
{
retry = data.Retry;
Holder.message.text = data.Message;
Holder.retryButton.gameObject.SetActive(data.Retry != null); // 错误态显示重试按钮
}
protected override void OnClear()
{
retry = null; // 回收时清掉业务引用
}
private void OnRetryClicked()
{
retry?.Invoke();
}
}
public sealed class PagedLoadingDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: 需有 ContentItem / LoadingItem / EmptyItem / ErrorItem 模板
private UGMixedList<PagedEntryData> list;
private int page = 1;
private void Start()
{
list = UGListCreateHelper.CreateMixed<PagedEntryData>(recyclerView);
list.RegisterItemRender<PagedContentRender>("ContentItem");
list.RegisterItemRender<PagedStateRender>("LoadingItem");
list.RegisterItemRender<PagedStateRender>("EmptyItem");
list.RegisterItemRender<PagedStateRender>("ErrorItem");
StartCoroutine(LoadPage());
}
private IEnumerator LoadPage()
{
ShowLoading();
yield return new WaitForSeconds(0.5f); // 模拟网络
if (page == 1)
{
ShowContent(new List<PagedEntryData>
{
new PagedContentData { TemplateName = "ContentItem", Title = "第一页-1" },
new PagedContentData { TemplateName = "ContentItem", Title = "第一页-2" },
});
page++;
yield break;
}
ShowEmpty(); // 第二次请求模拟空态
}
private void ShowLoading()
{
list.Data = new List<PagedEntryData>
{
new PagedStateData { TemplateName = "LoadingItem", Message = "正在加载..." },
};
}
private void ShowContent(List<PagedEntryData> rows)
{
list.Data = rows;
}
private void ShowEmpty()
{
list.Data = new List<PagedEntryData>
{
new PagedStateData { TemplateName = "EmptyItem", Message = "暂无数据" },
};
}
public void ShowError()
{
list.Data = new List<PagedEntryData>
{
new PagedStateData
{
TemplateName = "ErrorItem",
Message = "加载失败,点击重试",
Retry = () => StartCoroutine(LoadPage()), // 错误态回调
},
};
}
}
```
#### 示例 2滚动到底部加载下一页
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class PagedLoadMoreDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private bool isLoading;
private void Awake()
{
recyclerView.OnScrollValueChanged += TryLoadMore; // 监听滚动变化
}
private void TryLoadMore()
{
if (isLoading || recyclerView.Scroller == null)
{
return;
}
float remain = recyclerView.Scroller.MaxPosition - recyclerView.GetScrollPosition();
if (remain > 120f)
{
return; // 距离底部还远,不加载
}
isLoading = true;
Debug.Log("Reach bottom, load next page.");
}
}
```
<a id="scenario-carousel"></a>
### 轮播与循环滚动(自动播放、手动切换)
#### 示例 1循环 Banner
```csharp
using System.Collections;
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
using UnityEngine.UI;
public sealed class BannerCarouselDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView; // Inspector: PageLayoutManager + Snap = true
[SerializeField] private Button prevButton;
[SerializeField] private Button nextButton;
private UGLoopList<DemoSimpleData> list;
private int virtualIndex;
private Coroutine autoPlay;
private void Start()
{
list = UGListCreateHelper.CreateLoop<DemoSimpleData>(recyclerView);
list.RegisterItemRender<DemoSimpleRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "Banner A" },
new DemoSimpleData { Title = "Banner B" },
new DemoSimpleData { Title = "Banner C" },
};
virtualIndex = list.Adapter.GetRealCount() * 100; // 从中间起始,保证可双向滚
recyclerView.ScrollToWithAlignment(virtualIndex, ScrollAlignment.Center);
prevButton.onClick.AddListener(() => Move(-1)); // 手动上一张
nextButton.onClick.AddListener(() => Move(1)); // 手动下一张
autoPlay = StartCoroutine(AutoPlay());
}
private void OnDisable()
{
if (autoPlay != null)
{
StopCoroutine(autoPlay);
}
}
private IEnumerator AutoPlay()
{
while (true)
{
yield return new WaitForSeconds(3f);
Move(1); // 自动播放
}
}
private void Move(int step)
{
virtualIndex += step;
recyclerView.ScrollToWithAlignment(virtualIndex, ScrollAlignment.Center, 0f, true, 0.25f);
}
}
```
#### 示例 2非循环分页的边界保护
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class FiniteCarouselDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
[SerializeField] private int itemCount = 5;
private int currentIndex;
private void Awake()
{
recyclerView.OnIndexChanged += index => currentIndex = Mathf.Max(index, 0);
}
public void Next()
{
int target = Mathf.Min(currentIndex + 1, itemCount - 1);
recyclerView.ScrollToWithAlignment(target, ScrollAlignment.Center, 0f, true, 0.2f);
}
public void Prev()
{
int target = Mathf.Max(currentIndex - 1, 0);
recyclerView.ScrollToWithAlignment(target, ScrollAlignment.Center, 0f, true, 0.2f);
}
}
```
<a id="scenario-nav-scroll"></a>
### 手柄/键盘导航结合列表滚动
#### 示例 1打开面板自动聚焦第一项
```csharp
using System.Collections.Generic;
using AlicizaX.UI;
using RecyclerViewBookSamples;
using UnityEngine;
public sealed class GamepadListDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private UGList<DemoSimpleData> list;
private void Start()
{
list = UGListCreateHelper.Create<DemoSimpleData>(recyclerView);
list.RegisterItemRender<NavigableItemRender>();
list.Data = new List<DemoSimpleData>
{
new DemoSimpleData { Title = "图像" },
new DemoSimpleData { Title = "声音" },
new DemoSimpleData { Title = "操作" },
};
recyclerView.TryFocusIndex(0, false, ScrollAlignment.Start); // 初次进入聚焦第一项
}
}
```
#### 示例 2导航时自动把目标项滚进可见区
```csharp
using AlicizaX.UI;
using UnityEngine;
public sealed class NavigationScrollSyncDemo : MonoBehaviour
{
[SerializeField] private RecyclerView recyclerView;
private int currentIndex;
private void Awake()
{
recyclerView.OnIndexChanged += index => currentIndex = Mathf.Max(index, 0);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.DownArrow))
{
recyclerView.TryFocusIndex(currentIndex + 1, smooth: true, alignment: ScrollAlignment.End);
}
if (Input.GetKeyDown(KeyCode.UpArrow))
{
recyclerView.TryFocusIndex(currentIndex - 1, smooth: true, alignment: ScrollAlignment.Start);
}
}
}
```
> 📌 `TryFocusIndex(..., smooth: true)` 的意义不只是“滚动”,还包括“滚动结束后恢复焦点”。
<a id="faq"></a>
## FAQ
### 常见问题
#### Q1为什么我已经赋值了数据但列表里什么都没有
- 最常见原因是没有通过 `UGList.Data`、`Adapter.SetList()` 或 `Notify*()` 等正式入口触发列表更新。
- 其次是 `Templates` 为空、`LayoutManager` 未配置或模板名与 `TemplateName` 不匹配。
- 如果是多模板列表,还要确认已经为每个模板注册了 `ItemRender`
#### Q2为什么 `NotifyItemChanged()` 后界面没变?
- 只会重绑“当前可见”的 Holder不可见项会等滚动到可见区后再更新。
- 如果该项高度发生变化,需要用 `NotifyItemChanged(index, true)`
#### Q3为什么循环列表里业务索引和 `Holder.Index` 不一样?
- `OnIndexChanged` 回调参数与 `Adapter.ChoiceIndex` 代表的是业务层关注的“真实索引”。
- `Holder.Index` 是布局层的“虚拟索引”。
- `LoopAdapter` 绑定时会对虚拟索引取模。
#### Q4为什么混排列表报模板找不到
- `MixedAdapter.GetViewName()` 直接返回 `TemplateName`
- `MixedViewProvider` 用模板对象的 `name` 做键。
- 任何大小写、空格或重命名不一致都会导致失败。
#### Q5为什么滚动条不显示
- `showScrollBar``Scroll` 可能未打开。
- `ShowScrollBarOnlyWhenScrollable = true` 时,内容未超过视口就不会显示。
- `Direction.Custom` 不支持当前实现的溢出检测。
#### Q6为什么分页吸附不准确
- `PageLayoutManager` 最适合固定尺寸页面。
- 项尺寸变化大时应换 `MixedLayoutManager`,并自己定义滚动定位策略。
#### Q7为什么切换页面后焦点丢了
- 视图复用会销毁/回收之前的 `Selectable`
- 应该在 `OnEnable()` 或刷新完成后调用 `TryFocusIndex()`
#### Q8为什么对象池命中率低
- 预热量不足。
- 列表每次都被 `Reset()`
- 使用了全量刷新替代差量刷新。
<a id="anti-patterns"></a>
## Anti-patterns
### 常见反模式与替代方案
| 反模式 | 问题 | 正确做法 |
| --- | --- | --- |
| 每次数据变化都调用 `NotifyDataChanged()` | 频繁清空并重建可见项,浪费池命中率。 | 优先使用 `NotifyItemChanged()` / `NotifyItemRangeChanged()`。 |
| 多模板列表里把 `TemplateName` 写成业务文案 | 模板名一旦翻译或重命名就失效。 | 用固定常量,如 `const string FeedCard = "FeedCard";`。 |
| 在 `OnBind()` 里重复注册按钮事件 | Holder 被复用后会出现重复回调。 | 在 `OnHolderAttached()` 注册,在 `OnHolderDetached()` 解绑。 |
| 直接改 `Scroller.Position` 期待界面自动更新 | 位置变了但布局未刷新。 | 优先调用 `RecyclerView.ScrollTo*()`。 |
| 列表数据尚未通过正式入口完成更新就调用 `TryFocusIndex()` | 焦点目标尚未生成。 | 先写入 `Data` / `SetList()` / `Notify*()`,必要时延后到下一帧或 `OnEnable()`。 |
| 使用 `LoopAdapter` 却仍以真实索引做“下一页” | 会反复跳回前几项。 | 维护独立的虚拟索引。 |
| 每次打开页面都重新 new 包装层与数据容器 | 造成 GC 抖动和池统计失真。 | 页面级持有 `UGList` / `Adapter`,只替换数据。 |
| 把“加载中/空态/错误态”放在列表外部多套 UI | 逻辑分裂、切换难维护。 | 用 `MixedAdapter` 把状态也当作一种 Item。 |
<a id="performance"></a>
## 性能优化建议
### 建议清单
- 视图池预热:首屏数据尽量一次性设置到位,内部布局流程会自动触发 `PreparePool()` 准备“可见项 + 缓冲项”。
- 优先差量刷新:文本、数值、开关状态变化优先用 `NotifyItemChanged()` / `NotifyItemRangeChanged()`
- 避免全量刷新:`NotifyDataChanged()` 只用于排序、模板切换、尺寸大量变化等无法局部更新的情况。
- 复用业务包装层:面板级缓存 `UGList` / `Adapter`,不要每次打开都重建。
- 控制模板数量:多模板会降低池命中率;能合并的样式尽量合并。
- 固定 Item 尺寸优先:能用 `LinearLayoutManager` / `GridLayoutManager` 时不要上来就用 `MixedLayoutManager`
- 减少 `OnBind()` 重活:不要在 `OnBind()` 里做同步大图加载、复杂字符串拼接或深层级查找。
- 缓存引用:`Holder` 上的 `Text`、`Image`、`Button` 在模板初始化时就拖好,避免运行时 `GetComponent()`
- 记录峰值活跃数:观察 `PoolStats`、`PeakActive`、`GetPeakActiveCount()`,按峰值 + buffer 设置池容量。
- 滚动事件节流:`OnScrollValueChanged` 是高频回调,尽量只做阈值判断,不做昂贵计算。
- 大量分页时保留数据容器:分页追加优先 `AddRange()`,避免整页复制新 `List<T>`
- 减少 `Reset()``SetList()` 会触发 `RecyclerView.Reset()`;如果只是局部追加,直接对适配器做增量操作更稳。
> 💡 一个常见经验值:池容量先按“单屏可见数 + 1 屏缓冲”估,再通过 `PeakActive` 微调,而不是拍脑袋设置超大容量。
> ⚠️ 如果列表项高度经常变化,却还持续使用 `NotifyItemChanged(index, false)`,最终会出现错位、重叠或滚动条比例异常。
<a id="checklist"></a>
## 交付前检查清单
- `Templates` 已配置,且多模板名称与 `TemplateName` 完全一致。
- `LayoutManager``Scroller` 已在 Inspector 上正确绑定。
- 已为所有模板注册对应 `ItemRender`
- 数据赋值后已通过 `Data` / `SetList()` / `Notify*()` 触发内部布局与刷新流程。
- 差量更新优先使用 `NotifyItemChanged()` / `NotifyItemRangeChanged()`
- 焦点型页面已验证 `TryFocusIndex()`、`TryFocusEntry()` 与关闭/重开流程。
- 大列表页面已检查 `PoolStats` / `PeakActive` / `GetPeakActiveCount()`
- 加载态、空态、错误态已验证,且不会因模板缺失报错。
- 循环列表已使用虚拟索引,不直接拿真实索引做连续滚动。
- `OnBind()` 中没有重复注册事件,也没有未释放的临时引用。