com.alicizax.unity.ui.exten.../Runtime/RecyclerView/RecyclerView.cs
2026-03-11 14:18:07 +08:00

734 lines
22 KiB
C#
Raw 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.

using System;
using UnityEngine;
using UnityEngine.UI;
namespace AlicizaX.UI
{
/// <summary>
/// RecyclerView 核心组件,用于高效显示大量列表数据
/// 通过视图回收和复用机制,只渲染可见区域的项目,大幅提升性能
/// 支持垂直/水平滚动、网格布局、循环滚动等多种布局模式
/// </summary>
public class RecyclerView : MonoBehaviour
{
#region Serialized Fields - Layout Settings
[HideInInspector] [SerializeField] private Direction direction;
[HideInInspector] [SerializeField] private Alignment alignment;
[HideInInspector] [SerializeField] private Vector2 spacing;
[HideInInspector] [SerializeField] private Vector2 padding;
#endregion
#region Serialized Fields - Scroll Settings
[HideInInspector] [SerializeField] private bool scroll;
[HideInInspector] [SerializeField] private bool snap;
[HideInInspector] [SerializeField, Range(1f, 50f)]
private float scrollSpeed = 7f;
[HideInInspector] [SerializeField, Range(10f, 50f)]
private float wheelSpeed = 30f;
#endregion
#region Serialized Fields - Components
[HideInInspector] [SerializeField] private ViewHolder[] templates;
[HideInInspector] [SerializeField] private RectTransform content;
[HideInInspector] [SerializeField] private bool showScrollBar;
[HideInInspector] [SerializeField] private Scrollbar scrollbar;
#endregion
#region Serialized Fields - Internal (Hidden in Inspector)
[HideInInspector] [SerializeField] private string _layoutManagerTypeName;
[SerializeReference] private LayoutManager layoutManager;
[HideInInspector] [SerializeField] private string _scrollerTypeName;
[HideInInspector] [SerializeReference] private Scroller scroller;
#endregion
#region Private Fields
private ViewProvider viewProvider;
private int startIndex;
private int endIndex;
private int currentIndex;
#endregion
#region Public Properties - Layout Settings
/// <summary>
/// 获取或设置列表的滚动方向(垂直、水平或自定义)
/// </summary>
public Direction Direction
{
get => direction;
set => direction = value;
}
/// <summary>
/// 获取或设置列表项的对齐方式(起始、居中或结束)
/// </summary>
public Alignment Alignment
{
get => alignment;
set => alignment = value;
}
/// <summary>
/// 获取或设置列表项之间的间距X轴和Y轴
/// </summary>
public Vector2 Spacing
{
get => spacing;
set => spacing = value;
}
/// <summary>
/// 获取或设置列表内容的内边距X轴和Y轴
/// </summary>
public Vector2 Padding
{
get => padding;
set => padding = value;
}
#endregion
#region Public Properties - Scroll Settings
/// <summary>
/// 获取或设置是否启用滚动功能
/// 启用时会激活 Scroller 组件并显示滚动条(如果配置了)
/// </summary>
public bool Scroll
{
get => scroll;
set
{
if (scroll == value) return;
scroll = value;
// 启/停 scroller如果存在
if (scroller != null)
{
// 如果 Scroller 是 MonoBehaviour可以启/停组件;否则回退到设置一个 Position/flags
scroller.enabled = scroll;
// 将当前的滚动相关配置下发到 scroller保持一致性
scroller.ScrollSpeed = scrollSpeed;
scroller.WheelSpeed = wheelSpeed;
scroller.Snap = snap;
}
// 更新 scrollbar 显示(只有在 showScrollBar 为 true 时才显示)
if (scrollbar != null)
{
scrollbar.gameObject.SetActive(showScrollBar && scroll);
}
// 如果启用/禁用滚动后需要调整布局或滚动条大小,刷新布局
RequestLayout();
}
}
/// <summary>
/// 获取或设置是否启用吸附功能
/// 启用时滚动停止后会自动对齐到最近的列表项
/// 注意:此功能依赖于 Scroll 属性,只有在滚动启用时才生效
/// </summary>
public bool Snap
{
get => snap;
set
{
// Snap 依赖于 Scroll与原逻辑保持一致
bool newSnap = value & scroll;
if (snap == newSnap) return;
snap = newSnap;
if (scroller != null)
{
scroller.Snap = snap;
}
// 如果开启了 snap可以选做立即对齐到最近项
// if (snap && scroller != null) SnapToNearestItem();
}
}
/// <summary>
/// 获取或设置滚动速度范围1-50
/// 值越大,滚动响应越快
/// </summary>
public float ScrollSpeed
{
get => scrollSpeed;
set
{
if (Mathf.Approximately(scrollSpeed, value)) return;
scrollSpeed = value;
if (scroller != null)
{
scroller.ScrollSpeed = scrollSpeed;
}
}
}
/// <summary>
/// 获取或设置鼠标滚轮的滚动速度范围10-50
/// 值越大,滚轮滚动的距离越大
/// </summary>
public float WheelSpeed
{
get => wheelSpeed;
set
{
if (Mathf.Approximately(wheelSpeed, value)) return;
wheelSpeed = value;
if (scroller != null)
{
scroller.WheelSpeed = wheelSpeed;
}
}
}
#endregion
#region Public Properties - Components
/// <summary>
/// 获取或设置 ViewHolder 模板数组
/// 用于创建和复用列表项视图
/// </summary>
public ViewHolder[] Templates
{
get => templates;
set => templates = value;
}
/// <summary>
/// 获取内容容器的 RectTransform
/// 所有列表项都会作为此容器的子对象
/// </summary>
public RectTransform Content
{
get
{
if (content == null)
{
content = transform.GetChild(0).GetComponent<RectTransform>();
}
return content;
}
}
/// <summary>
/// 获取滚动条组件
/// </summary>
public Scrollbar Scrollbar => scrollbar;
/// <summary>
/// 获取滚动控制器组件
/// </summary>
public Scroller Scroller => scroller;
/// <summary>
/// 获取视图提供器
/// 负责创建、回收和管理 ViewHolder 实例
/// 根据模板数量自动选择 SimpleViewProvider 或 MixedViewProvider
/// </summary>
public ViewProvider ViewProvider
{
get
{
if (viewProvider == null)
{
viewProvider = templates.Length > 1
? new MixedViewProvider(this, templates)
: new SimpleViewProvider(this, templates);
}
return viewProvider;
}
}
#endregion
#region Public Properties - State
/// <summary>
/// 获取或设置当前绑定的适配器
/// 适配器负责提供数据和创建 ViewHolder
/// </summary>
public IAdapter RecyclerViewAdapter { get; set; }
/// <summary>
/// 获取或设置当前显示的列表项索引
/// </summary>
public int CurrentIndex
{
get => currentIndex;
set => currentIndex = value;
}
#endregion
#region Events
/// <summary>
/// 当前索引改变时触发的事件
/// 参数为新的索引值
/// </summary>
public Action<int> OnIndexChanged;
/// <summary>
/// 滚动位置改变时触发的事件
/// </summary>
public Action OnScrollValueChanged;
#endregion
#region Unity Lifecycle
private void Awake()
{
InitializeTemplates();
ConfigureScroller();
ConfigureScrollbar();
}
#endregion
#region Initialization
private void InitializeTemplates()
{
if (templates == null) return;
for (int i = 0; i < templates.Length; i++)
{
if (templates[i] != null)
{
templates[i].gameObject.SetActive(false);
}
}
}
private void ConfigureScroller()
{
if (scroller == null) return;
scroller.ScrollSpeed = scrollSpeed;
scroller.WheelSpeed = wheelSpeed;
scroller.Snap = snap;
scroller.OnValueChanged.AddListener(OnScrollChanged);
scroller.OnMoveStoped.AddListener(OnMoveStoped);
}
private void ConfigureScrollbar()
{
if (!showScrollBar || scrollbar == null) return;
scrollbar.gameObject.SetActive(scroll);
scrollbar.onValueChanged.AddListener(OnScrollbarChanged);
var scrollbarEx = scrollbar.gameObject.GetComponent<ScrollbarEx>();
if (scrollbarEx == null)
{
scrollbarEx = scrollbar.gameObject.AddComponent<ScrollbarEx>();
}
scrollbarEx.OnDragEnd = OnScrollbarDragEnd;
}
#endregion
#region Public Methods - Setup
/// <summary>
/// 设置数据适配器并初始化布局管理器
/// </summary>
/// <param name="adapter">要绑定的适配器实例</param>
public void SetAdapter(IAdapter adapter)
{
if (adapter == null)
{
Debug.LogError("Adapter cannot be null");
return;
}
RecyclerViewAdapter = adapter;
ViewProvider.Adapter = adapter;
ViewProvider.LayoutManager = layoutManager;
layoutManager.RecyclerView = this;
layoutManager.Adapter = adapter;
layoutManager.ViewProvider = viewProvider;
layoutManager.Direction = direction;
layoutManager.Alignment = alignment;
layoutManager.Spacing = spacing;
layoutManager.Padding = padding;
}
/// <summary>
/// 重置列表状态
/// 清空所有 ViewHolder 并将滚动位置重置为起始位置
/// </summary>
public void Reset()
{
viewProvider?.Reset();
if (scroller != null)
{
scroller.Position = 0;
}
if (scrollbar != null)
{
scrollbar.SetValueWithoutNotify(0);
}
}
#endregion
#region Public Methods - Layout
/// <summary>
/// 刷新列表显示
/// 清空当前所有 ViewHolder 并根据当前滚动位置重新创建可见项
/// </summary>
public void Refresh()
{
ViewProvider.Clear();
startIndex = layoutManager.GetStartIndex();
endIndex = layoutManager.GetEndIndex();
for (int i = startIndex; i <= endIndex; i += layoutManager.Unit)
{
ViewProvider.CreateViewHolder(i);
}
layoutManager.DoItemAnimation();
}
/// <summary>
/// 请求重新布局
/// 重新计算内容大小、视口大小,并更新滚动条显示状态
/// </summary>
public void RequestLayout()
{
layoutManager.SetContentSize();
if (scroller == null) return;
scroller.Direction = direction;
scroller.ViewSize = layoutManager.ViewportSize;
scroller.ContentSize = layoutManager.ContentSize;
UpdateScrollbarVisibility();
}
#endregion
#region Public Methods - Scrolling
/// <summary>
/// 获取当前的滚动位置
/// </summary>
/// <returns>当前滚动位置值,如果没有 Scroller 则返回 0</returns>
public float GetScrollPosition()
{
return scroller != null ? scroller.Position : 0;
}
/// <summary>
/// 滚动到指定索引的列表项
/// </summary>
/// <param name="index">目标列表项的索引</param>
/// <param name="smooth">是否使用平滑滚动动画</param>
public void ScrollTo(int index, bool smooth = false)
{
if (!scroll || scroller == null) return;
scroller.ScrollTo(layoutManager.IndexToPosition(index), smooth);
if (!smooth)
{
Refresh();
}
UpdateCurrentIndex(index);
}
/// <summary>
/// 滚动到指定索引的列表项,并使用指定的对齐方式
/// </summary>
/// <param name="index">目标列表项的索引</param>
/// <param name="alignment">对齐方式(起始、居中或结束)</param>
/// <param name="offset">额外的偏移量</param>
/// <param name="smooth">是否使用平滑滚动动画</param>
/// <param name="duration">滚动动画持续时间(秒)</param>
public void ScrollToWithAlignment(int index, ScrollAlignment alignment, float offset = 0f, bool smooth = false, float duration = 0.3f)
{
if (!scroll || scroller == null) return;
float targetPosition = CalculateScrollPositionWithAlignment(index, alignment, offset);
if (UGListExtensions.DebugScrollTo)
{
Debug.Log($"[RecyclerView] ScrollToWithAlignment: index={index}, alignment={alignment}, offset={offset}, targetPosition={targetPosition}, maxPosition={scroller.MaxPosition}");
}
if (duration > 0 && smooth)
{
scroller.ScrollTo(targetPosition, true);
}
else
{
scroller.ScrollTo(targetPosition, smooth);
}
if (!smooth)
{
Refresh();
}
UpdateCurrentIndex(index);
}
private float CalculateScrollPositionWithAlignment(int index, ScrollAlignment alignment, float offset)
{
if (RecyclerViewAdapter == null || index < 0 || index >= RecyclerViewAdapter.GetItemCount())
{
return scroller.Position;
}
float itemSize = GetItemSize(index);
float viewportLength = direction == Direction.Vertical ? layoutManager.ViewportSize.y : layoutManager.ViewportSize.x;
float contentLength = direction == Direction.Vertical ? layoutManager.ContentSize.y : layoutManager.ContentSize.x;
// Calculate the raw position of the item (without any clamping)
float itemPosition = CalculateRawItemPosition(index);
float targetPosition = alignment switch
{
ScrollAlignment.Start => itemPosition,
ScrollAlignment.Center => itemPosition - (viewportLength - itemSize) / 2f,
ScrollAlignment.End => itemPosition - viewportLength + itemSize,
_ => itemPosition
};
// Apply custom offset
targetPosition += offset;
if (UGListExtensions.DebugScrollTo)
{
Debug.Log($"[RecyclerView] CalculateScrollPosition: index={index}, itemPosition={itemPosition}, itemSize={itemSize}, viewportLength={viewportLength}, contentLength={contentLength}, targetPosition={targetPosition}, maxPosition={scroller.MaxPosition}");
}
// Clamp to valid scroll range
return Mathf.Clamp(targetPosition, 0, scroller.MaxPosition);
}
private float CalculateRawItemPosition(int index)
{
// Get spacing based on direction
Vector2 spacing = layoutManager.Spacing;
Vector2 padding = layoutManager.Padding;
float itemSize = GetItemSize(index);
float spacingValue = direction == Direction.Vertical ? spacing.y : spacing.x;
float paddingValue = direction == Direction.Vertical ? padding.y : padding.x;
// Calculate raw position without clamping
return index * (itemSize + spacingValue) + paddingValue;
}
private float GetItemSize(int index)
{
Vector2 itemSize = ViewProvider.CalculateViewSize(index);
return direction == Direction.Vertical ? itemSize.y : itemSize.x;
}
#endregion
#region Private Methods - Scroll Callbacks
private void OnScrollChanged(float position)
{
layoutManager.UpdateLayout();
UpdateScrollbarValue(position);
UpdateVisibleRange();
layoutManager.DoItemAnimation();
OnScrollValueChanged?.Invoke();
}
private void OnMoveStoped()
{
if (snap)
{
SnapToNearestItem();
}
}
private void OnScrollbarChanged(float ratio)
{
if (scroller != null)
{
scroller.ScrollToRatio(ratio);
}
}
private void OnScrollbarDragEnd()
{
if (scroller == null) return;
if (scroller.Position < scroller.MaxPosition && snap)
{
SnapToNearestItem();
}
}
#endregion
#region Private Methods - Scroll Helpers
private void UpdateScrollbarValue(float position)
{
if (scrollbar != null && scroller != null)
{
float ratio = scroller.MaxPosition > 0 ? position / scroller.MaxPosition : 0;
scrollbar.SetValueWithoutNotify(ratio);
}
}
private void UpdateVisibleRange()
{
// Handle start index
if (layoutManager.IsFullInvisibleStart(startIndex))
{
viewProvider.RemoveViewHolder(startIndex);
startIndex += layoutManager.Unit;
}
else if (layoutManager.IsFullVisibleStart(startIndex))
{
if (startIndex == 0)
{
// TODO: Implement refresh logic
}
else
{
startIndex -= layoutManager.Unit;
viewProvider.CreateViewHolder(startIndex);
}
}
// Handle end index
if (layoutManager.IsFullInvisibleEnd(endIndex))
{
viewProvider.RemoveViewHolder(endIndex);
endIndex -= layoutManager.Unit;
}
else if (layoutManager.IsFullVisibleEnd(endIndex))
{
if (endIndex >= viewProvider.GetItemCount() - layoutManager.Unit)
{
// TODO: Implement load more logic
}
else
{
endIndex += layoutManager.Unit;
viewProvider.CreateViewHolder(endIndex);
}
}
// Refresh if out of visible range
if (!layoutManager.IsVisible(startIndex) || !layoutManager.IsVisible(endIndex))
{
Refresh();
}
}
private void UpdateScrollbarVisibility()
{
if (scrollbar == null || scroller == null || layoutManager.ContentSize == Vector2.zero)
return;
bool shouldShow = ShouldShowScrollbar();
scrollbar.gameObject.SetActive(shouldShow);
if (shouldShow)
{
ConfigureScrollbarDirection();
ConfigureScrollbarSize();
}
}
private bool ShouldShowScrollbar()
{
if (direction == Direction.Custom) return false;
if (direction == Direction.Vertical)
{
return layoutManager.ContentSize.y > layoutManager.ViewportSize.y;
}
else // Horizontal
{
return layoutManager.ContentSize.x > layoutManager.ViewportSize.x;
}
}
private void ConfigureScrollbarDirection()
{
scrollbar.direction = direction == Direction.Vertical
? Scrollbar.Direction.TopToBottom
: Scrollbar.Direction.LeftToRight;
}
private void ConfigureScrollbarSize()
{
if (direction == Direction.Vertical)
{
scrollbar.size = scroller.ViewSize.y / scroller.ContentSize.y;
}
else
{
scrollbar.size = scroller.ViewSize.x / scroller.ContentSize.x;
}
}
private void SnapToNearestItem()
{
int index = layoutManager.PositionToIndex(GetScrollPosition());
ScrollTo(index, true);
}
private void UpdateCurrentIndex(int index)
{
if (RecyclerViewAdapter == null) return;
int itemCount = RecyclerViewAdapter.GetItemCount();
index %= itemCount;
index = index < 0 ? itemCount + index : index;
if (currentIndex != index)
{
currentIndex = index;
OnIndexChanged?.Invoke(currentIndex);
}
}
#endregion
}
}