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

89 KiB
Raw Permalink Blame History

RecyclerView 专业手册

📌 本文档基于 Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView 的运行时 API 编写,重点说明业务侧可直接调用的能力;文中提到的 private / internal 成员仅用于解释内部机制,不属于业务层 API。

⚠️ SetAdapter()Reset()Refresh()RequestLayout() 已收敛为框架内部流程;业务层请通过 UGListUGListCreateHelperUGList.DataAdapter.SetList()Notify*() 等正式入口驱动列表更新,不要手动调用这些底层方法。所有 UI 相关调用仍应统一放在 Unity 主线程。

💡 RecyclerViewLayoutManagerScroller 通过 Inspector 序列化引用配置,业务代码通常只“读取与使用”,不建议在运行中直接替换类型。

目录

模块概览

RecyclerView 是一套面向 Unity UGUI 的高性能列表组件,职责拆分如下:

  • 容器:RecyclerView 负责模板、可见区、滚动同步、滚动条、焦点恢复。
  • 适配器:Adapter<T> 家族负责数据量、模板选择、视图绑定、局部刷新。
  • 布局:LayoutManager 家族负责位置计算、可见区间与索引转换。
  • 滚动器:Scroller 家族负责拖拽、滚轮、惯性、吸附与平滑滚动。
  • 视图池:ViewProvider + ObjectPool 负责创建、回收、预热与统计。
  • 导航:RecyclerNavigationController 家族负责手柄/键盘在列表内的可达性。
  • 业务包装层:UGList 家族负责降低泛型与注册样板代码。

💡 一般调用顺序是:准备模板与布局 → 创建业务包装层/适配器包装 → 注册 ItemRender → 赋值数据;后续布局计算、池预热与首屏刷新由框架自动完成。

⚠️ 如果 Templates 为空、LayoutManager 未配置或 Content 解析失败,RecyclerView 会在首次运行时抛出错误或记录错误日志。

基础概念与共享示例类型

本节给出后续示例默认共用的最小类型。除非某个示例额外声明了自己的 Data / Holder / Render,否则都可以直接与以下代码一起编译。

示例基础类型

共享类型代码

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 中把模板预制体上的 TextImageButton 等引用拖好。

容器RecyclerView

RecyclerView 是系统的中心节点:它管理模板、内容区域、当前适配器、布局、滚动器、滚动条以及焦点导航。

API 说明

类型:RecyclerView

源码位置:Packages/com.alicizax.unity.ui.extension/Runtime/RecyclerView/RecyclerView.cs

属性

成员名 类型 默认值 说明 使用限制
Direction Direction Direction.Vertical(未序列化时) 主滚动方向。 LayoutManagerScroller 的方向必须一致。
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 指定为 ScrollerCircleScroller
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 == trueScroller != 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 == falseScroller == null 或目标索引非法时无效果。

⚠️ ScrollTo() / TryFocusIndex() 依赖列表已通过 UGList.DataAdapter.SetList()Notify*() 完成一次正式更新;布局与刷新会随这些入口自动完成,业务层不要额外手动补 RequestLayout() / Refresh()

初始化与刷新

示例 1基础纵向列表初始化

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异步拉取后再刷新容器

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空数据与安全刷新

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

定位与焦点控制

示例 1按钮驱动滚动到指定项

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打开面板时恢复焦点

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监听焦点索引变化

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

适配器Adapter / GroupAdapter / LoopAdapter / MixedAdapter

适配器负责把数据对象映射为模板名、绑定流程和局部刷新操作,是业务层最常直接扩展的模块。

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

基础数据更新

示例 1通过 UGList.Adapter 进行增删改

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差量刷新单项与区间

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尺寸变化时强制重布局

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);   // 尺寸变化时必须重布局
    }
}

多模板、分组与循环

示例 1多模板注册与绑定

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分组展开/收起

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循环适配器的虚拟索引

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

布局LayoutManager 系列)

布局器只负责“算位置”,不负责数据绑定。它决定内容尺寸、可见区间、索引到坐标的映射以及吸附基准。

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 执行布局相关动画。 PageLayoutManagerCircleLayoutManager 会覆写。

类型:布局派生类

类型 主要特性 默认值 使用限制
LinearLayoutManager 等尺寸线性布局。 无额外字段 适合单列/单行列表。
GridLayoutManager 网格布局。 cellCount = 1 cellCount 通过 Inspector 配置。
PageLayoutManager 分页式线性布局,带缩放动画。 minScale = 0.9f 常与 Snap 联用。
CircleLayoutManager 圆环布局。 circleDirection = Positive intervalAngle 在运行时按项数重算。
MixedLayoutManager 支持不同模板尺寸的异构布局。 内部缓存为空 最适合多模板或变高度列表。

参数:IndexToPosition(int index)

参数名 类型 默认值 说明
index int 目标布局索引。
  • 返回值:float,滚动器目标位置。
  • 使用限制:越界时通常被实现类收敛到合法范围,但业务层仍应传递有效索引。

线性与网格布局

示例 1线性列表

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网格背包

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空网格的边界结果

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>();          // 空网格
    }
}

分页、圆环与异构长度布局

示例 1分页卡片

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圆环菜单

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异构长度列表

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" },
        };
    }
}

滚动器Scroller / CircleScroller

滚动器负责手势输入与位移变化;布局负责“每个位移意味着什么”。二者解耦后,滚动和显示方式可以灵活组合。

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,需要上层自行保证。

程序化滚动

示例 1立即滚动与平滑滚动

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按固定时长滚动

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无滚动能力时的防御式调用

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

惯性、吸附与自动播放

示例 1分页列表的吸附体验

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自动播放轮播

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手动上一页 / 下一页

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

视图池ViewProvider / ObjectPool

视图池是 RecyclerView 性能表现的核心:它让“大列表”只保留可见区对象,其他对象进入可复用池而不是销毁重建。

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 历史峰值活跃数。 可用于回写容量。

预热与复用

示例 1依赖内建 PreparePool() 自动预热

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> 自定义容量

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校验失败时自动销毁

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 失败后不会重新入池
    }
}

多模板对象池配置

示例 1按模板类型分别预热

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根据峰值活跃数回写容量

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

导航RecyclerNavigation

导航模块负责把 EventSystem 的移动事件转成“列表内跳转 + 必要滚动 + 焦点恢复”。

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 清理交互宿主与状态。 回收时自动调用。

列表内导航

示例 1ItemRender 启用点击、移动与提交

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主动驱动导航器移动

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环绕导航与钳制导航

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

列表入口与焦点恢复

示例 1外部按钮把焦点送入列表

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重新打开面板时恢复上次项

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空列表时避免错误进入

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.");              // 空列表不强行进入
        }
    }
}

业务包装层UGList / UGListExtensions

UGList 家族是官方提供的“业务友好包装层”,适合绝大多数游戏页面。它把适配器创建、内部绑定流程和常见滚动操作做了简化。

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 滚到末端。 同上。

快速创建与滚动扩展

示例 1UGList 最简创建

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使用扩展方法滚动

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打开滚动定位调试日志

using AlicizaX.UI;
using UnityEngine;

public sealed class UgListDebugDemo : MonoBehaviour
{
    private void Awake()
    {
        UGListExtensions.DebugScrollTo = true;          // 打开定位日志
    }
}

业务封装示例

示例 1业务侧封装统一入口

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封装“刷新 + 保留焦点”

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); // 数据刷新后恢复焦点
    }
}

场景专题

本节给出更贴近业务的完整场景脚本,重点覆盖性能与交互要求较高的页面。

大量列表项复用(含对象池配置)

示例 1一万条数据的大列表

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手工调对象池容量

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

💡 大列表调优的首要目标不是“把所有项都放进池里”,而是让“可见项 + 一屏缓冲”命中率足够高。

多模板混排

示例 1信息流混排

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未知模板名的保护策略

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;不要把兜底逻辑留给运行时异常。

分页加载(加载态、空态、错误态)

示例 1统一状态流

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滚动到底部加载下一页

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.");
    }
}

轮播与循环滚动(自动播放、手动切换)

示例 1循环 Banner

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非循环分页的边界保护

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

手柄/键盘导航结合列表滚动

示例 1打开面板自动聚焦第一项

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导航时自动把目标项滚进可见区

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) 的意义不只是“滚动”,还包括“滚动结束后恢复焦点”。

FAQ

常见问题

Q1为什么我已经赋值了数据但列表里什么都没有

  • 最常见原因是没有通过 UGList.DataAdapter.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为什么滚动条不显示

  • showScrollBarScroll 可能未打开。
  • ShowScrollBarOnlyWhenScrollable = true 时,内容未超过视口就不会显示。
  • Direction.Custom 不支持当前实现的溢出检测。

Q6为什么分页吸附不准确

  • PageLayoutManager 最适合固定尺寸页面。
  • 项尺寸变化大时应换 MixedLayoutManager,并自己定义滚动定位策略。

Q7为什么切换页面后焦点丢了

  • 视图复用会销毁/回收之前的 Selectable
  • 应该在 OnEnable() 或刷新完成后调用 TryFocusIndex()

Q8为什么对象池命中率低

  • 预热量不足。
  • 列表每次都被 Reset()
  • 使用了全量刷新替代差量刷新。

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。

性能优化建议

建议清单

  • 视图池预热:首屏数据尽量一次性设置到位,内部布局流程会自动触发 PreparePool() 准备“可见项 + 缓冲项”。
  • 优先差量刷新:文本、数值、开关状态变化优先用 NotifyItemChanged() / NotifyItemRangeChanged()
  • 避免全量刷新:NotifyDataChanged() 只用于排序、模板切换、尺寸大量变化等无法局部更新的情况。
  • 复用业务包装层:面板级缓存 UGList / Adapter,不要每次打开都重建。
  • 控制模板数量:多模板会降低池命中率;能合并的样式尽量合并。
  • 固定 Item 尺寸优先:能用 LinearLayoutManager / GridLayoutManager 时不要上来就用 MixedLayoutManager
  • 减少 OnBind() 重活:不要在 OnBind() 里做同步大图加载、复杂字符串拼接或深层级查找。
  • 缓存引用:Holder 上的 TextImageButton 在模板初始化时就拖好,避免运行时 GetComponent()
  • 记录峰值活跃数:观察 PoolStatsPeakActiveGetPeakActiveCount(),按峰值 + buffer 设置池容量。
  • 滚动事件节流:OnScrollValueChanged 是高频回调,尽量只做阈值判断,不做昂贵计算。
  • 大量分页时保留数据容器:分页追加优先 AddRange(),避免整页复制新 List<T>
  • 减少 Reset()SetList() 会触发 RecyclerView.Reset();如果只是局部追加,直接对适配器做增量操作更稳。

💡 一个常见经验值:池容量先按“单屏可见数 + 1 屏缓冲”估,再通过 PeakActive 微调,而不是拍脑袋设置超大容量。

⚠️ 如果列表项高度经常变化,却还持续使用 NotifyItemChanged(index, false),最终会出现错位、重叠或滚动条比例异常。

交付前检查清单

  • Templates 已配置,且多模板名称与 TemplateName 完全一致。
  • LayoutManagerScroller 已在 Inspector 上正确绑定。
  • 已为所有模板注册对应 ItemRender
  • 数据赋值后已通过 Data / SetList() / Notify*() 触发内部布局与刷新流程。
  • 差量更新优先使用 NotifyItemChanged() / NotifyItemRangeChanged()
  • 焦点型页面已验证 TryFocusIndex()TryFocusEntry() 与关闭/重开流程。
  • 大列表页面已检查 PoolStats / PeakActive / GetPeakActiveCount()
  • 加载态、空态、错误态已验证,且不会因模板缺失报错。
  • 循环列表已使用虚拟索引,不直接拿真实索引做连续滚动。
  • OnBind() 中没有重复注册事件,也没有未释放的临时引用。