diff --git a/Runtime/RecyclerView/Adapter/Adapter.cs b/Runtime/RecyclerView/Adapter/Adapter.cs index 78cef3a..e3f83a3 100644 --- a/Runtime/RecyclerView/Adapter/Adapter.cs +++ b/Runtime/RecyclerView/Adapter/Adapter.cs @@ -44,7 +44,7 @@ namespace AlicizaX.UI public Adapter(RecyclerView recyclerView, List list) { this.recyclerView = recyclerView; - this.list = list; + this.list = list ?? new List(); } public virtual int GetItemCount() @@ -68,10 +68,19 @@ namespace AlicizaX.UI if (!TryGetBindData(index, out var data)) return; string viewName = GetViewName(index); - Action defaultClickAction = CreateItemClickAction(index, data); + viewHolder.AdvanceBindingVersion(); + viewHolder.DataIndex = index; if (TryGetOrCreateItemRender(viewHolder, viewName, out var itemRender)) { - itemRender.Bind(data, index, defaultClickAction); + if (itemRender is ITypedItemRender typedItemRender) + { + typedItemRender.BindData(data, index); + } + else + { + itemRender.Bind(data, index); + } + itemRender.UpdateSelection(index == choiceIndex); return; } @@ -90,24 +99,97 @@ namespace AlicizaX.UI itemRender.UpdateSelection(false); itemRender.Unbind(); } - - viewHolder.ClearInteractionCallbacks(); } public virtual void NotifyDataChanged() { + CoerceChoiceIndex(); recyclerView.RequestLayout(); recyclerView.Refresh(); } public virtual void SetList(List list) { - this.list = list; + this.list = list ?? new List(); recyclerView.Reset(); NotifyDataChanged(); } - public void RegisterItemRender(string viewName = "") where TItemRender : IItemRender + public virtual void NotifyItemChanged(int index, bool relayout = false) + { + if (index < 0 || index >= GetRealCount()) + { + return; + } + + CoerceChoiceIndex(); + if (relayout) + { + recyclerView.RequestLayout(); + recyclerView.Refresh(); + return; + } + + recyclerView.RebindVisibleDataIndex(index); + } + + public virtual void NotifyItemRangeChanged(int index, int count, bool relayout = false) + { + if (count <= 0 || index < 0 || index >= GetRealCount()) + { + return; + } + + CoerceChoiceIndex(); + if (relayout) + { + recyclerView.RequestLayout(); + recyclerView.Refresh(); + return; + } + + recyclerView.RebindVisibleDataRange(index, count); + } + + public virtual void NotifyItemInserted(int index) + { + CoerceChoiceIndex(); + recyclerView.RequestLayout(); + recyclerView.Refresh(); + } + + public virtual void NotifyItemRangeInserted(int index, int count) + { + if (count <= 0) + { + return; + } + + CoerceChoiceIndex(); + recyclerView.RequestLayout(); + recyclerView.Refresh(); + } + + public virtual void NotifyItemRemoved(int index) + { + CoerceChoiceIndex(); + recyclerView.RequestLayout(); + recyclerView.Refresh(); + } + + public virtual void NotifyItemRangeRemoved(int index, int count) + { + if (count <= 0) + { + return; + } + + CoerceChoiceIndex(); + recyclerView.RequestLayout(); + recyclerView.Refresh(); + } + + public void RegisterItemRender(string viewName = "") where TItemRender : ItemRenderBase { RegisterItemRender(typeof(TItemRender), viewName); } @@ -166,25 +248,53 @@ namespace AlicizaX.UI public void Add(T item) { + if (list == null) + { + list = new List(); + } + list.Add(item); - NotifyDataChanged(); + NotifyItemInserted(list.Count - 1); } public void AddRange(IEnumerable collection) { + if (collection == null) + { + return; + } + + int startIndex = list.Count; list.AddRange(collection); + if (collection is ICollection itemCollection) + { + NotifyItemRangeInserted(startIndex, itemCollection.Count); + return; + } + NotifyDataChanged(); } public void Insert(int index, T item) { list.Insert(index, item); - NotifyDataChanged(); + NotifyItemInserted(index); } public void InsertRange(int index, IEnumerable collection) { + if (collection == null) + { + return; + } + list.InsertRange(index, collection); + if (collection is ICollection itemCollection) + { + NotifyItemRangeInserted(index, itemCollection.Count); + return; + } + NotifyDataChanged(); } @@ -199,13 +309,13 @@ namespace AlicizaX.UI if (index < 0 || index >= GetItemCount()) return; list.RemoveAt(index); - NotifyDataChanged(); + NotifyItemRemoved(index); } public void RemoveRange(int index, int count) { list.RemoveRange(index, count); - NotifyDataChanged(); + NotifyItemRangeRemoved(index, count); } public void RemoveAll(Predicate match) @@ -216,8 +326,14 @@ namespace AlicizaX.UI public void Clear() { + if (list == null || list.Count == 0) + { + return; + } + + int count = list.Count; list.Clear(); - NotifyDataChanged(); + NotifyItemRangeRemoved(0, count); } public void Reverse(int index, int count) @@ -240,6 +356,20 @@ namespace AlicizaX.UI protected void SetChoiceIndex(int index) { + int itemCount = GetRealCount(); + if (itemCount <= 0) + { + index = -1; + } + else if (index >= itemCount) + { + index = itemCount - 1; + } + else if (index < -1) + { + index = -1; + } + if (index == choiceIndex) return; if (choiceIndex != -1 && TryGetViewHolder(choiceIndex, out var oldHolder)) @@ -255,12 +385,6 @@ namespace AlicizaX.UI } } - private bool TryGetViewHolder(int index, out ViewHolder viewHolder) - { - viewHolder = recyclerView.ViewProvider.GetViewHolder(index); - return viewHolder != null; - } - protected virtual bool TryGetBindData(int index, out T data) { if (list == null || index < 0 || index >= list.Count) @@ -273,9 +397,10 @@ namespace AlicizaX.UI return true; } - protected virtual Action CreateItemClickAction(int index, T data) + private bool TryGetViewHolder(int index, out ViewHolder viewHolder) { - return () => { SetChoiceIndex(index); }; + viewHolder = recyclerView.ViewProvider.GetViewHolderByDataIndex(index); + return viewHolder != null; } private bool TryGetItemRenderDefinition(string viewName, out ItemRenderResolver.ItemRenderDefinition definition) @@ -321,6 +446,20 @@ namespace AlicizaX.UI return false; } + private static void ReleaseItemRender(ItemRenderEntry entry) + { + if (entry?.ItemRender == null) + { + return; + } + + entry.ItemRender.Unbind(); + if (entry.ItemRender is ItemRenderBase itemRender) + { + itemRender.Detach(); + } + } + private bool TryGetOrCreateItemRender(ViewHolder viewHolder, string viewName, out IItemRender itemRender) { if (viewHolder == null) @@ -337,7 +476,7 @@ namespace AlicizaX.UI return true; } - entry.ItemRender?.Unbind(); + ReleaseItemRender(entry); viewHolder.Destroyed -= OnViewHolderDestroyed; itemRenders.Remove(viewHolder); } @@ -348,7 +487,7 @@ namespace AlicizaX.UI return false; } - itemRender = definition.Create(viewHolder); + itemRender = definition.Create(viewHolder, recyclerView, this, SetChoiceIndex); itemRenders[viewHolder] = new ItemRenderEntry(viewName, itemRender); viewHolder.Destroyed += OnViewHolderDestroyed; return true; @@ -363,7 +502,7 @@ namespace AlicizaX.UI { foreach (var pair in itemRenders) { - pair.Value.ItemRender?.Unbind(); + ReleaseItemRender(pair.Value); if (pair.Key != null) { pair.Key.Destroyed -= OnViewHolderDestroyed; @@ -388,7 +527,7 @@ namespace AlicizaX.UI continue; } - pair.Value.ItemRender?.Unbind(); + ReleaseItemRender(pair.Value); pair.Key.Destroyed -= OnViewHolderDestroyed; viewHoldersToRemove ??= new List(); viewHoldersToRemove.Add(pair.Key); @@ -413,7 +552,26 @@ namespace AlicizaX.UI } viewHolder.Destroyed -= OnViewHolderDestroyed; - itemRenders.Remove(viewHolder); + if (itemRenders.TryGetValue(viewHolder, out var entry)) + { + ReleaseItemRender(entry); + itemRenders.Remove(viewHolder); + } + } + + private void CoerceChoiceIndex() + { + int itemCount = GetRealCount(); + if (itemCount <= 0) + { + SetChoiceIndex(-1); + return; + } + + if (choiceIndex >= itemCount) + { + SetChoiceIndex(itemCount - 1); + } } } } diff --git a/Runtime/RecyclerView/Adapter/GroupAdapter.cs b/Runtime/RecyclerView/Adapter/GroupAdapter.cs index 736bd7d..09dd9d5 100644 --- a/Runtime/RecyclerView/Adapter/GroupAdapter.cs +++ b/Runtime/RecyclerView/Adapter/GroupAdapter.cs @@ -28,35 +28,47 @@ namespace AlicizaX.UI public override string GetViewName(int index) { - return showList[index].TemplateName; + return index >= 0 && index < showList.Count + ? showList[index].TemplateName + : string.Empty; } public override void NotifyDataChanged() { - foreach (var data in list) + if (string.IsNullOrEmpty(groupViewName)) { - CreateGroup(data.Type); + throw new InvalidOperationException("GroupAdapter requires a non-empty groupViewName."); } - var groupList = showList.FindAll(data => data.TemplateName == groupViewName); - for (int i = 0; i < groupList.Count; i++) + if (list == null) { - int index = showList.IndexOf(groupList[i]); - Collapse(index); - if (groupList[i].Expanded) + showList.Clear(); + base.NotifyDataChanged(); + return; + } + + for (int i = 0; i < list.Count; i++) + { + CreateGroup(list[i].Type); + } + + for (int i = 0; i < showList.Count; i++) + { + TData group = showList[i]; + if (group.TemplateName != groupViewName) { - Expand(index); - } - } - - foreach (var group in groupList) - { - if (list.FindAll(data => data.Type == group.Type).Count == 0) - { - showList.Remove(group); + continue; + } + + Collapse(i); + if (group.Expanded) + { + Expand(i); + i += CountItemsForType(group.Type); } } + RemoveEmptyGroups(); base.NotifyDataChanged(); } @@ -82,14 +94,45 @@ namespace AlicizaX.UI public void Expand(int index) { - var expandList = list.FindAll(data => data.Type == showList[index].Type); - showList.InsertRange(index + 1, expandList); + if (list == null || index < 0 || index >= showList.Count) + { + return; + } + + int type = showList[index].Type; + for (int i = 0; i < list.Count; i++) + { + if (list[i].Type == type) + { + showList.Insert(index + 1, list[i]); + index++; + } + } } public void Collapse(int index) { - var collapseList = showList.FindAll(data => data.Type == showList[index].Type && data.TemplateName != groupViewName); - showList.RemoveRange(index + 1, collapseList.Count); + if (index < 0 || index >= showList.Count) + { + return; + } + + int type = showList[index].Type; + int removeCount = 0; + for (int i = index + 1; i < showList.Count; i++) + { + if (showList[i].TemplateName == groupViewName || showList[i].Type != type) + { + break; + } + + removeCount++; + } + + if (removeCount > 0) + { + showList.RemoveRange(index + 1, removeCount); + } } protected override bool TryGetBindData(int index, out TData data) @@ -104,19 +147,58 @@ namespace AlicizaX.UI return true; } - protected override Action CreateItemClickAction(int index, TData data) + public void Activate(int index) { - return () => + if (index < 0 || index >= showList.Count) { - if (data.TemplateName == groupViewName) + return; + } + + TData data = showList[index]; + if (data.TemplateName == groupViewName) + { + data.Expanded = !data.Expanded; + NotifyDataChanged(); + return; + } + + SetChoiceIndex(index); + } + + private int CountItemsForType(int type) + { + if (list == null) + { + return 0; + } + + int count = 0; + for (int i = 0; i < list.Count; i++) + { + if (list[i].Type == type) { - data.Expanded = !data.Expanded; - NotifyDataChanged(); - return; + count++; + } + } + + return count; + } + + private void RemoveEmptyGroups() + { + for (int i = showList.Count - 1; i >= 0; i--) + { + TData group = showList[i]; + if (group.TemplateName != groupViewName) + { + continue; } - SetChoiceIndex(index); - }; + if (CountItemsForType(group.Type) == 0) + { + showList.RemoveAt(i); + } + } } } } diff --git a/Runtime/RecyclerView/Adapter/IAdapter.cs b/Runtime/RecyclerView/Adapter/IAdapter.cs index df32630..1b75266 100644 --- a/Runtime/RecyclerView/Adapter/IAdapter.cs +++ b/Runtime/RecyclerView/Adapter/IAdapter.cs @@ -1,41 +1,17 @@ namespace AlicizaX.UI { - /// - /// RecyclerView 适配器接口,负责提供数据和绑定视图 - /// public interface IAdapter { - /// - /// 获取列表项总数(包括循环或分组后的虚拟数量) - /// - /// 列表项总数 int GetItemCount(); - /// - /// 获取实际数据项数量(不包括循环或分组的虚拟数量) - /// - /// 实际数据项数量 int GetRealCount(); - /// - /// 获取指定索引位置的视图名称,用于视图类型区分 - /// - /// 列表项索引 - /// 视图名称 string GetViewName(int index); - /// - /// 绑定视图持有者与数据 - /// - /// 视图持有者 - /// 数据索引 void OnBindViewHolder(ViewHolder viewHolder, int index); void OnRecycleViewHolder(ViewHolder viewHolder); - /// - /// 通知数据已更改,触发视图刷新 - /// void NotifyDataChanged(); } } diff --git a/Runtime/RecyclerView/Adapter/ItemRender.cs b/Runtime/RecyclerView/Adapter/ItemRender.cs index 3ae31d9..caa4158 100644 --- a/Runtime/RecyclerView/Adapter/ItemRender.cs +++ b/Runtime/RecyclerView/Adapter/ItemRender.cs @@ -1,108 +1,364 @@ using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Reflection; +using UnityEngine.EventSystems; namespace AlicizaX.UI { - public interface IItemRender + /// + /// 定义 ItemRender 的基础绑定与解绑协议。 + /// + internal interface IItemRender { - void Bind(object data, int index, Action defaultClickAction); + /// + /// 将指定数据绑定到当前渲染实例。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + void Bind(object data, int index); + /// + /// 更新当前渲染实例的选中状态。 + /// + /// 是否处于选中状态。 void UpdateSelection(bool selected); + /// + /// 清理当前渲染实例上的绑定状态。 + /// void Unbind(); } - internal interface IItemRenderInitializer + /// + /// 定义带强类型数据绑定能力的 ItemRender 协议。 + /// + /// 列表数据类型。 + internal interface ITypedItemRender : IItemRender { - void Reset(ViewHolder viewHolder); + /// + /// 使用强类型数据执行绑定。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + void BindData(TData data, int index); } - public abstract class ItemRender : IItemRender - , IItemRenderInitializer + /// + /// 提供 ItemRender 的公共基类,封装框架内部的绑定生命周期入口。 + /// + public abstract class ItemRenderBase : IItemRender + { + /// + /// 将渲染实例附加到指定的视图持有者。 + /// + /// 目标视图持有者。 + /// 所属的 RecyclerView。 + /// 当前使用的适配器。 + /// 选中项变更回调。 + internal abstract void Attach(ViewHolder viewHolder, RecyclerView recyclerView, IAdapter adapter, Action selectionHandler); + + /// + /// 将渲染实例从当前视图持有者上分离。 + /// + internal abstract void Detach(); + + /// + /// 以对象形式绑定数据。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + internal abstract void BindObject(object data, int index); + + /// + /// 更新内部记录的选中状态。 + /// + /// 是否处于选中状态。 + internal abstract void UpdateSelectionInternal(bool selected); + + /// + /// 清理当前绑定产生的临时状态。 + /// + internal abstract void UnbindInternal(); + + /// + /// 由框架内部调用,将对象数据绑定到当前渲染实例。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + void IItemRender.Bind(object data, int index) + { + BindObject(data, index); + } + + /// + /// 由框架内部调用,更新当前渲染实例的选中状态。 + /// + /// 是否处于选中状态。 + void IItemRender.UpdateSelection(bool selected) + { + UpdateSelectionInternal(selected); + } + + /// + /// 由框架内部调用,清理当前渲染实例的绑定状态。 + /// + void IItemRender.Unbind() + { + UnbindInternal(); + } + } + + /// + /// 提供带强类型数据与视图持有者的列表项渲染基类。 + /// + /// 列表数据类型。 + /// 视图持有者类型。 + public abstract class ItemRender : ItemRenderBase, IItemInteractionHost, ITypedItemRender where THolder : ViewHolder { - private Action defaultClickAction; + /// + /// 当前持有者上的交互代理组件。 + /// + private ItemInteractionProxy interactionProxy; + + /// + /// 当前项被选中时的回调委托。 + /// + private Action selectionHandler; + + /// + /// 上一次绑定到交互代理的交互标记。 + /// + private ItemInteractionFlags cachedInteractionFlags; + + /// + /// 标记交互代理是否已经完成当前配置绑定。 + /// + private bool interactionBindingActive; + + /// + /// 获取当前附加的强类型视图持有者。 + /// protected THolder Holder { get; private set; } + /// + /// 获取当前所属的 RecyclerView。 + /// + protected RecyclerView RecyclerView { get; private set; } + + /// + /// 获取当前所属的适配器。 + /// + protected IAdapter Adapter { get; private set; } + + /// + /// 获取当前绑定的数据对象。 + /// protected TData CurrentData { get; private set; } + /// + /// 获取当前绑定的数据索引。 + /// protected int CurrentIndex { get; private set; } = -1; + /// + /// 获取当前绑定的布局索引。 + /// + protected int CurrentLayoutIndex { get; private set; } = -1; + + /// + /// 获取当前项是否处于选中状态。 + /// protected bool IsSelected { get; private set; } - public void Bind(object data, int index, Action defaultClickAction) - { - EnsureHolder(); + /// + /// 获取当前绑定版本号,用于校验异步回调是否仍然有效。 + /// + protected uint CurrentBindingVersion { get; private set; } + /// + /// 获取当前渲染项支持的交互能力。 + /// + public virtual ItemInteractionFlags InteractionFlags => ItemInteractionFlags.None; + + /// + /// 由框架交互代理读取当前渲染项的交互能力。 + /// + ItemInteractionFlags IItemInteractionHost.InteractionFlags => InteractionFlags; + + /// + /// 获取键盘或手柄导航时采用的移动选项。 + /// + protected virtual RecyclerNavigationOptions NavigationOptions => RecyclerNavigationOptions.Circular; + + /// + /// 以对象形式绑定数据并执行强类型校验。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + internal override void BindObject(object data, int index) + { if (data is not TData itemData) { throw new InvalidCastException( $"ItemRender '{GetType().Name}' expected data '{typeof(TData).Name}', but got '{data?.GetType().Name ?? "null"}'."); } - CurrentData = itemData; - CurrentIndex = index; - this.defaultClickAction = defaultClickAction; - Holder.SetInteractionCallbacks(HandleClick, HandlePointerEnter, HandlePointerExit); - Bind(itemData, index); + BindCore(itemData, index); } - public void UpdateSelection(bool selected) + /// + /// 由框架内部调用,使用强类型数据执行绑定。 + /// + /// 待绑定的数据对象。 + /// 当前数据索引。 + void ITypedItemRender.BindData(TData data, int index) + { + BindCore(data, index); + } + + /// + /// 更新内部选中状态并触发选中状态回调。 + /// + /// 是否处于选中状态。 + internal override void UpdateSelectionInternal(bool selected) { EnsureHolder(); IsSelected = selected; OnSelectionChanged(selected); } - public void Unbind() + /// + /// 清理当前绑定数据关联的状态,并重置内部缓存。 + /// + internal override void UnbindInternal() { - ResetState(); + if (Holder != null) + { + if (IsSelected) + { + IsSelected = false; + OnSelectionChanged(false); + } + + OnClear(); + if (interactionProxy != null) + { + interactionProxy.Clear(); + interactionBindingActive = false; + cachedInteractionFlags = ItemInteractionFlags.None; + } + + Holder.DataIndex = -1; + } + + CurrentData = default; + CurrentIndex = -1; + CurrentLayoutIndex = -1; + CurrentBindingVersion = 0; + IsSelected = false; } - protected abstract void Bind(TData data, int index); + /// + /// 判断指定绑定版本是否仍与当前持有者保持一致。 + /// + /// 待校验的绑定版本号。 + /// 版本号仍然有效时返回 ;否则返回 + protected bool IsBindingCurrent(uint bindingVersion) + { + return Holder != null && + CurrentBindingVersion != 0 && + CurrentBindingVersion == bindingVersion && + Holder.BindingVersion == bindingVersion; + } - protected virtual void OnHolderChanged() + /// + /// 执行一次完整的数据绑定流程。 + /// + /// 待绑定的强类型数据。 + /// 当前数据索引。 + private void BindCore(TData itemData, int index) + { + EnsureHolder(); + CurrentData = itemData; + CurrentIndex = index; + CurrentLayoutIndex = Holder.Index; + Holder.DataIndex = index; + CurrentBindingVersion = Holder.BindingVersion; + BindInteractionProxyIfNeeded(); + OnBind(itemData, index); + } + + /// + /// 每次当前持有者绑定到新的数据项时调用。 + /// 仅用于执行数据驱动的界面刷新,例如文本、图片与状态更新。 + /// 不要在此注册持有者级别的事件监听。 + /// + /// 当前绑定的数据对象。 + /// 当前数据索引。 + protected abstract void OnBind(TData data, int index); + + /// + /// 当当前渲染实例附加到持有者实例时调用。 + /// 这是持有者级生命周期,通常对同一组 render 与 holder 仅触发一次。 + /// 适合执行一次性的持有者初始化,例如注册按钮监听或挂接可复用交互组件。 + /// + protected virtual void OnHolderAttached() + { + interactionProxy = Holder.GetComponent(); + if (interactionProxy == null) + { + interactionProxy = Holder.gameObject.AddComponent(); + } + } + + /// + /// 当当前渲染实例即将从持有者实例分离时调用。 + /// 这是持有者级清理生命周期,通常对同一组 render 与 holder 仅触发一次。 + /// 适合执行一次性的持有者清理,例如注销按钮监听或释放附加阶段缓存的引用。 + /// + protected virtual void OnHolderDetached() { } + /// + /// 当当前项的选中状态发生变化时调用。 + /// 仅应在此更新选中态相关的界面表现。 + /// + /// 当前是否处于选中状态。 protected virtual void OnSelectionChanged(bool selected) { } + /// + /// 每次当前数据绑定被清理时调用。 + /// 这是绑定级清理生命周期,在复用过程中可能被多次触发。 + /// 适合在此重置由当前绑定数据产生的临时界面状态。 + /// protected virtual void OnClear() { } - protected virtual void OnClick() + /// + /// 通知外部选中当前数据项。 + /// + private void SelectCurrentItem() { + if (CurrentIndex >= 0) + { + selectionHandler?.Invoke(CurrentIndex); + } } - protected virtual void OnPointerEnter() - { - } - - protected virtual void OnPointerExit() - { - } - - private void HandleClick() - { - defaultClickAction?.Invoke(); - OnClick(); - } - - private void HandlePointerEnter() - { - OnPointerEnter(); - } - - private void HandlePointerExit() - { - OnPointerExit(); - } - - void IItemRenderInitializer.Reset(ViewHolder viewHolder) + /// + /// 将当前渲染实例附加到指定持有者,并初始化上下文引用。 + /// + /// 目标视图持有者。 + /// 所属的 RecyclerView。 + /// 当前使用的适配器。 + /// 选中项变更回调。 + internal override void Attach(ViewHolder viewHolder, RecyclerView recyclerView, IAdapter adapter, Action selectionHandler) { if (viewHolder == null) { @@ -115,11 +371,44 @@ namespace AlicizaX.UI $"RecyclerView item render '{GetType().FullName}' expects holder '{typeof(THolder).FullName}', but got '{viewHolder.GetType().FullName}'."); } - ResetState(); Holder = holder; - OnHolderChanged(); + RecyclerView = recyclerView; + Adapter = adapter; + this.selectionHandler = selectionHandler; + interactionBindingActive = false; + cachedInteractionFlags = ItemInteractionFlags.None; + OnHolderAttached(); } + /// + /// 将当前渲染实例从持有者上分离,并释放上下文引用。 + /// + internal override void Detach() + { + if (Holder == null) + { + return; + } + + OnHolderDetached(); + interactionProxy?.Clear(); + interactionProxy = null; + selectionHandler = null; + Holder = null; + RecyclerView = null; + Adapter = null; + CurrentData = default; + CurrentIndex = -1; + CurrentLayoutIndex = -1; + CurrentBindingVersion = 0; + IsSelected = false; + interactionBindingActive = false; + cachedInteractionFlags = ItemInteractionFlags.None; + } + + /// + /// 确保当前渲染实例已经绑定有效的视图持有者。 + /// private void EnsureHolder() { if (Holder == null) @@ -129,42 +418,279 @@ namespace AlicizaX.UI } } - private void ResetState() + /// + /// 按指定方向尝试移动焦点。 + /// + /// 焦点移动方向。 + /// 成功移动焦点时返回 ;否则返回 + private bool MoveFocus(MoveDirection direction) { - if (Holder != null) - { - if (IsSelected) - { - IsSelected = false; - OnSelectionChanged(false); - } + return RecyclerView != null && RecyclerView.NavigationController.TryMove(Holder, direction, NavigationOptions); + } - OnClear(); - Holder.ClearInteractionCallbacks(); + /// + /// 在需要时将当前渲染实例绑定到交互代理。 + /// + private void BindInteractionProxyIfNeeded() + { + if (interactionProxy == null) + { + return; } - CurrentData = default; - CurrentIndex = -1; - IsSelected = false; - defaultClickAction = null; + ItemInteractionFlags interactionFlags = InteractionFlags; + if (interactionBindingActive && cachedInteractionFlags == interactionFlags) + { + return; + } + + interactionProxy.Bind(this); + cachedInteractionFlags = interactionFlags; + interactionBindingActive = true; + } + + /// + /// 由交互代理转发点击事件。 + /// + /// 点击事件数据。 + void IItemInteractionHost.HandlePointerClick(PointerEventData eventData) + { + SelectCurrentItem(); + OnPointerClick(eventData); + } + + /// + /// 由交互代理转发指针进入事件。 + /// + /// 指针事件数据。 + void IItemInteractionHost.HandlePointerEnter(PointerEventData eventData) + { + OnPointerEnter(eventData); + } + + /// + /// 由交互代理转发指针离开事件。 + /// + /// 指针事件数据。 + void IItemInteractionHost.HandlePointerExit(PointerEventData eventData) + { + OnPointerExit(eventData); + } + + /// + /// 由交互代理转发选中事件。 + /// + /// 选中事件数据。 + void IItemInteractionHost.HandleSelect(BaseEventData eventData) + { + OnItemSelected(eventData); + } + + /// + /// 由交互代理转发取消选中事件。 + /// + /// 取消选中事件数据。 + void IItemInteractionHost.HandleDeselect(BaseEventData eventData) + { + OnItemDeselected(eventData); + } + + /// + /// 由交互代理转发导航移动事件。 + /// + /// 导航事件数据。 + void IItemInteractionHost.HandleMove(AxisEventData eventData) + { + if (!OnMove(eventData)) + { + MoveFocus(eventData.moveDir); + } + } + + /// + /// 由交互代理转发开始拖拽事件。 + /// + /// 拖拽事件数据。 + void IItemInteractionHost.HandleBeginDrag(PointerEventData eventData) + { + OnBeginDrag(eventData); + } + + /// + /// 由交互代理转发拖拽事件。 + /// + /// 拖拽事件数据。 + void IItemInteractionHost.HandleDrag(PointerEventData eventData) + { + OnDrag(eventData); + } + + /// + /// 由交互代理转发结束拖拽事件。 + /// + /// 拖拽事件数据。 + void IItemInteractionHost.HandleEndDrag(PointerEventData eventData) + { + OnEndDrag(eventData); + } + + /// + /// 由交互代理转发提交事件。 + /// + /// 提交事件数据。 + void IItemInteractionHost.HandleSubmit(BaseEventData eventData) + { + SelectCurrentItem(); + OnSubmit(eventData); + } + + /// + /// 由交互代理转发取消事件。 + /// + /// 取消事件数据。 + void IItemInteractionHost.HandleCancel(BaseEventData eventData) + { + OnCancel(eventData); + } + + /// + /// 当当前项收到点击事件时调用。 + /// + /// 点击事件数据。 + protected virtual void OnPointerClick(PointerEventData eventData) + { + } + + /// + /// 当指针进入当前项时调用。 + /// + /// 指针事件数据。 + protected virtual void OnPointerEnter(PointerEventData eventData) + { + } + + /// + /// 当指针离开当前项时调用。 + /// + /// 指针事件数据。 + protected virtual void OnPointerExit(PointerEventData eventData) + { + } + + /// + /// 当当前项被 EventSystem 选中时调用。 + /// + /// 选中事件数据。 + protected virtual void OnItemSelected(BaseEventData eventData) + { + } + + /// + /// 当当前项被 EventSystem 取消选中时调用。 + /// + /// 取消选中事件数据。 + protected virtual void OnItemDeselected(BaseEventData eventData) + { + } + + /// + /// 当当前项收到导航移动事件时调用。 + /// + /// 导航事件数据。 + /// 已自行处理导航事件时返回 ;否则返回 + protected virtual bool OnMove(AxisEventData eventData) + { + return false; + } + + /// + /// 当当前项开始被拖拽时调用。 + /// + /// 拖拽事件数据。 + protected virtual void OnBeginDrag(PointerEventData eventData) + { + } + + /// + /// 当当前项发生拖拽时调用。 + /// + /// 拖拽事件数据。 + protected virtual void OnDrag(PointerEventData eventData) + { + } + + /// + /// 当当前项结束拖拽时调用。 + /// + /// 拖拽事件数据。 + protected virtual void OnEndDrag(PointerEventData eventData) + { + } + + /// + /// 当当前项收到提交操作时调用。 + /// + /// 提交事件数据。 + protected virtual void OnSubmit(BaseEventData eventData) + { + } + + /// + /// 当当前项收到取消操作时调用。 + /// + /// 取消事件数据。 + protected virtual void OnCancel(BaseEventData eventData) + { } } + /// + /// 负责解析、缓存并创建 ItemRender 定义。 + /// internal static class ItemRenderResolver { + /// + /// 描述单个 ItemRender 的类型信息与创建方式。 + /// internal sealed class ItemRenderDefinition { - public ItemRenderDefinition(Type itemRenderType, Type holderType) + /// + /// 初始化一份 ItemRender 定义。 + /// + /// 渲染器运行时类型。 + /// 对应的持有者类型。 + /// 渲染器实例创建委托。 + public ItemRenderDefinition(Type itemRenderType, Type holderType, Func createInstance) { ItemRenderType = itemRenderType; HolderType = holderType; + this.createInstance = createInstance; } + /// + /// 获取渲染器运行时类型。 + /// public Type ItemRenderType { get; } + /// + /// 获取渲染器要求的持有者类型。 + /// public Type HolderType { get; } - public IItemRender Create(ViewHolder viewHolder) + /// + /// 用于创建渲染器实例的缓存委托。 + /// + private readonly Func createInstance; + + /// + /// 创建并初始化一个可用的 ItemRender 实例。 + /// + /// 目标视图持有者。 + /// 所属的 RecyclerView。 + /// 当前使用的适配器。 + /// 选中项变更回调。 + /// 已初始化完成的渲染器实例。 + public IItemRender Create(ViewHolder viewHolder, RecyclerView recyclerView, IAdapter adapter, Action selectionHandler) { if (viewHolder == null) { @@ -177,25 +703,27 @@ namespace AlicizaX.UI $"RecyclerView item render '{ItemRenderType.FullName}' expects holder '{HolderType.FullName}', but got '{viewHolder.GetType().FullName}'."); } - if (Activator.CreateInstance(ItemRenderType, true) is not IItemRender itemRender) + if (createInstance() is not ItemRenderBase itemRender) { throw new InvalidOperationException( $"RecyclerView item render '{ItemRenderType.FullName}' could not be created."); } - if (itemRender is not IItemRenderInitializer initializer) - { - throw new InvalidOperationException( - $"RecyclerView item render '{ItemRenderType.FullName}' must inherit from ItemRender."); - } - - initializer.Reset(viewHolder); + itemRender.Attach(viewHolder, recyclerView, adapter, selectionHandler); return itemRender; } } + /// + /// ItemRender 定义缓存表,键为渲染器类型。 + /// private static readonly Dictionary Definitions = new(); + /// + /// 获取指定渲染器类型对应的定义,不存在时自动创建并缓存。 + /// + /// 渲染器运行时类型。 + /// 与该类型对应的渲染器定义。 public static ItemRenderDefinition GetOrCreate(Type itemRenderType) { if (itemRenderType == null) @@ -213,6 +741,11 @@ namespace AlicizaX.UI return definition; } + /// + /// 为指定渲染器类型构建定义信息。 + /// + /// 渲染器运行时类型。 + /// 创建完成的渲染器定义。 private static ItemRenderDefinition CreateDefinition(Type itemRenderType) { if (itemRenderType.IsAbstract || @@ -242,9 +775,15 @@ namespace AlicizaX.UI $"RecyclerView item render '{itemRenderType.FullName}' must have a parameterless constructor."); } - return new ItemRenderDefinition(itemRenderType, holderType); + return new ItemRenderDefinition(itemRenderType, holderType, CreateFactory(constructor)); } + /// + /// 尝试从渲染器继承链中解析对应的持有者类型。 + /// + /// 渲染器运行时类型。 + /// 解析得到的持有者类型。 + /// 解析成功时返回 ;否则返回 private static bool TryGetHolderType(Type itemRenderType, out Type holderType) { for (Type current = itemRenderType; current != null && current != typeof(object); current = current.BaseType) @@ -261,5 +800,17 @@ namespace AlicizaX.UI holderType = null; return false; } + + /// + /// 基于无参构造函数创建渲染器实例工厂。 + /// + /// 渲染器的无参构造函数。 + /// 用于创建渲染器实例的委托。 + private static Func CreateFactory(ConstructorInfo constructor) + { + NewExpression newExpression = Expression.New(constructor); + UnaryExpression convertExpression = Expression.Convert(newExpression, typeof(IItemRender)); + return Expression.Lambda>(convertExpression).Compile(); + } } } diff --git a/Runtime/RecyclerView/Adapter/LoopAdapter.cs b/Runtime/RecyclerView/Adapter/LoopAdapter.cs index 69182bf..f8b324a 100644 --- a/Runtime/RecyclerView/Adapter/LoopAdapter.cs +++ b/Runtime/RecyclerView/Adapter/LoopAdapter.cs @@ -14,7 +14,7 @@ namespace AlicizaX.UI public override int GetItemCount() { - return int.MaxValue; + return GetRealCount() > 0 ? int.MaxValue : 0; } public override int GetRealCount() @@ -24,6 +24,11 @@ namespace AlicizaX.UI public override void OnBindViewHolder(ViewHolder viewHolder, int index) { + if (list == null || list.Count == 0) + { + return; + } + index %= list.Count; base.OnBindViewHolder(viewHolder, index); } diff --git a/Runtime/RecyclerView/Adapter/MixedAdapter.cs b/Runtime/RecyclerView/Adapter/MixedAdapter.cs index d156e48..2e4631a 100644 --- a/Runtime/RecyclerView/Adapter/MixedAdapter.cs +++ b/Runtime/RecyclerView/Adapter/MixedAdapter.cs @@ -14,7 +14,9 @@ namespace AlicizaX.UI public override string GetViewName(int index) { - return list[index].TemplateName; + return index >= 0 && list != null && index < list.Count + ? list[index].TemplateName + : string.Empty; } } } diff --git a/Runtime/RecyclerView/EaseUtil.cs b/Runtime/RecyclerView/EaseUtil.cs index 46ea76e..c0359b6 100644 --- a/Runtime/RecyclerView/EaseUtil.cs +++ b/Runtime/RecyclerView/EaseUtil.cs @@ -1,23 +1,12 @@ -using System; +using System; -/// -/// 缓动函数工具类 -/// 提供各种常用的缓动函数,用于实现平滑的动画效果 -/// 基于 https://easings.net/ 的标准缓动函数 -/// public class EaseUtil { - /// - /// 正弦缓入函数 - /// public static double EaseInSine(float x) { return 1 - Math.Cos(x * Math.PI / 2); } - /// - /// 正弦缓出函数 - /// public static double EaseOutSine(float x) { return Math.Sin(x * Math.PI / 2); diff --git a/Runtime/RecyclerView/Interaction.meta b/Runtime/RecyclerView/Interaction.meta new file mode 100644 index 0000000..8c7411b --- /dev/null +++ b/Runtime/RecyclerView/Interaction.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e63a2384482cb7b418bc1a4149b11742 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Interaction/IItemInteractionHost.cs b/Runtime/RecyclerView/Interaction/IItemInteractionHost.cs new file mode 100644 index 0000000..a3895c8 --- /dev/null +++ b/Runtime/RecyclerView/Interaction/IItemInteractionHost.cs @@ -0,0 +1,31 @@ +using UnityEngine.EventSystems; + +namespace AlicizaX.UI +{ + internal interface IItemInteractionHost + { + ItemInteractionFlags InteractionFlags { get; } + + void HandlePointerClick(PointerEventData eventData); + + void HandlePointerEnter(PointerEventData eventData); + + void HandlePointerExit(PointerEventData eventData); + + void HandleSelect(BaseEventData eventData); + + void HandleDeselect(BaseEventData eventData); + + void HandleMove(AxisEventData eventData); + + void HandleBeginDrag(PointerEventData eventData); + + void HandleDrag(PointerEventData eventData); + + void HandleEndDrag(PointerEventData eventData); + + void HandleSubmit(BaseEventData eventData); + + void HandleCancel(BaseEventData eventData); + } +} diff --git a/Runtime/RecyclerView/ObjectPool/IPooledObject.cs.meta b/Runtime/RecyclerView/Interaction/IItemInteractionHost.cs.meta similarity index 83% rename from Runtime/RecyclerView/ObjectPool/IPooledObject.cs.meta rename to Runtime/RecyclerView/Interaction/IItemInteractionHost.cs.meta index ee50c4b..c1d2907 100644 --- a/Runtime/RecyclerView/ObjectPool/IPooledObject.cs.meta +++ b/Runtime/RecyclerView/Interaction/IItemInteractionHost.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b174ccb64b3938c449d4a69a3262d8d5 +guid: b993c99fa9bf9634a8eb949a82efe103 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs b/Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs new file mode 100644 index 0000000..830d53e --- /dev/null +++ b/Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs @@ -0,0 +1,21 @@ +using System; + +namespace AlicizaX.UI +{ + [Flags] + public enum ItemInteractionFlags + { + None = 0, + PointerClick = 1 << 0, + PointerEnter = 1 << 1, + PointerExit = 1 << 2, + Select = 1 << 3, + Deselect = 1 << 4, + Move = 1 << 5, + BeginDrag = 1 << 6, + Drag = 1 << 7, + EndDrag = 1 << 8, + Submit = 1 << 9, + Cancel = 1 << 10, + } +} diff --git a/Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs.meta b/Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs.meta similarity index 83% rename from Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs.meta rename to Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs.meta index d7b2804..9a3af96 100644 --- a/Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs.meta +++ b/Runtime/RecyclerView/Interaction/ItemInteractionFlags.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 491a09d781095104989abb7a91424008 +guid: 264e45e52d936c44b96c6bb5eeaf4b98 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs b/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs new file mode 100644 index 0000000..37cc2f0 --- /dev/null +++ b/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs @@ -0,0 +1,205 @@ +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace AlicizaX.UI +{ + [DisallowMultipleComponent] + public sealed class ItemInteractionProxy : MonoBehaviour, + IPointerClickHandler, + IPointerEnterHandler, + IPointerExitHandler, + ISelectHandler, + IDeselectHandler, + IMoveHandler, + IBeginDragHandler, + IDragHandler, + IEndDragHandler, + ISubmitHandler, + ICancelHandler + { + private IItemInteractionHost host; + private ItemInteractionFlags flags; + private Selectable focusAnchor; + private RecyclerItemSelectable ownedSelectable; + private Scroller parentScroller; + + internal void Bind(IItemInteractionHost interactionHost) + { + host = interactionHost; + flags = interactionHost?.InteractionFlags ?? ItemInteractionFlags.None; + parentScroller = GetComponentInParent(); + EnsureFocusAnchor(); + + if (ownedSelectable != null) + { + bool requiresSelection = RequiresSelection(flags); + ownedSelectable.interactable = requiresSelection; + ownedSelectable.enabled = requiresSelection; + } + + InvalidateNavigationScope(); + } + + public void Clear() + { + host = null; + flags = ItemInteractionFlags.None; + parentScroller = null; + + if (ownedSelectable != null) + { + ownedSelectable.interactable = false; + ownedSelectable.enabled = false; + } + + InvalidateNavigationScope(); + } + + public Selectable GetSelectable() + { + EnsureFocusAnchor(); + return focusAnchor; + } + + public void OnPointerClick(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.PointerClick) != 0) + { + host?.HandlePointerClick(eventData); + } + } + + public void OnPointerEnter(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.PointerEnter) != 0) + { + host?.HandlePointerEnter(eventData); + } + } + + public void OnPointerExit(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.PointerExit) != 0) + { + host?.HandlePointerExit(eventData); + } + } + + public void OnSelect(BaseEventData eventData) + { + if ((flags & ItemInteractionFlags.Select) != 0) + { + host?.HandleSelect(eventData); + } + } + + public void OnDeselect(BaseEventData eventData) + { + if ((flags & ItemInteractionFlags.Deselect) != 0) + { + host?.HandleDeselect(eventData); + } + } + + public void OnMove(AxisEventData eventData) + { + if ((flags & ItemInteractionFlags.Move) != 0) + { + host?.HandleMove(eventData); + } + } + + public void OnBeginDrag(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.BeginDrag) != 0) + { + host?.HandleBeginDrag(eventData); + return; + } + + parentScroller?.OnBeginDrag(eventData); + } + + public void OnDrag(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.Drag) != 0) + { + host?.HandleDrag(eventData); + return; + } + + parentScroller?.OnDrag(eventData); + } + + public void OnEndDrag(PointerEventData eventData) + { + if ((flags & ItemInteractionFlags.EndDrag) != 0) + { + host?.HandleEndDrag(eventData); + return; + } + + parentScroller?.OnEndDrag(eventData); + } + + public void OnSubmit(BaseEventData eventData) + { + if ((flags & ItemInteractionFlags.Submit) != 0) + { + host?.HandleSubmit(eventData); + } + } + + public void OnCancel(BaseEventData eventData) + { + if ((flags & ItemInteractionFlags.Cancel) != 0) + { + host?.HandleCancel(eventData); + } + } + + private void EnsureFocusAnchor() + { + if (focusAnchor != null) + { + return; + } + + focusAnchor = GetComponent(); + if (focusAnchor != null) + { + return; + } + + ownedSelectable = GetComponent(); + if (ownedSelectable == null) + { + ownedSelectable = gameObject.AddComponent(); + } + + focusAnchor = ownedSelectable; + } + + private static bool RequiresSelection(ItemInteractionFlags interactionFlags) + { + const ItemInteractionFlags selectionFlags = + ItemInteractionFlags.Select | + ItemInteractionFlags.Deselect | + ItemInteractionFlags.Move | + ItemInteractionFlags.Submit | + ItemInteractionFlags.Cancel; + + return (interactionFlags & selectionFlags) != 0; + } + + [System.Diagnostics.Conditional("INPUTSYSTEM_SUPPORT")] + private void InvalidateNavigationScope() + { +#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION + var scope = GetComponentInParent(true); + scope?.InvalidateSelectableCache(); +#endif + } + } +} diff --git a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta b/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta new file mode 100644 index 0000000..9237f6f --- /dev/null +++ b/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac4f0b81367e72b408b7d4a0148d39c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs b/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs new file mode 100644 index 0000000..074a285 --- /dev/null +++ b/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs @@ -0,0 +1,16 @@ +using UnityEngine.UI; + +namespace AlicizaX.UI +{ + public sealed class RecyclerItemSelectable : Selectable + { + protected override void Awake() + { + base.Awake(); + transition = Transition.None; + Navigation disabledNavigation = navigation; + disabledNavigation.mode = Navigation.Mode.None; + navigation = disabledNavigation; + } + } +} diff --git a/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs.meta b/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs.meta new file mode 100644 index 0000000..9654a9e --- /dev/null +++ b/Runtime/RecyclerView/Interaction/RecyclerItemSelectable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 720e8e459a50e2443847a385966fd104 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Layout/CircleLayoutManager.cs b/Runtime/RecyclerView/Layout/CircleLayoutManager.cs index 7667219..d8f207a 100644 --- a/Runtime/RecyclerView/Layout/CircleLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/CircleLayoutManager.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using UnityEngine; namespace AlicizaX.UI @@ -21,9 +20,17 @@ namespace AlicizaX.UI public override Vector2 CalculateContentSize() { + int itemCount = adapter != null ? adapter.GetItemCount() : 0; + if (itemCount <= 0) + { + radius = 0f; + intervalAngle = 0f; + return viewportSize; + } + Vector2 size = viewProvider.CalculateViewSize(0); radius = (Mathf.Min(viewportSize.x, viewportSize.y) - Mathf.Min(size.x, size.y)) / 2f - Mathf.Max(padding.x, padding.y); - intervalAngle = adapter.GetItemCount() > 0 ? 360f / adapter.GetItemCount() : 0; + intervalAngle = 360f / itemCount; return viewportSize; } @@ -62,7 +69,7 @@ namespace AlicizaX.UI public override int GetEndIndex() { - return adapter.GetItemCount() - 1; + return adapter == null || adapter.GetItemCount() <= 0 ? -1 : adapter.GetItemCount() - 1; } public override bool IsFullVisibleStart(int index) => false; @@ -77,6 +84,11 @@ namespace AlicizaX.UI public override float IndexToPosition(int index) { + if (Mathf.Approximately(intervalAngle, 0f)) + { + return 0f; + } + float position = index * intervalAngle; return -position; @@ -84,13 +96,18 @@ namespace AlicizaX.UI public override int PositionToIndex(float position) { + if (Mathf.Approximately(intervalAngle, 0f)) + { + return 0; + } + int index = Mathf.RoundToInt(position / intervalAngle); return -index; } public override void DoItemAnimation() { - List viewHolders = viewProvider.ViewHolders; + var viewHolders = viewProvider.ViewHolders; for (int i = 0; i < viewHolders.Count; i++) { float angle = i * intervalAngle + initalAngle; diff --git a/Runtime/RecyclerView/Layout/GridLayoutManager.cs b/Runtime/RecyclerView/Layout/GridLayoutManager.cs index 92e5896..1ac2ac6 100644 --- a/Runtime/RecyclerView/Layout/GridLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/GridLayoutManager.cs @@ -1,4 +1,5 @@ using UnityEngine; +using UnityEngine.Serialization; namespace AlicizaX.UI { @@ -7,18 +8,28 @@ namespace AlicizaX.UI { private Vector2 cellSize; - [SerializeField] private int cellCounnt = 1; + [FormerlySerializedAs("cellCounnt")] + [SerializeField] private int cellCount = 1; public GridLayoutManager() { - this.unit = cellCounnt; + unit = cellCount; } public override Vector2 CalculateContentSize() { + int itemCount = adapter != null ? adapter.GetItemCount() : 0; + if (itemCount <= 0) + { + cellSize = Vector2.zero; + return direction == Direction.Vertical + ? new Vector2(contentSize.x, padding.y * 2) + : new Vector2(padding.x * 2, contentSize.y); + } + cellSize = viewProvider.CalculateViewSize(0); - int row = Mathf.CeilToInt(adapter.GetItemCount() / (float)unit); + int row = Mathf.CeilToInt(itemCount / (float)unit); float len; if (direction == Direction.Vertical) { @@ -51,6 +62,11 @@ namespace AlicizaX.UI public override Vector2 CalculateContentOffset() { + if (cellSize == Vector2.zero) + { + return Vector2.zero; + } + float width, height; if (alignment == Alignment.Center) { @@ -67,6 +83,11 @@ namespace AlicizaX.UI public override Vector2 CalculateViewportOffset() { + if (cellSize == Vector2.zero) + { + return Vector2.zero; + } + float width, height; if (alignment == Alignment.Center) { @@ -83,21 +104,46 @@ namespace AlicizaX.UI public override int GetStartIndex() { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return 0; + } + float len = direction == Direction.Vertical ? cellSize.y + spacing.y : cellSize.x + spacing.x; + if (len <= 0f) + { + return 0; + } + int index = Mathf.FloorToInt(ScrollPosition / len) * unit; return Mathf.Max(0, index); } public override int GetEndIndex() { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return -1; + } + float viewLength = direction == Direction.Vertical ? viewportSize.y : viewportSize.x; float len = direction == Direction.Vertical ? cellSize.y + spacing.y : cellSize.x + spacing.x; + if (len <= 0f) + { + return adapter.GetItemCount() - 1; + } + int index = Mathf.FloorToInt((ScrollPosition + viewLength) / len) * unit; return Mathf.Min(index, adapter.GetItemCount() - 1); } public override float IndexToPosition(int index) { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return 0f; + } + int row = index / unit; float len, viewLength, position; if (direction == Direction.Vertical) @@ -119,6 +165,11 @@ namespace AlicizaX.UI public override int PositionToIndex(float position) { float len = direction == Direction.Vertical ? cellSize.y + spacing.y : cellSize.x + spacing.x; + if (len <= 0f) + { + return 0; + } + int index = Mathf.RoundToInt(position / len); return index * unit; diff --git a/Runtime/RecyclerView/Layout/ILayoutManager.cs b/Runtime/RecyclerView/Layout/ILayoutManager.cs index 47cfdec..60b7454 100644 --- a/Runtime/RecyclerView/Layout/ILayoutManager.cs +++ b/Runtime/RecyclerView/Layout/ILayoutManager.cs @@ -4,112 +4,38 @@ namespace AlicizaX.UI { public interface ILayoutManager { - /// - /// 滚动时,刷新整个页面的布局 - /// void UpdateLayout(); - /// - /// 为 ViewHolder 设置布局 - /// - /// - /// void Layout(ViewHolder viewHolder, int index); - /// - /// 设置 Content 大小 - /// void SetContentSize(); - /// - /// 计算 Content 的大小 - /// - /// Vector2 CalculateContentSize(); - /// - /// 计算第 index 个 ViewHolder 到顶部的距离 - /// - /// - /// Vector2 CalculatePosition(int index); - /// - /// 计算 ViewHolder 相对于内容长度的偏移 - /// - /// Vector2 CalculateContentOffset(); - /// - /// 计算 ViewHolder 相对于视口的偏移 - /// - /// Vector2 CalculateViewportOffset(); - /// - /// 获取当前显示的第一个 ViewHolder 下标 - /// - /// int GetStartIndex(); - /// - /// 获取当前显示的最后一个 ViewHolder 下标 - /// - /// int GetEndIndex(); - /// - /// 数据下标转换成在布局中对应的位置 - /// - /// - /// float IndexToPosition(int index); - /// - /// 在布局中的位置转换成数据下标 - /// - /// - /// int PositionToIndex(float position); - /// - /// 滚动时,item 对应的动画 - /// void DoItemAnimation(); - /// - /// 判断第一个 ViewHolder 是否完全可见 - /// - /// 数据的真实下标 - /// bool IsFullVisibleStart(int index); - /// - /// 判断第一个 ViewHolder 是否完全不可见 - /// - /// 数据的真实下标 - /// bool IsFullInvisibleStart(int index); - /// - /// 判定最后一个 ViewHolder 是否完全可见 - /// - /// - /// bool IsFullVisibleEnd(int index); - /// - /// 判定最后一个 ViewHolder 是否完全不可见 - /// - /// - /// bool IsFullInvisibleEnd(int index); - /// - /// 判定第 index ViewHolder是否可见 - /// - /// - /// bool IsVisible(int index); } } diff --git a/Runtime/RecyclerView/Layout/LayoutManager.cs b/Runtime/RecyclerView/Layout/LayoutManager.cs index e64d8ed..4e73d18 100644 --- a/Runtime/RecyclerView/Layout/LayoutManager.cs +++ b/Runtime/RecyclerView/Layout/LayoutManager.cs @@ -1,19 +1,11 @@ -using UnityEngine; +using UnityEngine; namespace AlicizaX.UI { - /// - /// 布局管理器抽象基类 - /// 负责计算和管理 RecyclerView 中列表项的位置、大小和可见性 - /// 子类需要实现具体的布局算法(如线性、网格、圆形等) - /// [System.Serializable] public abstract class LayoutManager : ILayoutManager { protected Vector2 viewportSize; - /// - /// 获取视口大小(可见区域的尺寸) - /// public Vector2 ViewportSize { get => viewportSize; @@ -21,9 +13,6 @@ namespace AlicizaX.UI } protected Vector2 contentSize; - /// - /// 获取内容总大小(所有列表项占据的总尺寸) - /// public Vector2 ContentSize { get => contentSize; @@ -31,9 +20,6 @@ namespace AlicizaX.UI } protected Vector2 contentOffset; - /// - /// 获取内容偏移量(用于对齐计算) - /// public Vector2 ContentOffset { get => contentOffset; @@ -41,9 +27,6 @@ namespace AlicizaX.UI } protected Vector2 viewportOffset; - /// - /// 获取视口偏移量(用于对齐计算) - /// public Vector2 ViewportOffset { get => viewportOffset; @@ -51,9 +34,6 @@ namespace AlicizaX.UI } protected IAdapter adapter; - /// - /// 获取或设置数据适配器 - /// public IAdapter Adapter { get => adapter; @@ -61,9 +41,6 @@ namespace AlicizaX.UI } protected ViewProvider viewProvider; - /// - /// 获取或设置视图提供器 - /// public ViewProvider ViewProvider { get => viewProvider; @@ -71,9 +48,6 @@ namespace AlicizaX.UI } protected RecyclerView recyclerView; - /// - /// 获取或设置关联的 RecyclerView 实例 - /// public virtual RecyclerView RecyclerView { get => recyclerView; @@ -81,9 +55,6 @@ namespace AlicizaX.UI } protected Direction direction; - /// - /// 获取或设置滚动方向 - /// public Direction Direction { get => direction; @@ -91,9 +62,6 @@ namespace AlicizaX.UI } protected Alignment alignment; - /// - /// 获取或设置对齐方式 - /// public Alignment Alignment { get => alignment; @@ -101,9 +69,6 @@ namespace AlicizaX.UI } protected Vector2 spacing; - /// - /// 获取或设置列表项间距 - /// public Vector2 Spacing { get => spacing; @@ -111,9 +76,6 @@ namespace AlicizaX.UI } protected Vector2 padding; - /// - /// 获取或设置内边距 - /// public Vector2 Padding { get => padding; @@ -121,9 +83,6 @@ namespace AlicizaX.UI } protected int unit = 1; - /// - /// 获取或设置布局单元(用于网格布局等,表示一次处理多少个项) - /// public int Unit { get => unit; @@ -131,17 +90,10 @@ namespace AlicizaX.UI } - /// - /// 获取当前滚动位置 - /// public float ScrollPosition => recyclerView.GetScrollPosition(); public LayoutManager() { } - /// - /// 设置内容大小 - /// 计算视口大小、内容大小以及各种偏移量 - /// public void SetContentSize() { viewportSize = recyclerView.GetComponent().rect.size; @@ -150,10 +102,6 @@ namespace AlicizaX.UI viewportOffset = CalculateViewportOffset(); } - /// - /// 更新所有可见 ViewHolder 的布局 - /// 遍历所有当前显示的 ViewHolder 并重新计算其位置 - /// public void UpdateLayout() { foreach (var viewHolder in viewProvider.ViewHolders) @@ -162,11 +110,6 @@ namespace AlicizaX.UI } } - /// - /// 为指定的 ViewHolder 设置布局位置 - /// - /// 要布局的 ViewHolder - /// ViewHolder 对应的数据索引 public virtual void Layout(ViewHolder viewHolder, int index) { Vector2 pos = CalculatePosition(index); @@ -176,67 +119,24 @@ namespace AlicizaX.UI viewHolder.RectTransform.anchoredPosition3D = position; } - /// - /// 计算内容总大小(抽象方法,由子类实现) - /// - /// 内容的总尺寸 public abstract Vector2 CalculateContentSize(); - /// - /// 计算指定索引的 ViewHolder 位置(抽象方法,由子类实现) - /// - /// 数据索引 - /// ViewHolder 的位置 public abstract Vector2 CalculatePosition(int index); - /// - /// 计算内容偏移量(抽象方法,由子类实现) - /// - /// 内容偏移量 public abstract Vector2 CalculateContentOffset(); - /// - /// 计算视口偏移量(抽象方法,由子类实现) - /// - /// 视口偏移量 public abstract Vector2 CalculateViewportOffset(); - /// - /// 获取当前可见区域的起始索引(抽象方法,由子类实现) - /// - /// 起始索引 public abstract int GetStartIndex(); - /// - /// 获取当前可见区域的结束索引(抽象方法,由子类实现) - /// - /// 结束索引 public abstract int GetEndIndex(); - /// - /// 将数据索引转换为滚动位置(抽象方法,由子类实现) - /// - /// 数据索引 - /// 对应的滚动位置 public abstract float IndexToPosition(int index); - /// - /// 将滚动位置转换为数据索引(抽象方法,由子类实现) - /// - /// 滚动位置 - /// 对应的数据索引 public abstract int PositionToIndex(float position); - /// - /// 执行列表项动画(虚方法,子类可选择性重写) - /// public virtual void DoItemAnimation() { } - /// - /// 判断起始位置的 ViewHolder 是否完全可见 - /// - /// 数据索引 - /// 如果完全可见返回 true,否则返回 false public virtual bool IsFullVisibleStart(int index) { Vector2 vector2 = CalculatePosition(index); @@ -244,11 +144,6 @@ namespace AlicizaX.UI return position + GetOffset() >= 0; } - /// - /// 判断起始位置的 ViewHolder 是否完全不可见 - /// - /// 数据索引 - /// 如果完全不可见返回 true,否则返回 false public virtual bool IsFullInvisibleStart(int index) { Vector2 vector2 = CalculatePosition(index + unit); @@ -256,11 +151,6 @@ namespace AlicizaX.UI return position + GetOffset() < 0; } - /// - /// 判断结束位置的 ViewHolder 是否完全可见 - /// - /// 数据索引 - /// 如果完全可见返回 true,否则返回 false public virtual bool IsFullVisibleEnd(int index) { Vector2 vector2 = CalculatePosition(index + unit); @@ -269,11 +159,6 @@ namespace AlicizaX.UI return position + GetOffset() <= viewLength; } - /// - /// 判断结束位置的 ViewHolder 是否完全不可见 - /// - /// 数据索引 - /// 如果完全不可见返回 true,否则返回 false public virtual bool IsFullInvisibleEnd(int index) { Vector2 vector2 = CalculatePosition(index); @@ -282,11 +167,6 @@ namespace AlicizaX.UI return position + GetOffset() > viewLength; } - /// - /// 判断指定索引的 ViewHolder 是否可见(部分或完全) - /// - /// 数据索引 - /// 如果可见返回 true,否则返回 false public virtual bool IsVisible(int index) { float position, viewLength; @@ -309,11 +189,6 @@ namespace AlicizaX.UI return false; } - /// - /// 获取适配内容大小 - /// 根据对齐方式计算实际显示的内容长度 - /// - /// 适配后的内容大小 protected virtual float GetFitContentSize() { float len; @@ -328,40 +203,23 @@ namespace AlicizaX.UI return len; } - /// - /// 获取偏移量 - /// 计算内容偏移和视口偏移的组合值 - /// - /// 总偏移量 protected virtual float GetOffset() { return direction == Direction.Vertical ? -contentOffset.y + viewportOffset.y : -contentOffset.x + viewportOffset.x; } } - /// - /// 滚动方向枚举 - /// public enum Direction { - /// 垂直滚动 Vertical = 0, - /// 水平滚动 Horizontal = 1, - /// 自定义滚动 Custom = 2 } - /// - /// 对齐方式枚举 - /// public enum Alignment { - /// 左对齐 Left, - /// 居中对齐 Center, - /// 顶部对齐 Top } } diff --git a/Runtime/RecyclerView/Layout/LinearLayoutManager.cs b/Runtime/RecyclerView/Layout/LinearLayoutManager.cs index 2742cd1..6519eb7 100644 --- a/Runtime/RecyclerView/Layout/LinearLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/LinearLayoutManager.cs @@ -12,17 +12,25 @@ namespace AlicizaX.UI public override Vector2 CalculateContentSize() { + int itemCount = adapter != null ? adapter.GetItemCount() : 0; + if (itemCount <= 0) + { + lineHeight = 0f; + return direction == Direction.Vertical + ? new Vector2(contentSize.x, padding.y * 2) + : new Vector2(padding.x * 2, contentSize.y); + } + Vector2 size = viewProvider.CalculateViewSize(0); lineHeight = direction == Direction.Vertical ? size.y : size.x; - int index = adapter.GetItemCount(); float position; if (direction == Direction.Vertical) { - position = index * (lineHeight + spacing.y) - spacing.y; + position = itemCount * (lineHeight + spacing.y) - spacing.y; return new Vector2(contentSize.x, position + padding.y * 2); } - position = index * (lineHeight + spacing.x) - spacing.x; + position = itemCount * (lineHeight + spacing.x) - spacing.x; return new Vector2(position + padding.x * 2, contentSize.y); } @@ -40,6 +48,11 @@ namespace AlicizaX.UI public override Vector2 CalculateContentOffset() { + if (lineHeight <= 0f) + { + return Vector2.zero; + } + float len = GetFitContentSize(); if (direction == Direction.Vertical) { @@ -50,6 +63,11 @@ namespace AlicizaX.UI public override Vector2 CalculateViewportOffset() { + if (lineHeight <= 0f) + { + return Vector2.zero; + } + if (direction == Direction.Vertical) { return new Vector2(0, (viewportSize.y - lineHeight) / 2); @@ -59,22 +77,42 @@ namespace AlicizaX.UI public override int GetStartIndex() { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return 0; + } + float len = direction == Direction.Vertical ? lineHeight + spacing.y : lineHeight + spacing.x; + if (len <= 0f) + { + return 0; + } + int index = Mathf.FloorToInt(ScrollPosition / len); return Mathf.Max(0, index); } public override int GetEndIndex() { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return -1; + } + float viewLength = direction == Direction.Vertical ? viewportSize.y : viewportSize.x; float len = direction == Direction.Vertical ? lineHeight + spacing.y : lineHeight + spacing.x; + if (len <= 0f) + { + return adapter.GetItemCount() - 1; + } + int index = Mathf.FloorToInt((ScrollPosition + viewLength) / len); return Mathf.Min(index, adapter.GetItemCount() - 1); } public override float IndexToPosition(int index) { - if (index < 0 || index >= adapter.GetItemCount()) return 0; + if (adapter == null || adapter.GetItemCount() <= 0 || index < 0 || index >= adapter.GetItemCount()) return 0; float len, viewLength, position; if (direction == Direction.Vertical) @@ -96,6 +134,11 @@ namespace AlicizaX.UI public override int PositionToIndex(float position) { float len = direction == Direction.Vertical ? lineHeight + spacing.y : lineHeight + spacing.x; + if (len <= 0f) + { + return 0; + } + int index = Mathf.RoundToInt(position / len); return index; diff --git a/Runtime/RecyclerView/Layout/MixedLayoutManager.cs b/Runtime/RecyclerView/Layout/MixedLayoutManager.cs index 07a0247..1a56d75 100644 --- a/Runtime/RecyclerView/Layout/MixedLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/MixedLayoutManager.cs @@ -6,133 +6,238 @@ namespace AlicizaX.UI [Serializable] public class MixedLayoutManager : LayoutManager { + private float[] itemLengths = Array.Empty(); + private float[] itemPositions = Array.Empty(); + private Vector2 firstItemSize = Vector2.zero; + private int cachedItemCount = -1; + private bool positionCacheDirty = true; + public MixedLayoutManager() { } public override Vector2 CalculateContentSize() { - int index = adapter.GetItemCount(); - float position = 0; - for (int i = 0; i < index; i++) - { - position += GetLength(i); - } + positionCacheDirty = true; + EnsurePositionCache(); - return direction == Direction.Vertical ? - new Vector2(contentSize.x, position - spacing.y + padding.y * 2) : - new Vector2(position - spacing.x + padding.x * 2, contentSize.y); + float totalLength = cachedItemCount > 0 + ? itemPositions[cachedItemCount - 1] + itemLengths[cachedItemCount - 1] + : 0f; + + float paddingLength = direction == Direction.Vertical ? padding.y * 2 : padding.x * 2; + return direction == Direction.Vertical + ? new Vector2(contentSize.x, cachedItemCount > 0 ? totalLength + paddingLength : paddingLength) + : new Vector2(cachedItemCount > 0 ? totalLength + paddingLength : paddingLength, contentSize.y); } public override Vector2 CalculatePosition(int index) { - // TODO 优化点,将 position 定义成全局变量 - float position = 0; - for (int i = 0; i < index; i++) - { - position += GetLength(i); - } - position -= ScrollPosition; - return direction == Direction.Vertical ? new Vector2(0, position + padding.y) : new Vector2(position + padding.x, 0); + EnsurePositionCache(); + + float position = GetItemPosition(index) - ScrollPosition; + return direction == Direction.Vertical + ? new Vector2(0, position + padding.y) + : new Vector2(position + padding.x, 0); } public override Vector2 CalculateContentOffset() { - Vector2 size = viewProvider.CalculateViewSize(0); + EnsurePositionCache(); + if (cachedItemCount <= 0) + { + return Vector2.zero; + } + float len = GetFitContentSize(); if (direction == Direction.Vertical) { - return new Vector2(0, (len - size.y) / 2); + return new Vector2(0, (len - firstItemSize.y) / 2); } - return new Vector2((len - size.x) / 2, 0); + + return new Vector2((len - firstItemSize.x) / 2, 0); } public override Vector2 CalculateViewportOffset() { - Vector2 size = viewProvider.CalculateViewSize(0); + EnsurePositionCache(); + if (cachedItemCount <= 0) + { + return Vector2.zero; + } + if (direction == Direction.Vertical) { - return new Vector2(0, (viewportSize.y - size.y) / 2); + return new Vector2(0, (viewportSize.y - firstItemSize.y) / 2); } - return new Vector2((viewportSize.x - size.x) / 2, 0); + + return new Vector2((viewportSize.x - firstItemSize.x) / 2, 0); } public override int GetStartIndex() { - float position = 0; - float contentPosition = ScrollPosition; - int itemCount = adapter.GetItemCount(); - for (int i = 0; i < itemCount; i++) + EnsurePositionCache(); + if (cachedItemCount <= 0) { - position += GetLength(i); - - if (position > contentPosition) - { - return Mathf.Max(0, i); - } + return 0; } - return 0; + + int index = FindFirstItemEndingAfter(ScrollPosition); + return index >= 0 ? index : 0; } public override int GetEndIndex() { - float position = 0; + EnsurePositionCache(); + if (cachedItemCount <= 0) + { + return -1; + } + float viewLength = direction == Direction.Vertical ? viewportSize.y : viewportSize.x; - int itemCount = adapter.GetItemCount(); - for (int i = 0; i < itemCount; i++) - { - position += GetLength(i); - - if (position > ScrollPosition + viewLength) - { - return Mathf.Min(i, adapter.GetItemCount() - 1); ; - } - } - return itemCount - 1; - } - - private float GetLength(int index) - { - Vector2 size = viewProvider.CalculateViewSize(index); - if (index < adapter.GetItemCount() - 1) - { - size += spacing; - } - float len = direction == Direction.Vertical ? size.y : size.x; - return len; + int index = FindFirstItemEndingAfter(ScrollPosition + viewLength); + return index >= 0 ? Mathf.Min(index, cachedItemCount - 1) : cachedItemCount - 1; } public override float IndexToPosition(int index) { - Vector2 position = CalculatePosition(index); + EnsurePositionCache(); + if (cachedItemCount <= 0) + { + return 0f; + } + + float position = GetItemPosition(index); if (direction == Direction.Vertical) { - position.y = Mathf.Max(0, position.y); - position.y = Mathf.Min(position.y, contentSize.y - viewportSize.y); - return position.y; - } - else - { - position.x = Mathf.Max(0, position.x); - position.x = Mathf.Min(position.x, contentSize.x - viewportSize.x); - return position.x; + return Mathf.Clamp(position, 0f, Mathf.Max(contentSize.y - viewportSize.y, 0f)); } + + return Mathf.Clamp(position, 0f, Mathf.Max(contentSize.x - viewportSize.x, 0f)); } public override int PositionToIndex(float position) { - float len = 0; + EnsurePositionCache(); + if (cachedItemCount <= 0) + { + return 0; + } - int itemCount = adapter.GetItemCount(); + int index = FindFirstItemEndingAtOrAfter(position); + return index >= 0 ? index : cachedItemCount - 1; + } + + private void EnsurePositionCache() + { + int itemCount = adapter != null ? adapter.GetItemCount() : 0; + if (itemCount < 0) + { + itemCount = 0; + } + + if (!positionCacheDirty && cachedItemCount == itemCount) + { + return; + } + + RebuildPositionCache(itemCount); + } + + private void RebuildPositionCache(int itemCount) + { + if (itemLengths.Length != itemCount) + { + itemLengths = itemCount > 0 ? new float[itemCount] : Array.Empty(); + itemPositions = itemCount > 0 ? new float[itemCount] : Array.Empty(); + } + + firstItemSize = itemCount > 0 ? viewProvider.CalculateViewSize(0) : Vector2.zero; + float position = 0f; for (int i = 0; i < itemCount; i++) { - len += GetLength(i); + itemPositions[i] = position; + itemLengths[i] = GetLength(i, itemCount); + position += itemLengths[i]; + } - if (len >= position) + cachedItemCount = itemCount; + positionCacheDirty = false; + } + + private float GetItemPosition(int index) + { + if (index <= 0 || cachedItemCount <= 0) + { + return 0f; + } + + if (index >= cachedItemCount) + { + return itemPositions[cachedItemCount - 1] + itemLengths[cachedItemCount - 1]; + } + + return itemPositions[index]; + } + + private int FindFirstItemEndingAfter(float position) + { + int low = 0; + int high = cachedItemCount - 1; + int result = -1; + + while (low <= high) + { + int mid = low + ((high - low) / 2); + if (GetItemEndPosition(mid) > position) { - return i; + result = mid; + high = mid - 1; + } + else + { + low = mid + 1; } } - return 0; + return result; + } + + private int FindFirstItemEndingAtOrAfter(float position) + { + int low = 0; + int high = cachedItemCount - 1; + int result = -1; + + while (low <= high) + { + int mid = low + ((high - low) / 2); + if (GetItemEndPosition(mid) >= position) + { + result = mid; + high = mid - 1; + } + else + { + low = mid + 1; + } + } + + return result; + } + + private float GetItemEndPosition(int index) + { + return itemPositions[index] + itemLengths[index]; + } + + private float GetLength(int index, int itemCount) + { + Vector2 size = viewProvider.CalculateViewSize(index); + if (index < itemCount - 1) + { + size += spacing; + } + + return direction == Direction.Vertical ? size.y : size.x; } } } diff --git a/Runtime/RecyclerView/Layout/PageLayoutManager.cs b/Runtime/RecyclerView/Layout/PageLayoutManager.cs index 91e0b97..ef1a62c 100644 --- a/Runtime/RecyclerView/Layout/PageLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/PageLayoutManager.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System; using UnityEngine; namespace AlicizaX.UI @@ -15,19 +14,27 @@ namespace AlicizaX.UI public override Vector2 CalculateContentSize() { + int itemCount = adapter != null ? adapter.GetItemCount() : 0; + if (itemCount <= 0) + { + lineHeight = 0f; + return direction == Direction.Vertical + ? new Vector2(contentSize.x, padding.y * 2) + : new Vector2(padding.x * 2, contentSize.y); + } + Vector2 size = viewProvider.CalculateViewSize(0); lineHeight = direction == Direction.Vertical ? size.y : size.x; - int index = adapter.GetItemCount(); float position; if (direction == Direction.Vertical) { - position = index * (lineHeight + spacing.y) - spacing.y; + position = itemCount * (lineHeight + spacing.y) - spacing.y; position += viewportSize.y - lineHeight; return new Vector2(contentSize.x, position + padding.y * 2); } - position = index * (lineHeight + spacing.x) - spacing.x; + position = itemCount * (lineHeight + spacing.x) - spacing.x; position += viewportSize.x - lineHeight; return new Vector2(position + padding.x * 2, contentSize.y); } @@ -57,15 +64,29 @@ namespace AlicizaX.UI protected override float GetOffset() { + if (lineHeight <= 0f) + { + return 0f; + } + float offset = direction == Direction.Vertical ? viewportSize.y - lineHeight : viewportSize.x - lineHeight; return offset / 2; } public override int PositionToIndex(float position) { + if (adapter == null || adapter.GetItemCount() <= 0) + { + return 0; + } + float len = direction == Direction.Vertical ? lineHeight + spacing.y : lineHeight + spacing.x; + if (len <= 0f) + { + return 0; + } + float pos = IndexToPosition(recyclerView.CurrentIndex); - // 根据是前划还是后划,来加减偏移量 int index = position > pos ? Mathf.RoundToInt(position / len + 0.25f) : Mathf.RoundToInt(position / len - 0.25f); return index; @@ -73,7 +94,7 @@ namespace AlicizaX.UI public override void DoItemAnimation() { - List viewHolders = viewProvider.ViewHolders; + var viewHolders = viewProvider.ViewHolders; for (int i = 0; i < viewHolders.Count; i++) { float viewPos = direction == Direction.Vertical ? -viewHolders[i].RectTransform.anchoredPosition.y : viewHolders[i].RectTransform.anchoredPosition.x; diff --git a/Runtime/RecyclerView/Navigation.meta b/Runtime/RecyclerView/Navigation.meta new file mode 100644 index 0000000..485dfbe --- /dev/null +++ b/Runtime/RecyclerView/Navigation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2450ac660a373c24caa2f5eba25c8237 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs b/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs new file mode 100644 index 0000000..ae8a02e --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs @@ -0,0 +1,49 @@ +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace AlicizaX.UI +{ + [DisallowMultipleComponent] + public sealed class RecyclerNavigationBridge : Selectable, ISelectHandler, IMoveHandler, ISubmitHandler + { + [SerializeField] private MoveDirection defaultEntryDirection = MoveDirection.Down; + + private RecyclerView recyclerView; + + protected override void Awake() + { + base.Awake(); + transition = Transition.None; + Navigation navigationConfig = navigation; + navigationConfig.mode = Navigation.Mode.None; + navigation = navigationConfig; + recyclerView = GetComponent(); + } + + public override void OnSelect(BaseEventData eventData) + { + base.OnSelect(eventData); + TryEnter(defaultEntryDirection); + } + + public override void OnMove(AxisEventData eventData) + { + if (!TryEnter(eventData.moveDir)) + { + base.OnMove(eventData); + } + } + + public void OnSubmit(BaseEventData eventData) + { + TryEnter(defaultEntryDirection); + } + + private bool TryEnter(MoveDirection direction) + { + recyclerView ??= GetComponent(); + return recyclerView != null && recyclerView.TryFocusEntry(direction); + } + } +} diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs.meta b/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs.meta new file mode 100644 index 0000000..c28dac7 --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eaec44cc09df6a546a8b02169d0a3e18 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs b/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs new file mode 100644 index 0000000..bbe0a92 --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs @@ -0,0 +1,97 @@ +using UnityEngine; +using UnityEngine.EventSystems; + +namespace AlicizaX.UI +{ + public sealed class RecyclerNavigationController + { + private readonly RecyclerView recyclerView; + + public RecyclerNavigationController(RecyclerView recyclerView) + { + this.recyclerView = recyclerView; + } + + public bool TryMove(ViewHolder currentHolder, MoveDirection direction, RecyclerNavigationOptions options) + { + if (recyclerView == null || + recyclerView.RecyclerViewAdapter == null || + currentHolder == null) + { + return false; + } + + int itemCount = recyclerView.RecyclerViewAdapter.GetItemCount(); + int realCount = recyclerView.RecyclerViewAdapter.GetRealCount(); + if (itemCount <= 0 || realCount <= 0) + { + return false; + } + + int step = GetStep(direction); + if (step == 0) + { + return false; + } + + bool isLoopSource = itemCount != realCount; + bool allowWrap = isLoopSource && options.Wrap; + int nextLayoutIndex = currentHolder.Index + step; + if (allowWrap) + { + nextLayoutIndex = WrapIndex(nextLayoutIndex, realCount); + } + else + { + nextLayoutIndex = Mathf.Clamp(nextLayoutIndex, 0, itemCount - 1); + } + + if (!allowWrap && nextLayoutIndex == currentHolder.Index) + { + return false; + } + + ScrollAlignment alignment = ResolveAlignment(direction, options.Alignment); + return recyclerView.TryFocusIndex(nextLayoutIndex, options.SmoothScroll, alignment); + } + + private int GetStep(MoveDirection direction) + { + int unit = Mathf.Max(1, recyclerView.LayoutManager != null ? recyclerView.LayoutManager.Unit : 1); + bool vertical = recyclerView.Direction == Direction.Vertical; + + return direction switch + { + MoveDirection.Left => vertical ? -1 : -unit, + MoveDirection.Right => vertical ? 1 : unit, + MoveDirection.Up => vertical ? -unit : -1, + MoveDirection.Down => vertical ? unit : 1, + _ => 0 + }; + } + + private static int WrapIndex(int index, int count) + { + int wrapped = index % count; + return wrapped < 0 ? wrapped + count : wrapped; + } + + private ScrollAlignment ResolveAlignment(MoveDirection direction, ScrollAlignment fallback) + { + if (recyclerView == null || recyclerView.LayoutManager == null) + { + return fallback; + } + + bool vertical = recyclerView.Direction == Direction.Vertical; + return direction switch + { + MoveDirection.Down when vertical => ScrollAlignment.End, + MoveDirection.Up when vertical => ScrollAlignment.Start, + MoveDirection.Right when !vertical => ScrollAlignment.End, + MoveDirection.Left when !vertical => ScrollAlignment.Start, + _ => fallback + }; + } + } +} diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs.meta b/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs.meta new file mode 100644 index 0000000..bc5d1be --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f3d534ccefdd51448ffa7281ae6d881 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs b/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs new file mode 100644 index 0000000..54cf0be --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs @@ -0,0 +1,21 @@ +namespace AlicizaX.UI +{ + public readonly struct RecyclerNavigationOptions + { + public static readonly RecyclerNavigationOptions Clamped = new(false, false, ScrollAlignment.Center); + public static readonly RecyclerNavigationOptions Circular = new(true, false, ScrollAlignment.Center); + + public RecyclerNavigationOptions(bool wrap, bool smoothScroll, ScrollAlignment alignment) + { + Wrap = wrap; + SmoothScroll = smoothScroll; + Alignment = alignment; + } + + public bool Wrap { get; } + + public bool SmoothScroll { get; } + + public ScrollAlignment Alignment { get; } + } +} diff --git a/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs.meta b/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs.meta new file mode 100644 index 0000000..254f665 --- /dev/null +++ b/Runtime/RecyclerView/Navigation/RecyclerNavigationOptions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a92798bee41f6334ba18374359da1329 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/RecyclerView/ObjectPool/IObjectFactory.cs b/Runtime/RecyclerView/ObjectPool/IObjectFactory.cs index b2e2d27..e3c580e 100644 --- a/Runtime/RecyclerView/ObjectPool/IObjectFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/IObjectFactory.cs @@ -2,29 +2,12 @@ namespace AlicizaX.UI { public interface IObjectFactory where T : class { - /// - /// 创建对象 - /// - /// T Create(); - /// - /// 销毁对象 - /// - /// void Destroy(T obj); - /// - /// 重置对象 - /// - /// void Reset(T obj); - /// - /// 验证对象 - /// - /// - /// bool Validate(T obj); } } diff --git a/Runtime/RecyclerView/ObjectPool/IObjectPool.cs b/Runtime/RecyclerView/ObjectPool/IObjectPool.cs index 29da139..266bcf8 100644 --- a/Runtime/RecyclerView/ObjectPool/IObjectPool.cs +++ b/Runtime/RecyclerView/ObjectPool/IObjectPool.cs @@ -4,16 +4,8 @@ namespace AlicizaX.UI public interface IObjectPool : IDisposable { - /// - /// 从池子中分配一个可用对象,没有的话就创建一个 - /// - /// object Allocate(); - /// - /// 将对象回收到池子中去,如果池中的对象数量已经超过了 maxSize,则直接销毁该对象 - /// - /// void Free(object obj); } @@ -23,5 +15,4 @@ namespace AlicizaX.UI void Free(T obj); } - } diff --git a/Runtime/RecyclerView/ObjectPool/IPooledObject.cs b/Runtime/RecyclerView/ObjectPool/IPooledObject.cs deleted file mode 100644 index 6749fdd..0000000 --- a/Runtime/RecyclerView/ObjectPool/IPooledObject.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace AlicizaX.UI -{ - public interface IPooledObject - { - void Free(); - } -} diff --git a/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs b/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs index fdb4180..5c20619 100644 --- a/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs +++ b/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs @@ -1,18 +1,22 @@ namespace AlicizaX.UI { using System; - using System.Collections.Concurrent; using System.Collections.Generic; public class MixedObjectPool : IMixedObjectPool where T : class { private const int DEFAULT_MAX_SIZE_PER_TYPE = 10; - private readonly ConcurrentDictionary> entries; - private readonly ConcurrentDictionary typeSize; + private readonly Dictionary> entries; + private readonly Dictionary typeSize; + private readonly Dictionary activeCountByType; + private readonly Dictionary peakActiveByType; private readonly IMixedObjectFactory factory; - private int defaultMaxSizePerType; + private readonly int defaultMaxSizePerType; + private int hitCount; + private int missCount; + private int destroyCount; public MixedObjectPool(IMixedObjectFactory factory) : this(factory, DEFAULT_MAX_SIZE_PER_TYPE) { @@ -28,19 +32,25 @@ namespace AlicizaX.UI throw new ArgumentException("The maxSize must be greater than 0."); } - entries = new ConcurrentDictionary>(); - typeSize = new ConcurrentDictionary(); + entries = new Dictionary>(StringComparer.Ordinal); + typeSize = new Dictionary(StringComparer.Ordinal); + activeCountByType = new Dictionary(StringComparer.Ordinal); + peakActiveByType = new Dictionary(StringComparer.Ordinal); } public T Allocate(string typeName) { - if (entries.TryGetValue(typeName, out List list) && list.Count > 0) + Stack stack = GetOrCreateStack(typeName); + if (stack.Count > 0) { - T obj = list[0]; - list.RemoveAt(0); + T obj = stack.Pop(); + hitCount++; + TrackAllocate(typeName); return obj; } + missCount++; + TrackAllocate(typeName); return factory.Create(typeName); } @@ -51,19 +61,25 @@ namespace AlicizaX.UI if (!factory.Validate(typeName, obj)) { factory.Destroy(typeName, obj); + destroyCount++; + TrackFree(typeName); return; } int maxSize = GetMaxSize(typeName); - List list = entries.GetOrAdd(typeName, n => new List()); - if (list.Count >= maxSize) + Stack stack = GetOrCreateStack(typeName); + + factory.Reset(typeName, obj); + TrackFree(typeName); + + if (stack.Count >= maxSize) { factory.Destroy(typeName, obj); + destroyCount++; return; } - factory.Reset(typeName, obj); - list.Add(obj); + stack.Push(obj); } public int GetMaxSize(string typeName) @@ -78,24 +94,79 @@ namespace AlicizaX.UI public void SetMaxSize(string typeName, int value) { - typeSize.AddOrUpdate(typeName, value, (key, oldValue) => value); + typeSize[typeName] = value; } + public void EnsureCapacity(string typeName, int value) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + int current = GetMaxSize(typeName); + if (value > current) + { + typeSize[typeName] = value; + } + } + + public void Warm(string typeName, int count) + { + if (count <= 0) + { + return; + } + + int maxSize = GetMaxSize(typeName); + if (count > maxSize) + { + count = maxSize; + } + + Stack stack = GetOrCreateStack(typeName); + while (stack.Count < count) + { + stack.Push(factory.Create(typeName)); + } + } + + public int GetActiveCount(string typeName) + { + return activeCountByType.TryGetValue(typeName, out int count) ? count : 0; + } + + public int GetPeakActiveCount(string typeName) + { + return peakActiveByType.TryGetValue(typeName, out int count) ? count : 0; + } + + public int HitCount => hitCount; + + public int MissCount => missCount; + + public int DestroyCount => destroyCount; + protected virtual void Clear() { foreach (var kv in entries) { string typeName = kv.Key; - List list = kv.Value; + Stack stack = kv.Value; - if (list == null || list.Count <= 0) continue; + if (stack == null || stack.Count <= 0) continue; - list.ForEach(e => factory.Destroy(typeName, e)); - list.Clear(); + while (stack.Count > 0) + { + factory.Destroy(typeName, stack.Pop()); + destroyCount++; + } } entries.Clear(); typeSize.Clear(); + activeCountByType.Clear(); + peakActiveByType.Clear(); } public void Dispose() @@ -103,6 +174,42 @@ namespace AlicizaX.UI Clear(); GC.SuppressFinalize(this); } + + private Stack GetOrCreateStack(string typeName) + { + if (!entries.TryGetValue(typeName, out Stack stack)) + { + stack = new Stack(GetMaxSize(typeName)); + entries[typeName] = stack; + } + + return stack; + } + + private void TrackAllocate(string typeName) + { + int active = GetActiveCount(typeName) + 1; + activeCountByType[typeName] = active; + if (active > GetPeakActiveCount(typeName)) + { + peakActiveByType[typeName] = active; + } + } + + private void TrackFree(string typeName) + { + int active = GetActiveCount(typeName); + if (active > 0) + { + activeCountByType[typeName] = active - 1; + } + + int recommendedMax = GetPeakActiveCount(typeName) + 1; + if (recommendedMax > GetMaxSize(typeName)) + { + typeSize[typeName] = recommendedMax; + } + } } } diff --git a/Runtime/RecyclerView/ObjectPool/ObjectPool.cs b/Runtime/RecyclerView/ObjectPool/ObjectPool.cs index 983504d..27a373b 100644 --- a/Runtime/RecyclerView/ObjectPool/ObjectPool.cs +++ b/Runtime/RecyclerView/ObjectPool/ObjectPool.cs @@ -1,14 +1,20 @@ namespace AlicizaX.UI { using System; - using System.Threading; + using System.Collections.Generic; public class ObjectPool : IObjectPool where T : class { + private readonly Stack entries; + private readonly int initialSize; private int maxSize; - private int initialSize; - protected readonly T[] entries = null; protected readonly IObjectFactory factory; + private int totalCount; + private int activeCount; + private int hitCount; + private int missCount; + private int destroyCount; + private int peakActive; public ObjectPool(IObjectFactory factory) : this(factory, Environment.ProcessorCount * 2) { @@ -23,37 +29,62 @@ namespace AlicizaX.UI this.factory = factory; this.initialSize = initialSize; this.maxSize = maxSize; - this.entries = new T[maxSize]; if (maxSize < initialSize) { throw new ArgumentException("The maxSize must be greater than or equal to the initialSize."); } - for (int i = 0; i < initialSize; i++) - { - entries[i] = factory.Create(); - } + entries = new Stack(maxSize); + Warm(initialSize); } public int MaxSize => maxSize; public int InitialSize => initialSize; + public int InactiveCount => entries.Count; + + public int ActiveCount => activeCount; + + public int TotalCount => totalCount; + + public int PeakActive => peakActive; + + public int HitCount => hitCount; + + public int MissCount => missCount; + + public int DestroyCount => destroyCount; + public virtual T Allocate() { - for (var i = 0; i < entries.Length; i++) + T value; + if (entries.Count > 0) { - T value = entries[i]; - if (value == null) continue; - - if (Interlocked.CompareExchange(ref entries[i], null, value) == value) + value = entries.Pop(); + if (value != null) { + hitCount++; + activeCount++; + if (activeCount > peakActive) + { + peakActive = activeCount; + } return value; } } - return factory.Create(); + missCount++; + value = factory.Create(); + totalCount++; + activeCount++; + if (activeCount > peakActive) + { + peakActive = activeCount; + } + + return value; } public virtual void Free(T obj) @@ -63,20 +94,39 @@ namespace AlicizaX.UI if (!factory.Validate(obj)) { factory.Destroy(obj); + destroyCount++; + if (totalCount > 0) + { + totalCount--; + } + + if (activeCount > 0) + { + activeCount--; + } + return; } factory.Reset(obj); - for (var i = 0; i < entries.Length; i++) + if (activeCount > 0) { - if (Interlocked.CompareExchange(ref entries[i], obj, null) == null) - { - return; - } + activeCount--; + } + + if (entries.Count < maxSize) + { + entries.Push(obj); + return; } factory.Destroy(obj); + destroyCount++; + if (totalCount > 0) + { + totalCount--; + } } object IObjectPool.Allocate() @@ -91,15 +141,17 @@ namespace AlicizaX.UI protected virtual void Clear() { - for (var i = 0; i < entries.Length; i++) + while (entries.Count > 0) { - var value = Interlocked.Exchange(ref entries[i], null); - + var value = entries.Pop(); if (value != null) { factory.Destroy(value); + destroyCount++; } } + + totalCount = activeCount; } public void Dispose() @@ -107,6 +159,38 @@ namespace AlicizaX.UI Clear(); GC.SuppressFinalize(this); } + + public void EnsureCapacity(int value) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + if (value > maxSize) + { + maxSize = value; + } + } + + public void Warm(int count) + { + if (count <= 0) + { + return; + } + + if (count > maxSize) + { + count = maxSize; + } + + while (totalCount < count) + { + entries.Push(factory.Create()); + totalCount++; + } + } } } diff --git a/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs index 0729269..9c8ca72 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs @@ -27,8 +27,6 @@ namespace AlicizaX.UI public void Reset(T obj) { obj.gameObject.SetActive(false); - obj.gameObject.transform.position = Vector3.zero; - obj.gameObject.transform.rotation = Quaternion.identity; } public bool Validate(T obj) diff --git a/Runtime/RecyclerView/ObjectPool/UnityGameObjectFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityGameObjectFactory.cs index 3fb9f5c..e73e3b3 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityGameObjectFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityGameObjectFactory.cs @@ -21,8 +21,6 @@ namespace AlicizaX.UI public virtual void Reset(GameObject obj) { obj.SetActive(false); - obj.transform.position = Vector3.zero; - obj.transform.rotation = Quaternion.identity; } public virtual void Destroy(GameObject obj) diff --git a/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs index 92cee18..55b41f6 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs @@ -1,6 +1,5 @@ namespace AlicizaX.UI { - using System.Collections; using System.Collections.Generic; using UnityEngine; @@ -38,8 +37,18 @@ namespace AlicizaX.UI public T Create(string typeName) { T obj = Object.Instantiate(dict[typeName], parent); - obj.transform.position = Vector3.zero; - obj.transform.rotation = Quaternion.identity; + if (obj.transform is RectTransform rectTransform) + { + rectTransform.anchoredPosition3D = Vector3.zero; + rectTransform.localRotation = Quaternion.identity; + rectTransform.localScale = Vector3.one; + } + else + { + obj.transform.localPosition = Vector3.zero; + obj.transform.localRotation = Quaternion.identity; + obj.transform.localScale = Vector3.one; + } return obj; } @@ -52,8 +61,6 @@ namespace AlicizaX.UI public void Reset(string typeName, T obj) { obj.gameObject.SetActive(false); - obj.transform.position = Vector3.zero; - obj.transform.rotation = Quaternion.identity; } public bool Validate(string typeName, T obj) diff --git a/Runtime/RecyclerView/ObjectPool/UnityMixedGameObjectFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityMixedGameObjectFactory.cs index 5b8e507..dc22447 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityMixedGameObjectFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityMixedGameObjectFactory.cs @@ -31,8 +31,6 @@ namespace AlicizaX.UI public void Reset(string typeName, GameObject obj) { obj.SetActive(false); - obj.transform.position = Vector3.zero; - obj.transform.rotation = Quaternion.identity; } public bool Validate(string typeName, GameObject obj) diff --git a/Runtime/RecyclerView/RecyclerView.cs b/Runtime/RecyclerView/RecyclerView.cs index 803b8da..208118e 100644 --- a/Runtime/RecyclerView/RecyclerView.cs +++ b/Runtime/RecyclerView/RecyclerView.cs @@ -1,62 +1,176 @@ -using System; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.UI; namespace AlicizaX.UI { /// - /// RecyclerView 核心组件,用于高效显示大量列表数据 - /// 通过视图回收和复用机制,只渲染可见区域的项目,大幅提升性能 - /// 支持垂直/水平滚动、网格布局、循环滚动等多种布局模式 + /// RecyclerView 的核心组件,负责适配器绑定、布局刷新、滚动控制与焦点导航。 /// public class RecyclerView : MonoBehaviour { + /// + /// 滚动条拖拽手柄允许的最小像素长度。 + /// + private const float MinScrollbarHandlePixels = 18f; + + /// + /// 记录 Unity 主线程的托管线程标识。 + /// + private static int mainThreadId = -1; + #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)] + /// + /// 平滑滚动时的速度系数。 + /// + [HideInInspector] [SerializeField, Range(0.5f, 50f)] private float scrollSpeed = 7f; - [HideInInspector] [SerializeField, Range(10f, 50f)] + /// + /// 鼠标滚轮滚动时的速度系数。 + /// + [HideInInspector] [SerializeField, Range(1f, 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 bool showScrollBarOnlyWhenScrollable; + + /// + /// 与当前列表关联的滚动条组件。 + /// [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 RecyclerNavigationController navigationController; + + /// + /// 下一帧恢复 UI 焦点时使用的协程句柄。 + /// + private Coroutine focusRecoveryCoroutine; + + /// + /// 是否存在等待滚动结束后执行的焦点请求。 + /// + private bool hasPendingFocusRequest; + + /// + /// 挂起焦点请求期望采用的对齐方式。 + /// + private ScrollAlignment pendingFocusAlignment; + + /// + /// 挂起焦点请求对应的数据索引。 + /// + private int pendingFocusIndex = -1; + + /// + /// 当前可见区间的起始布局索引。 + /// private int startIndex; + + /// + /// 当前可见区间的结束布局索引。 + /// private int endIndex; + + /// + /// 当前记录的逻辑选中索引。 + /// private int currentIndex; #endregion @@ -64,7 +178,7 @@ namespace AlicizaX.UI #region Public Properties - Layout Settings /// - /// 获取或设置列表的滚动方向(垂直、水平或自定义) + /// 获取或设置列表的主滚动方向。 /// public Direction Direction { @@ -73,7 +187,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表项的对齐方式(起始、居中或结束) + /// 获取或设置列表项在交叉轴上的对齐方式。 /// public Alignment Alignment { @@ -82,7 +196,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表项之间的间距(X轴和Y轴) + /// 获取或设置列表项之间的间距。 /// public Vector2 Spacing { @@ -91,7 +205,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表内容的内边距(X轴和Y轴) + /// 获取或设置列表内容区域的内边距。 /// public Vector2 Padding { @@ -104,8 +218,7 @@ namespace AlicizaX.UI #region Public Properties - Scroll Settings /// - /// 获取或设置是否启用滚动功能 - /// 启用时会激活 Scroller 组件并显示滚动条(如果配置了) + /// 获取或设置是否启用滚动能力。 /// public bool Scroll { @@ -115,40 +228,30 @@ namespace AlicizaX.UI 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(); } } /// - /// 获取或设置是否启用吸附功能 - /// 启用时滚动停止后会自动对齐到最近的列表项 - /// 注意:此功能依赖于 Scroll 属性,只有在滚动启用时才生效 + /// 获取或设置是否在停止滚动后自动吸附到最近项。 /// public bool Snap { get => snap; set { - // Snap 依赖于 Scroll(与原逻辑保持一致) bool newSnap = value & scroll; if (snap == newSnap) return; snap = newSnap; @@ -158,14 +261,12 @@ namespace AlicizaX.UI scroller.Snap = snap; } - // 如果开启了 snap,可以选做:立即对齐到最近项 - // if (snap && scroller != null) SnapToNearestItem(); + // 如需在启用吸附后立即校正位置,可在此触发最近项吸附。 } } /// - /// 获取或设置滚动速度(范围:1-50) - /// 值越大,滚动响应越快 + /// 获取或设置平滑滚动速度系数。 /// public float ScrollSpeed { @@ -183,8 +284,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置鼠标滚轮的滚动速度(范围:10-50) - /// 值越大,滚轮滚动的距离越大 + /// 获取或设置鼠标滚轮滚动速度系数。 /// public float WheelSpeed { @@ -202,7 +302,7 @@ namespace AlicizaX.UI } /// - /// 是否仅在内容超出可视区域时显示滚动条并允许滚动 + /// 获取或设置是否仅在内容可滚动时显示滚动条。 /// public bool ShowScrollBarOnlyWhenScrollable { @@ -221,8 +321,7 @@ namespace AlicizaX.UI #region Public Properties - Components /// - /// 获取或设置 ViewHolder 模板数组 - /// 用于创建和复用列表项视图 + /// 获取或设置用于创建列表项的模板集合。 /// public ViewHolder[] Templates { @@ -231,8 +330,7 @@ namespace AlicizaX.UI } /// - /// 获取内容容器的 RectTransform - /// 所有列表项都会作为此容器的子对象 + /// 获取内容节点;未显式指定时会尝试从首个子节点推断。 /// public RectTransform Content { @@ -240,7 +338,16 @@ namespace AlicizaX.UI { if (content == null) { + if (transform.childCount == 0) + { + throw new InvalidOperationException("RecyclerView content is not assigned and no child RectTransform exists."); + } + content = transform.GetChild(0).GetComponent(); + if (content == null) + { + throw new InvalidOperationException("RecyclerView content child must have a RectTransform component."); + } } return content; @@ -248,19 +355,17 @@ namespace AlicizaX.UI } /// - /// 获取滚动条组件 + /// 获取当前绑定的滚动条组件。 /// public Scrollbar Scrollbar => scrollbar; /// - /// 获取滚动控制器组件 + /// 获取当前绑定的滚动器实例。 /// public Scroller Scroller => scroller; /// - /// 获取视图提供器 - /// 负责创建、回收和管理 ViewHolder 实例 - /// 根据模板数量自动选择 SimpleViewProvider 或 MixedViewProvider + /// 获取视图提供器;首次访问时根据模板数量自动创建。 /// public ViewProvider ViewProvider { @@ -268,6 +373,11 @@ namespace AlicizaX.UI { if (viewProvider == null) { + if (templates == null || templates.Length == 0) + { + throw new InvalidOperationException("RecyclerView templates can not be null or empty."); + } + viewProvider = templates.Length > 1 ? new MixedViewProvider(this, templates) : new SimpleViewProvider(this, templates); @@ -277,18 +387,32 @@ namespace AlicizaX.UI } } + /// + /// 获取当前对象池的统计信息文本。 + /// + public string PoolStats => viewProvider?.PoolStats ?? string.Empty; + + /// + /// 获取当前布局管理器实例。 + /// + public LayoutManager LayoutManager => layoutManager; + + /// + /// 获取导航控制器;首次访问时自动创建。 + /// + public RecyclerNavigationController NavigationController => navigationController ??= new RecyclerNavigationController(this); + #endregion #region Public Properties - State /// - /// 获取或设置当前绑定的适配器 - /// 适配器负责提供数据和创建 ViewHolder + /// 获取或设置当前绑定的适配器实例。 /// public IAdapter RecyclerViewAdapter { get; set; } /// - /// 获取或设置当前显示的列表项索引 + /// 获取或设置当前记录的逻辑选中索引。 /// public int CurrentIndex { @@ -301,13 +425,12 @@ namespace AlicizaX.UI #region Events /// - /// 当前索引改变时触发的事件 - /// 参数为新的索引值 + /// 当当前逻辑索引发生变化时触发。 /// public Action OnIndexChanged; /// - /// 滚动位置改变时触发的事件 + /// 当滚动位置发生变化时触发。 /// public Action OnScrollValueChanged; @@ -315,17 +438,29 @@ namespace AlicizaX.UI #region Unity Lifecycle + /// + /// 初始化模板、滚动器、滚动条与导航桥接组件。 + /// private void Awake() { + if (mainThreadId < 0) + { + mainThreadId = Thread.CurrentThread.ManagedThreadId; + } + InitializeTemplates(); ConfigureScroller(); ConfigureScrollbar(); + EnsureNavigationBridge(); } #endregion #region Initialization + /// + /// 初始化所有模板实例并将其隐藏,避免模板对象直接参与显示。 + /// private void InitializeTemplates() { if (templates == null) return; @@ -339,6 +474,57 @@ namespace AlicizaX.UI } } + /// + /// 确保当前对象挂载用于导航事件桥接的组件。 + /// + private void EnsureNavigationBridge() + { + if (GetComponent() == null) + { + gameObject.AddComponent(); + } + } + + /// + /// 查找当前可见列表边缘对应的数据索引。 + /// + /// 表示查找最大的布局索引;否则查找最小的布局索引。 + /// 找到的边缘数据索引;不存在可见项时返回 -1 + private int FindVisibleEdgeDataIndex(bool useMax) + { + if (ViewProvider.ViewHolders.Count == 0) + { + return -1; + } + + int best = useMax ? int.MinValue : int.MaxValue; + for (int i = 0; i < ViewProvider.ViewHolders.Count; i++) + { + ViewHolder holder = ViewProvider.ViewHolders[i]; + if (holder == null || holder.Index < 0) + { + continue; + } + + if (useMax) + { + if (holder.Index > best) + { + best = holder.Index; + } + } + else if (holder.Index < best) + { + best = holder.Index; + } + } + + return best is int.MinValue or int.MaxValue ? -1 : best; + } + + /// + /// 配置滚动器参数并注册滚动回调。 + /// private void ConfigureScroller() { if (scroller == null) return; @@ -351,6 +537,9 @@ namespace AlicizaX.UI UpdateScrollerState(); } + /// + /// 配置滚动条监听与拖拽结束回调。 + /// private void ConfigureScrollbar() { if (!showScrollBar || scrollbar == null) return; @@ -372,17 +561,35 @@ namespace AlicizaX.UI #region Public Methods - Setup /// - /// 设置数据适配器并初始化布局管理器 + /// 绑定新的适配器,并重建 RecyclerView 与布局管理器之间的关联关系。 /// - /// 要绑定的适配器实例 + /// 要绑定的适配器实例。 public void SetAdapter(IAdapter adapter) { + if (!EnsureMainThread(nameof(SetAdapter))) + { + return; + } + if (adapter == null) { Debug.LogError("Adapter cannot be null"); return; } + if (layoutManager == null) + { + Debug.LogError("LayoutManager cannot be null"); + return; + } + + if (ReferenceEquals(RecyclerViewAdapter, adapter)) + { + return; + } + + viewProvider?.Clear(); + (RecyclerViewAdapter as IItemRenderCacheOwner)?.ReleaseAllItemRenders(); RecyclerViewAdapter = adapter; ViewProvider.Adapter = adapter; ViewProvider.LayoutManager = layoutManager; @@ -394,14 +601,238 @@ namespace AlicizaX.UI layoutManager.Alignment = alignment; layoutManager.Spacing = spacing; layoutManager.Padding = padding; + startIndex = 0; + endIndex = -1; + currentIndex = -1; + ClearPendingFocusRequest(); } /// - /// 重置列表状态 - /// 清空所有 ViewHolder 并将滚动位置重置为起始位置 + /// 尝试获取当前可见区域内指定索引对应的视图持有者。 + /// + /// 目标布局索引。 + /// 返回找到的视图持有者。 + /// 找到且该持有者仍处于可见范围内时返回 ;否则返回 + public bool TryGetVisibleViewHolder(int index, out ViewHolder viewHolder) + { + viewHolder = ViewProvider.GetViewHolder(index); + return viewHolder != null && layoutManager != null && layoutManager.IsVisible(viewHolder.Index); + } + + /// + /// 尝试将焦点移动到指定索引对应的列表项。 + /// + /// 目标数据索引。 + /// 是否先以平滑滚动方式将目标项滚入可见区域。 + /// 目标项滚动完成后的对齐方式。 + /// 成功定位并应用焦点时返回 ;否则返回 + public bool TryFocusIndex(int index, bool smooth = false, ScrollAlignment alignment = ScrollAlignment.Center) + { + if (RecyclerViewAdapter == null || RecyclerViewAdapter.GetItemCount() <= 0 || index < 0 || index >= RecyclerViewAdapter.GetItemCount()) + { + return false; + } + + if (smooth && (!TryGetVisibleViewHolder(index, out ViewHolder smoothHolder) || !IsFullyVisible(smoothHolder))) + { + QueueFocusRequest(index, alignment); + ScrollToWithAlignment(index, alignment, 0f, true); + return true; + } + + if (!TryGetVisibleViewHolder(index, out ViewHolder holder) || !IsFullyVisible(holder)) + { + ScrollToWithAlignment(index, alignment, 0f, false); + if (!TryGetVisibleViewHolder(index, out holder)) + { + Refresh(); + TryGetVisibleViewHolder(index, out holder); + } + } + + if (holder == null) + { + return false; + } + + if (!IsFullyVisible(holder)) + { + ScrollToWithAlignment(index, alignment, 0f, false); + Refresh(); + TryGetVisibleViewHolder(index, out holder); + } + + if (holder == null || !IsFullyVisible(holder) || !TryResolveFocusTarget(holder, out GameObject target)) + { + return false; + } + + ApplyFocus(target); + UpdateCurrentIndex(index); + return true; + } + + /// + /// 按进入方向尝试将焦点移入当前列表。 + /// + /// 焦点进入列表时的方向。 + /// 成功聚焦某个列表项时返回 ;否则返回 + public bool TryFocusEntry(MoveDirection entryDirection) + { + if (RecyclerViewAdapter == null) + { + return false; + } + + int realCount = RecyclerViewAdapter.GetRealCount(); + if (realCount <= 0) + { + return false; + } + + int targetIndex = entryDirection is MoveDirection.Up or MoveDirection.Left + ? FindVisibleEdgeDataIndex(true) + : FindVisibleEdgeDataIndex(false); + + if (targetIndex < 0) + { + targetIndex = entryDirection is MoveDirection.Up or MoveDirection.Left + ? realCount - 1 + : Mathf.Clamp(CurrentIndex, 0, realCount - 1); + } + + return TryFocusIndex(targetIndex); + } + + /// + /// 解析指定持有者最终应被聚焦的目标对象。 + /// + /// 目标视图持有者。 + /// 返回解析得到的焦点对象。 + /// 成功解析到可聚焦对象时返回 ;否则返回 + internal bool TryResolveFocusTarget(ViewHolder holder, out GameObject target) + { + target = null; + if (holder == null) + { + return false; + } + + ItemInteractionProxy proxy = holder.GetComponent(); + Selectable selectable = proxy != null ? proxy.GetSelectable() : holder.GetComponent(); + if (selectable == null) + { + selectable = holder.GetComponentInChildren(true); + } + + target = selectable != null ? selectable.gameObject : holder.gameObject; + return target != null; + } + + /// + /// 判断指定持有者是否已经完整处于当前视口内。 + /// + /// 待检测的视图持有者。 + /// 完整可见时返回 ;否则返回 + private bool IsFullyVisible(ViewHolder holder) + { + if (holder == null) + { + return false; + } + + RectTransform viewport = content != null ? content.parent as RectTransform : null; + if (viewport == null) + { + viewport = transform as RectTransform; + } + + if (viewport == null) + { + return true; + } + + Bounds bounds = RectTransformUtility.CalculateRelativeRectTransformBounds(viewport, holder.RectTransform); + Rect viewportRect = viewport.rect; + const float epsilon = 0.01f; + + return direction switch + { + Direction.Vertical => bounds.min.y >= viewportRect.yMin - epsilon && + bounds.max.y <= viewportRect.yMax + epsilon, + Direction.Horizontal => bounds.min.x >= viewportRect.xMin - epsilon && + bounds.max.x <= viewportRect.xMax + epsilon, + _ => bounds.min.x >= viewportRect.xMin - epsilon && + bounds.max.x <= viewportRect.xMax + epsilon && + bounds.min.y >= viewportRect.yMin - epsilon && + bounds.max.y <= viewportRect.yMax + epsilon + }; + } + + /// + /// 将 EventSystem 焦点切换到指定目标,并在下一帧做一次恢复校正。 + /// + /// 目标焦点对象。 + private void ApplyFocus(GameObject target) + { + if (target == null) + { + return; + } + + EventSystem eventSystem = EventSystem.current; + if (eventSystem == null) + { + return; + } + + if (!ReferenceEquals(eventSystem.currentSelectedGameObject, target)) + { + eventSystem.SetSelectedGameObject(target); + } + + if (focusRecoveryCoroutine != null) + { + StopCoroutine(focusRecoveryCoroutine); + } + + focusRecoveryCoroutine = StartCoroutine(RecoverFocusNextFrame(target)); + } + + /// + /// 在下一帧尝试恢复目标对象的焦点,避免布局刷新期间焦点丢失。 + /// + /// 需要恢复焦点的目标对象。 + /// 用于协程调度的枚举器。 + private IEnumerator RecoverFocusNextFrame(GameObject target) + { + yield return null; + focusRecoveryCoroutine = null; + + if (target == null || !target.activeInHierarchy) + { + yield break; + } + + EventSystem eventSystem = EventSystem.current; + if (eventSystem == null || ReferenceEquals(eventSystem.currentSelectedGameObject, target)) + { + yield break; + } + + eventSystem.SetSelectedGameObject(target); + } + + /// + /// 重置视图池、滚动位置与当前索引状态。 /// public void Reset() { + if (!EnsureMainThread(nameof(Reset))) + { + return; + } + viewProvider?.Reset(); if (scroller != null) @@ -413,6 +844,11 @@ namespace AlicizaX.UI { scrollbar.SetValueWithoutNotify(0); } + + startIndex = 0; + endIndex = -1; + currentIndex = -1; + ClearPendingFocusRequest(); } #endregion @@ -420,15 +856,29 @@ namespace AlicizaX.UI #region Public Methods - Layout /// - /// 刷新列表显示 - /// 清空当前所有 ViewHolder 并根据当前滚动位置重新创建可见项 + /// 按当前滚动位置重新创建可见范围内的所有视图持有者。 /// public void Refresh() { - ViewProvider.Clear(); + if (!EnsureMainThread(nameof(Refresh))) + { + return; + } - startIndex = layoutManager.GetStartIndex(); + ViewProvider.Clear(); + if (layoutManager == null || RecyclerViewAdapter == null || RecyclerViewAdapter.GetItemCount() <= 0) + { + startIndex = 0; + endIndex = -1; + return; + } + + startIndex = Mathf.Max(0, layoutManager.GetStartIndex()); endIndex = layoutManager.GetEndIndex(); + if (endIndex < startIndex) + { + return; + } for (int i = startIndex; i <= endIndex; i += layoutManager.Unit) { @@ -439,11 +889,15 @@ namespace AlicizaX.UI } /// - /// 请求重新布局 - /// 重新计算内容大小、视口大小,并更新滚动条显示状态 + /// 重新计算内容尺寸、滚动能力与对象池预热状态。 /// public void RequestLayout() { + if (!EnsureMainThread(nameof(RequestLayout))) + { + return; + } + if (layoutManager == null) { UpdateScrollbarVisibility(); @@ -454,6 +908,7 @@ namespace AlicizaX.UI if (scroller == null) { + viewProvider?.PreparePool(); UpdateScrollbarVisibility(); return; } @@ -462,6 +917,7 @@ namespace AlicizaX.UI scroller.ViewSize = layoutManager.ViewportSize; scroller.ContentSize = layoutManager.ContentSize; scroller.Position = Mathf.Clamp(scroller.Position, 0, scroller.MaxPosition); + viewProvider?.PreparePool(); UpdateScrollerState(); UpdateScrollbarVisibility(); @@ -473,19 +929,19 @@ namespace AlicizaX.UI #region Public Methods - Scrolling /// - /// 获取当前的滚动位置 + /// 获取当前滚动位置。 /// - /// 当前滚动位置值,如果没有 Scroller 则返回 0 + /// 当前滚动偏移量;未启用滚动器时返回 0 public float GetScrollPosition() { return scroller != null ? scroller.Position : 0; } /// - /// 滚动到指定索引的列表项 + /// 将列表滚动到指定索引对应的位置。 /// - /// 目标列表项的索引 - /// 是否使用平滑滚动动画 + /// 目标数据索引。 + /// 是否使用平滑滚动。 public void ScrollTo(int index, bool smooth = false) { if (!scroll || scroller == null) return; @@ -501,13 +957,13 @@ namespace AlicizaX.UI } /// - /// 滚动到指定索引的列表项,并使用指定的对齐方式 + /// 将列表滚动到指定索引,并按给定对齐方式定位。 /// - /// 目标列表项的索引 - /// 对齐方式(起始、居中或结束) - /// 额外的偏移量 - /// 是否使用平滑滚动动画 - /// 滚动动画持续时间(秒) + /// 目标数据索引。 + /// 目标项滚动完成后的对齐方式。 + /// 在对齐基础上的额外偏移量。 + /// 是否使用平滑滚动。 + /// 平滑滚动时长,单位为秒。 public void ScrollToWithAlignment(int index, ScrollAlignment alignment, float offset = 0f, bool smooth = false, float duration = 0.3f) { if (!scroll || scroller == null) return; @@ -521,7 +977,7 @@ namespace AlicizaX.UI if (duration > 0 && smooth) { - scroller.ScrollTo(targetPosition, true); + scroller.ScrollToDuration(targetPosition, duration); } else { @@ -536,6 +992,13 @@ namespace AlicizaX.UI UpdateCurrentIndex(index); } + /// + /// 计算指定索引在目标对齐方式下应滚动到的位置。 + /// + /// 目标数据索引。 + /// 目标项滚动完成后的对齐方式。 + /// 在对齐基础上的额外偏移量。 + /// 计算得到的滚动位置,结果会被限制在合法范围内。 private float CalculateScrollPositionWithAlignment(int index, ScrollAlignment alignment, float offset) { if (RecyclerViewAdapter == null || index < 0 || index >= RecyclerViewAdapter.GetItemCount()) @@ -547,7 +1010,7 @@ namespace AlicizaX.UI 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 @@ -558,7 +1021,7 @@ namespace AlicizaX.UI _ => itemPosition }; - // Apply custom offset + // 叠加调用方传入的额外偏移量。 targetPosition += offset; if (UGListExtensions.DebugScrollTo) @@ -566,23 +1029,33 @@ namespace AlicizaX.UI 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); @@ -593,6 +1066,10 @@ namespace AlicizaX.UI #region Private Methods - Scroll Callbacks + /// + /// 响应滚动器位置变化,更新布局、滚动条与可见区间。 + /// + /// 当前滚动位置。 private void OnScrollChanged(float position) { layoutManager.UpdateLayout(); @@ -602,14 +1079,23 @@ namespace AlicizaX.UI OnScrollValueChanged?.Invoke(); } + /// + /// 响应滚动器停止移动事件,处理吸附与挂起焦点请求。 + /// private void OnMoveStoped() { - if (snap) + if (snap && SnapToNearestItem()) { - SnapToNearestItem(); + return; } + + TryProcessPendingFocusRequest(); } + /// + /// 响应滚动条值变化并同步滚动器位置。 + /// + /// 滚动条归一化值。 private void OnScrollbarChanged(float ratio) { if (scroller != null) @@ -618,6 +1104,9 @@ namespace AlicizaX.UI } } + /// + /// 响应滚动条拖拽结束事件,并在需要时触发吸附。 + /// private void OnScrollbarDragEnd() { if (scroller == null) return; @@ -632,6 +1121,10 @@ namespace AlicizaX.UI #region Private Methods - Scroll Helpers + /// + /// 根据当前滚动位置同步滚动条显示值。 + /// + /// 当前滚动位置。 private void UpdateScrollbarValue(float position) { if (scrollbar != null && scroller != null) @@ -641,9 +1134,17 @@ namespace AlicizaX.UI } } + /// + /// 根据当前滚动位置增量更新可见区间内的视图持有者。 + /// private void UpdateVisibleRange() { - // Handle start index + if (layoutManager == null || RecyclerViewAdapter == null || RecyclerViewAdapter.GetItemCount() <= 0) + { + return; + } + + // 处理可见区间起始端的回收与补充。 if (layoutManager.IsFullInvisibleStart(startIndex)) { viewProvider.RemoveViewHolder(startIndex); @@ -653,7 +1154,7 @@ namespace AlicizaX.UI { if (startIndex == 0) { - // TODO: Implement refresh logic + // TODO: 在滚动到列表起始端时补充刷新逻辑。 } else { @@ -662,7 +1163,7 @@ namespace AlicizaX.UI } } - // Handle end index + // 处理可见区间末端的回收与补充。 if (layoutManager.IsFullInvisibleEnd(endIndex)) { viewProvider.RemoveViewHolder(endIndex); @@ -672,7 +1173,7 @@ namespace AlicizaX.UI { if (endIndex >= viewProvider.GetItemCount() - layoutManager.Unit) { - // TODO: Implement load more logic + // TODO: 在滚动到列表末端时补充加载更多逻辑。 } else { @@ -681,13 +1182,16 @@ namespace AlicizaX.UI } } - // Refresh if out of visible range + // 若增量更新后的区间与实际可见区不一致,则退化为全量刷新。 if (!layoutManager.IsVisible(startIndex) || !layoutManager.IsVisible(endIndex)) { Refresh(); } } + /// + /// 根据当前状态更新滚动条的显示与交互能力。 + /// private void UpdateScrollbarVisibility() { if (scrollbar == null) @@ -706,6 +1210,10 @@ namespace AlicizaX.UI } } + /// + /// 判断当前是否应显示滚动条。 + /// + /// 应显示滚动条时返回 ;否则返回 private bool ShouldShowScrollbar() { if (!showScrollBar || !scroll || scrollbar == null || scroller == null || layoutManager == null || !SupportsOverflowCheck()) @@ -726,6 +1234,9 @@ namespace AlicizaX.UI return HasScrollableContent(); } + /// + /// 根据列表方向设置滚动条方向。 + /// private void ConfigureScrollbarDirection() { scrollbar.direction = direction == Direction.Vertical @@ -733,18 +1244,67 @@ namespace AlicizaX.UI : Scrollbar.Direction.LeftToRight; } + /// + /// 根据内容尺寸与视口尺寸更新滚动条手柄长度。 + /// private void ConfigureScrollbarSize() { + float contentLength; + float viewLength; + float trackLength; + if (direction == Direction.Vertical) { - scrollbar.size = scroller.ViewSize.y / scroller.ContentSize.y; + contentLength = scroller.ContentSize.y; + viewLength = scroller.ViewSize.y; + trackLength = GetScrollbarTrackLength(true); } else { - scrollbar.size = scroller.ViewSize.x / scroller.ContentSize.x; + contentLength = scroller.ContentSize.x; + viewLength = scroller.ViewSize.x; + trackLength = GetScrollbarTrackLength(false); } + + if (contentLength <= 0f || viewLength <= 0f) + { + scrollbar.size = 1f; + return; + } + + float normalizedSize = viewLength / contentLength; + float minNormalizedSize = trackLength > 0f + ? Mathf.Clamp01(MinScrollbarHandlePixels / trackLength) + : 0f; + + scrollbar.size = Mathf.Clamp(Mathf.Max(normalizedSize, minNormalizedSize), minNormalizedSize, 1f); } + /// + /// 获取滚动条轨道的像素长度。 + /// + /// 是否按垂直滚动条计算。 + /// 滚动条轨道长度;无法获取时返回 0 + private float GetScrollbarTrackLength(bool vertical) + { + if (scrollbar == null) + { + return 0f; + } + + RectTransform scrollbarRect = scrollbar.transform as RectTransform; + if (scrollbarRect == null) + { + return 0f; + } + + Rect rect = scrollbarRect.rect; + return vertical ? rect.height : rect.width; + } + + /// + /// 根据当前内容是否可滚动来更新滚动器启用状态。 + /// private void UpdateScrollerState() { if (scroller == null) @@ -755,11 +1315,19 @@ namespace AlicizaX.UI scroller.enabled = scroll && (!ShouldLimitScrollToOverflow() || HasScrollableContent()); } + /// + /// 判断是否需要将滚动能力限制在内容溢出场景下才启用。 + /// + /// 需要仅在内容溢出时启用滚动返回 ;否则返回 private bool ShouldLimitScrollToOverflow() { return showScrollBar && showScrollBarOnlyWhenScrollable && SupportsOverflowCheck(); } + /// + /// 判断当前内容尺寸是否超过视口尺寸。 + /// + /// 内容可滚动时返回 ;否则返回 private bool HasScrollableContent() { if (layoutManager == null) @@ -780,11 +1348,19 @@ namespace AlicizaX.UI return false; } + /// + /// 判断当前方向是否支持溢出检测。 + /// + /// 支持垂直或水平溢出检测时返回 ;否则返回 private bool SupportsOverflowCheck() { return direction == Direction.Vertical || direction == Direction.Horizontal; } + /// + /// 判断当前布局是否已经具备有效的滚动尺寸信息。 + /// + /// 内容尺寸与视口尺寸均有效时返回 ;否则返回 private bool HasValidScrollMetrics() { if (direction == Direction.Vertical) @@ -800,17 +1376,48 @@ namespace AlicizaX.UI return false; } - private void SnapToNearestItem() + /// + /// 将滚动位置吸附到最近的列表项。 + /// + /// 触发了新的吸附滚动时返回 ;否则返回 + private bool SnapToNearestItem() { int index = layoutManager.PositionToIndex(GetScrollPosition()); + float targetPosition = layoutManager.IndexToPosition(index); + if (Mathf.Abs(targetPosition - GetScrollPosition()) <= 0.1f) + { + return false; + } + ScrollTo(index, true); + return true; } + /// + /// 更新当前逻辑索引,并在变化时触发事件通知。 + /// + /// 新的候选索引。 private void UpdateCurrentIndex(int index) { if (RecyclerViewAdapter == null) return; - int itemCount = RecyclerViewAdapter.GetItemCount(); + int itemCount = RecyclerViewAdapter.GetRealCount(); + if (itemCount <= 0) + { + itemCount = RecyclerViewAdapter.GetItemCount(); + } + + if (itemCount <= 0) + { + if (currentIndex != -1) + { + currentIndex = -1; + OnIndexChanged?.Invoke(currentIndex); + } + + return; + } + index %= itemCount; index = index < 0 ? itemCount + index : index; @@ -821,6 +1428,122 @@ namespace AlicizaX.UI } } + /// + /// 重新绑定当前可见区域内指定数据索引对应的所有持有者。 + /// + /// 要重绑的数据索引。 + /// 实际完成重绑的持有者数量。 + internal int RebindVisibleDataIndex(int dataIndex) + { + if (!EnsureMainThread(nameof(RebindVisibleDataIndex)) || + RecyclerViewAdapter == null || + !ViewProvider.TryGetViewHoldersByDataIndex(dataIndex, out IReadOnlyList holders)) + { + return 0; + } + + int reboundCount = 0; + for (int i = 0; i < holders.Count; i++) + { + ViewHolder holder = holders[i]; + if (holder == null) + { + continue; + } + + RecyclerViewAdapter.OnBindViewHolder(holder, holder.Index); + reboundCount++; + } + + return reboundCount; + } + + /// + /// 重新绑定当前可见区域内指定数据区间对应的所有持有者。 + /// + /// 起始数据索引。 + /// 需要重绑的数据项数量。 + /// 实际完成重绑的持有者总数。 + internal int RebindVisibleDataRange(int startDataIndex, int count) + { + if (count <= 0) + { + return 0; + } + + int reboundCount = 0; + int endDataIndex = startDataIndex + count; + for (int dataIndex = startDataIndex; dataIndex < endDataIndex; dataIndex++) + { + reboundCount += RebindVisibleDataIndex(dataIndex); + } + + return reboundCount; + } + + /// + /// 缓存一条等待滚动结束后执行的焦点请求。 + /// + /// 待聚焦的数据索引。 + /// 目标对齐方式。 + private void QueueFocusRequest(int index, ScrollAlignment alignment) + { + hasPendingFocusRequest = true; + pendingFocusIndex = index; + pendingFocusAlignment = alignment; + } + + /// + /// 清除当前缓存的焦点请求。 + /// + private void ClearPendingFocusRequest() + { + hasPendingFocusRequest = false; + pendingFocusIndex = -1; + pendingFocusAlignment = ScrollAlignment.Center; + } + + /// + /// 尝试执行当前缓存的焦点请求。 + /// + private void TryProcessPendingFocusRequest() + { + if (!hasPendingFocusRequest) + { + return; + } + + int index = pendingFocusIndex; + ScrollAlignment alignment = pendingFocusAlignment; + ClearPendingFocusRequest(); + TryFocusIndex(index, false, alignment); + } + + /// + /// 判断当前调用线程是否为 Unity 主线程。 + /// + /// 当前线程为主线程时返回 ;否则返回 + private static bool IsMainThread() + { + return mainThreadId < 0 || Thread.CurrentThread.ManagedThreadId == mainThreadId; + } + + /// + /// 校验当前调用是否发生在 Unity 主线程上。 + /// + /// 发起校验的调用方名称。 + /// 位于主线程时返回 ;否则返回 + private bool EnsureMainThread(string caller) + { + if (IsMainThread()) + { + return true; + } + + Debug.LogError($"RecyclerView.{caller} must run on Unity main thread."); + return false; + } + #endregion } } diff --git a/Runtime/RecyclerView/ScrollAlignment.cs b/Runtime/RecyclerView/ScrollAlignment.cs index a25fb96..c420433 100644 --- a/Runtime/RecyclerView/ScrollAlignment.cs +++ b/Runtime/RecyclerView/ScrollAlignment.cs @@ -1,23 +1,9 @@ namespace AlicizaX.UI { - /// - /// 定义滚动到列表项时的对齐方式 - /// public enum ScrollAlignment { - /// - /// 将列表项对齐到视口的顶部/左侧 - /// Start, - - /// - /// 将列表项对齐到视口的中心 - /// Center, - - /// - /// 将列表项对齐到视口的底部/右侧 - /// End } } diff --git a/Runtime/RecyclerView/Scroller/IScroller.cs b/Runtime/RecyclerView/Scroller/IScroller.cs index 79cddf4..755a926 100644 --- a/Runtime/RecyclerView/Scroller/IScroller.cs +++ b/Runtime/RecyclerView/Scroller/IScroller.cs @@ -2,37 +2,16 @@ using UnityEngine.Events; namespace AlicizaX.UI { - /// - /// 滚动控制器接口 - /// 定义滚动行为的基本契约 - /// public interface IScroller { - /// - /// 获取或设置当前滚动位置 - /// float Position { get; set; } - /// - /// 滚动到指定位置 - /// - /// 目标位置 - /// 是否使用平滑滚动 void ScrollTo(float position, bool smooth = false); } - /// - /// 滚动位置改变事件 - /// public class ScrollerEvent : UnityEvent { } - /// - /// 滚动停止事件 - /// public class MoveStopEvent : UnityEvent { } - /// - /// 拖拽状态改变事件 - /// public class DraggingEvent : UnityEvent { } } diff --git a/Runtime/RecyclerView/Scroller/Scroller.cs b/Runtime/RecyclerView/Scroller/Scroller.cs index 47d050b..3332f0e 100644 --- a/Runtime/RecyclerView/Scroller/Scroller.cs +++ b/Runtime/RecyclerView/Scroller/Scroller.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using UnityEngine; using UnityEngine.EventSystems; @@ -6,6 +6,9 @@ namespace AlicizaX.UI { public class Scroller : MonoBehaviour, IScroller, IBeginDragHandler, IEndDragHandler, IDragHandler, IScrollHandler { + private static readonly WaitForEndOfFrame EndOfFrameYield = new(); + private Coroutine movementCoroutine; + protected float position; public float Position { get => position; set => position = value; } @@ -19,9 +22,6 @@ namespace AlicizaX.UI set => direction = value; } - /// - /// 内容所需要大小 - /// protected Vector2 contentSize; public Vector2 ContentSize { @@ -29,9 +29,6 @@ namespace AlicizaX.UI set => contentSize = value; } - /// - /// 所在 View 的真实大小 - /// protected Vector2 viewSize; public Vector2 ViewSize { @@ -76,12 +73,14 @@ namespace AlicizaX.UI public DraggingEvent OnDragging { get => draggingEvent; set => draggingEvent = value; } - // 停止滑动的时间,但此时并未释放鼠标按键 public float dragStopTime = 0f; + public virtual void ScrollTo(float position, bool smooth = false) { - if (position == this.position) return; + if (Mathf.Approximately(position, this.position)) return; + + StopMovement(); if (!smooth) { @@ -90,11 +89,28 @@ namespace AlicizaX.UI } else { - StopAllCoroutines(); - StartCoroutine(MoveTo(position)); + movementCoroutine = StartCoroutine(RunMotion(MoveTo(position))); } } + public virtual void ScrollToDuration(float position, float duration) + { + if (Mathf.Approximately(position, this.position)) + { + return; + } + + StopMovement(); + if (duration <= 0f) + { + this.position = position; + OnValueChanged?.Invoke(this.position); + return; + } + + movementCoroutine = StartCoroutine(RunMotion(ToPositionByDuration(position, duration))); + } + public virtual void ScrollToRatio(float ratio) { ScrollTo(MaxPosition * ratio, false); @@ -103,7 +119,7 @@ namespace AlicizaX.UI public void OnBeginDrag(PointerEventData eventData) { OnDragging?.Invoke(true); - StopAllCoroutines(); + StopMovement(); } public void OnEndDrag(PointerEventData eventData) @@ -125,7 +141,7 @@ namespace AlicizaX.UI public void OnScroll(PointerEventData eventData) { - StopAllCoroutines(); + StopMovement(); float rate = GetScrollRate() * wheelSpeed; velocity = direction == Direction.Vertical ? -eventData.scrollDelta.y * rate : eventData.scrollDelta.x * rate; @@ -156,18 +172,13 @@ namespace AlicizaX.UI return rate; } - /// - /// 松手时的惯性滑动 - /// protected virtual void Inertia() { - // 松手时的时间 离 停止滑动的时间 超过一定时间,则认为此次惯性滑动无效 - if (!snap && (Time.time - dragStopTime) > 0.01f) return; if (Mathf.Abs(velocity) > 0.1f) { - StopAllCoroutines(); - StartCoroutine(InertiaTo()); + StopMovement(); + movementCoroutine = StartCoroutine(RunMotion(InertiaTo())); } else { @@ -175,20 +186,17 @@ namespace AlicizaX.UI } } - /// - /// 滑动到顶部/底部之后,松手时回弹 - /// protected virtual void Elastic() { if (position < 0) { - StopAllCoroutines(); - StartCoroutine(ElasticTo(0)); + StopMovement(); + movementCoroutine = StartCoroutine(RunMotion(ElasticTo(0))); } else if (position > MaxPosition) { - StopAllCoroutines(); - StartCoroutine(ElasticTo(MaxPosition)); + StopMovement(); + movementCoroutine = StartCoroutine(RunMotion(ElasticTo(MaxPosition))); } } @@ -208,7 +216,7 @@ namespace AlicizaX.UI OnValueChanged?.Invoke(position); - yield return new WaitForEndOfFrame(); + yield return EndOfFrameYield; } OnMoveStoped?.Invoke(); @@ -224,6 +232,24 @@ namespace AlicizaX.UI yield return ToPosition(targetPos, scrollSpeed); } + IEnumerator ToPositionByDuration(float targetPos, float duration) + { + duration = Mathf.Max(duration, 0.0001f); + float startPos = position; + float elapsed = 0f; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + float t = Mathf.Clamp01(elapsed / duration); + position = Mathf.Lerp(startPos, targetPos, t); + OnValueChanged?.Invoke(position); + yield return EndOfFrameYield; + } + + position = targetPos; + OnValueChanged?.Invoke(position); + } + IEnumerator ToPosition(float targetPos, float speed) { float startPos = position; @@ -235,11 +261,28 @@ namespace AlicizaX.UI time += Time.deltaTime; - yield return new WaitForEndOfFrame(); + yield return EndOfFrameYield; } position = targetPos; OnValueChanged?.Invoke(position); } + + private IEnumerator RunMotion(IEnumerator motion) + { + yield return motion; + movementCoroutine = null; + } + + private void StopMovement() + { + if (movementCoroutine == null) + { + return; + } + + StopCoroutine(movementCoroutine); + movementCoroutine = null; + } } } diff --git a/Runtime/RecyclerView/UGList.cs b/Runtime/RecyclerView/UGList.cs index 2e9a186..f706af0 100644 --- a/Runtime/RecyclerView/UGList.cs +++ b/Runtime/RecyclerView/UGList.cs @@ -3,13 +3,33 @@ using System.Collections.Generic; namespace AlicizaX.UI { + /// + /// 封装 RecyclerView 与 Adapter 的通用列表基类。 + /// + /// 列表数据类型。 + /// 适配器类型。 public abstract class UGListBase where TAdapter : Adapter where TData : ISimpleViewData { + /// + /// 关联的 RecyclerView 实例。 + /// protected readonly RecyclerView _recyclerView; + + /// + /// 当前列表使用的适配器实例。 + /// protected readonly TAdapter _adapter; + /// + /// 获取当前绑定的 RecyclerView。 + /// public RecyclerView RecyclerView => _recyclerView; + /// + /// 初始化列表封装并将适配器绑定到 RecyclerView。 + /// + /// 目标 RecyclerView。 + /// 用于驱动列表渲染的适配器。 public UGListBase(RecyclerView recyclerView, TAdapter adapter) { _recyclerView = recyclerView; @@ -21,30 +41,60 @@ namespace AlicizaX.UI } } + /// + /// 获取当前列表使用的适配器。 + /// public TAdapter Adapter => _adapter; - public void RegisterItemRender(string viewName = "") where TItemRender : IItemRender + /// + /// 注册指定视图类型对应的 ItemRender。 + /// + /// ItemRender 类型。 + /// 视图名称;为空时表示默认视图。 + public void RegisterItemRender(string viewName = "") where TItemRender : ItemRenderBase { _adapter.RegisterItemRender(viewName); } + /// + /// 按运行时类型注册指定视图对应的 ItemRender。 + /// + /// ItemRender 的运行时类型。 + /// 视图名称;为空时表示默认视图。 public void RegisterItemRender(Type itemRenderType, string viewName = "") { _adapter.RegisterItemRender(itemRenderType, viewName); } + /// + /// 注销指定视图名称对应的 ItemRender 注册。 + /// + /// 视图名称;为空时表示默认视图。 + /// 是否成功移除对应注册。 public bool UnregisterItemRender(string viewName = "") { return _adapter.UnregisterItemRender(viewName); } + /// + /// 清空当前列表的全部 ItemRender 注册信息。 + /// public void ClearItemRenderRegistrations() { _adapter.ClearItemRenderRegistrations(); } + /// + /// 当前持有的数据集合引用。 + /// private List _datas; + /// + /// 获取或设置当前列表数据。 + /// + /// + /// 设置数据时会同步调用适配器刷新列表内容。 + /// public List Data { get => _datas; @@ -56,49 +106,110 @@ namespace AlicizaX.UI } } + /// + /// 提供单模板列表的便捷封装。 + /// + /// 列表数据类型。 public class UGList : UGListBase> where TData : ISimpleViewData { + /// + /// 初始化单模板列表。 + /// + /// 目标 RecyclerView。 public UGList(RecyclerView recyclerView) : base(recyclerView, new Adapter(recyclerView)) { } } + /// + /// 提供分组列表的便捷封装。 + /// + /// 分组列表数据类型。 public class UGGroupList : UGListBase> where TData : class, IGroupViewData, new() { + /// + /// 初始化分组列表。 + /// + /// 目标 RecyclerView。 + /// 分组头使用的模板名称。 public UGGroupList(RecyclerView recyclerView, string groupViewName) : base(recyclerView, new GroupAdapter(recyclerView, groupViewName)) { } } + /// + /// 提供循环列表的便捷封装。 + /// + /// 循环列表数据类型。 public class UGLoopList : UGListBase> where TData : ISimpleViewData, new() { + /// + /// 初始化循环列表。 + /// + /// 目标 RecyclerView。 public UGLoopList(RecyclerView recyclerView) : base(recyclerView, new LoopAdapter(recyclerView)) { } } + /// + /// 提供多模板列表的便捷封装。 + /// + /// 多模板列表数据类型。 public class UGMixedList : UGListBase> where TData : IMixedViewData { + /// + /// 初始化多模板列表。 + /// + /// 目标 RecyclerView。 public UGMixedList(RecyclerView recyclerView) : base(recyclerView, new MixedAdapter(recyclerView)) { } } + /// + /// 提供常用 UGList 类型的快速创建方法。 + /// public static class UGListCreateHelper { + /// + /// 创建单模板列表封装。 + /// + /// 列表数据类型。 + /// 目标 RecyclerView。 + /// 创建后的单模板列表实例。 public static UGList Create(RecyclerView recyclerView) where TData : ISimpleViewData => new UGList(recyclerView); + /// + /// 创建分组列表封装。 + /// + /// 分组列表数据类型。 + /// 目标 RecyclerView。 + /// 分组头使用的模板名称。 + /// 创建后的分组列表实例。 public static UGGroupList CreateGroup(RecyclerView recyclerView, string groupViewName) where TData : class, IGroupViewData, new() => new UGGroupList(recyclerView, groupViewName); + /// + /// 创建循环列表封装。 + /// + /// 循环列表数据类型。 + /// 目标 RecyclerView。 + /// 创建后的循环列表实例。 public static UGLoopList CreateLoop(RecyclerView recyclerView) where TData : ISimpleViewData, new() => new UGLoopList(recyclerView); + /// + /// 创建多模板列表封装。 + /// + /// 多模板列表数据类型。 + /// 目标 RecyclerView。 + /// 创建后的多模板列表实例。 public static UGMixedList CreateMixed(RecyclerView recyclerView) where TData : IMixedViewData => new UGMixedList(recyclerView); } diff --git a/Runtime/RecyclerView/UGListExtensions.cs b/Runtime/RecyclerView/UGListExtensions.cs index fccf077..9cd164c 100644 --- a/Runtime/RecyclerView/UGListExtensions.cs +++ b/Runtime/RecyclerView/UGListExtensions.cs @@ -1,28 +1,29 @@ -using System; +using System; using UnityEngine; namespace AlicizaX.UI { /// - /// UGList 扩展方法类 - /// 提供增强的滚动功能 + /// 提供 UGList 的常用扩展方法。 /// public static class UGListExtensions { /// - /// 启用 ScrollTo 操作的调试日志 + /// 控制是否输出滚动定位调试日志。 /// public static bool DebugScrollTo { get; set; } = false; /// - /// 滚动到指定的列表项,支持对齐方式和动画选项 + /// 将列表滚动到指定索引,并按给定对齐方式定位。 /// - /// UGList 实例 - /// 要滚动到的列表项索引 - /// 列表项在视口中的对齐方式(起始、居中或结束) - /// 对齐后额外应用的偏移量(像素) - /// 是否使用动画滚动 - /// 动画持续时间(秒),仅在 smooth 为 true 时使用 + /// 列表数据类型。 + /// 适配器类型。 + /// 目标列表实例。 + /// 目标数据索引。 + /// 滚动完成后的对齐方式。 + /// 在对齐基础上的额外偏移量。 + /// 是否使用平滑滚动。 + /// 平滑滚动时长,单位为秒。 public static void ScrollTo( this UGListBase ugList, int index, @@ -48,8 +49,15 @@ namespace AlicizaX.UI } /// - /// 滚动到指定的列表项并将其对齐到视口的起始位置(顶部/左侧) + /// 将列表滚动到指定索引,并使目标项靠近起始端。 /// + /// 列表数据类型。 + /// 适配器类型。 + /// 目标列表实例。 + /// 目标数据索引。 + /// 在起始对齐基础上的额外偏移量。 + /// 是否使用平滑滚动。 + /// 平滑滚动时长,单位为秒。 public static void ScrollToStart( this UGListBase ugList, int index, @@ -63,8 +71,15 @@ namespace AlicizaX.UI } /// - /// 滚动到指定的列表项并将其对齐到视口的中心位置 + /// 将列表滚动到指定索引,并使目标项居中显示。 /// + /// 列表数据类型。 + /// 适配器类型。 + /// 目标列表实例。 + /// 目标数据索引。 + /// 在居中对齐基础上的额外偏移量。 + /// 是否使用平滑滚动。 + /// 平滑滚动时长,单位为秒。 public static void ScrollToCenter( this UGListBase ugList, int index, @@ -78,8 +93,15 @@ namespace AlicizaX.UI } /// - /// 滚动到指定的列表项并将其对齐到视口的结束位置(底部/右侧) + /// 将列表滚动到指定索引,并使目标项靠近末端。 /// + /// 列表数据类型。 + /// 适配器类型。 + /// 目标列表实例。 + /// 目标数据索引。 + /// 在末端对齐基础上的额外偏移量。 + /// 是否使用平滑滚动。 + /// 平滑滚动时长,单位为秒。 public static void ScrollToEnd( this UGListBase ugList, int index, diff --git a/Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs b/Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs deleted file mode 100644 index d6de0d4..0000000 --- a/Runtime/RecyclerView/ViewHolder/InteractiveViewHolder.cs +++ /dev/null @@ -1,22 +0,0 @@ -using UnityEngine.EventSystems; - -namespace AlicizaX.UI -{ - public abstract class InteractiveViewHolder : ViewHolder, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler - { - public virtual void OnPointerClick(PointerEventData eventData) - { - InvokeClickAction(); - } - - public virtual void OnPointerEnter(PointerEventData eventData) - { - InvokePointerEnterAction(); - } - - public virtual void OnPointerExit(PointerEventData eventData) - { - InvokePointerExitAction(); - } - } -} diff --git a/Runtime/RecyclerView/ViewHolder/ViewHolder.cs b/Runtime/RecyclerView/ViewHolder/ViewHolder.cs index 1fe4971..46e1ee6 100644 --- a/Runtime/RecyclerView/ViewHolder/ViewHolder.cs +++ b/Runtime/RecyclerView/ViewHolder/ViewHolder.cs @@ -6,9 +6,6 @@ namespace AlicizaX.UI public abstract class ViewHolder : MonoBehaviour { private RectTransform rectTransform; - private Action clickAction; - private Action pointerEnterAction; - private Action pointerExitAction; internal event Action Destroyed; @@ -23,53 +20,33 @@ namespace AlicizaX.UI return rectTransform; } - private set => rectTransform = value; } public string Name { get; internal set; } public int Index { get; internal set; } + public int DataIndex { get; internal set; } = -1; + + public RecyclerView RecyclerView { get; internal set; } + + public uint BindingVersion { get; private set; } + public Vector2 SizeDelta => RectTransform.sizeDelta; + internal uint AdvanceBindingVersion() + { + BindingVersion = BindingVersion == uint.MaxValue ? 1u : BindingVersion + 1u; + return BindingVersion; + } + protected internal virtual void OnRecycled() { - } - - internal void SetInteractionCallbacks( - Action clickAction = null, - Action pointerEnterAction = null, - Action pointerExitAction = null) - { - this.clickAction = clickAction; - this.pointerEnterAction = pointerEnterAction; - this.pointerExitAction = pointerExitAction; - } - - public void ClearInteractionCallbacks() - { - clickAction = null; - pointerEnterAction = null; - pointerExitAction = null; - } - - protected virtual void OnClearInteractionCallbacks() - { - } - - protected void InvokeClickAction() - { - clickAction?.Invoke(); - } - - protected void InvokePointerEnterAction() - { - pointerEnterAction?.Invoke(); - } - - protected void InvokePointerExitAction() - { - pointerExitAction?.Invoke(); + AdvanceBindingVersion(); + Name = string.Empty; + Index = -1; + DataIndex = -1; + RecyclerView = null; } protected virtual void OnDestroy() diff --git a/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs b/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs index 5024d93..6fb2147 100644 --- a/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs @@ -1,26 +1,30 @@ using System; using System.Collections.Generic; -using System.Linq; -using UnityEngine; namespace AlicizaX.UI { public class MixedViewProvider : ViewProvider { - [SerializeField] private ViewHolder chatLeftViewHolder; - [SerializeField] private ViewHolder chatRightViewHolder; + private readonly MixedObjectPool objectPool; + private readonly Dictionary templatesByName = new(StringComparer.Ordinal); - private IMixedObjectPool objectPool; - private Dictionary dict = new(); + public override string PoolStats => + $"hits={objectPool.HitCount}, misses={objectPool.MissCount}, destroys={objectPool.DestroyCount}"; public MixedViewProvider(RecyclerView recyclerView, ViewHolder[] templates) : base(recyclerView, templates) { - foreach (var template in templates) + for (int i = 0; i < templates.Length; i++) { - dict[template.name] = template; + ViewHolder template = templates[i]; + if (template == null) + { + continue; + } + + templatesByName[template.name] = template; } - UnityMixedComponentFactory factory = new(dict, recyclerView.Content); + UnityMixedComponentFactory factory = new(templatesByName, recyclerView.Content); objectPool = new MixedObjectPool(factory); } @@ -30,7 +34,13 @@ namespace AlicizaX.UI { throw new NullReferenceException("ViewProvider templates can not null or empty."); } - return dict[viewName]; + + if (!templatesByName.TryGetValue(viewName, out ViewHolder template)) + { + throw new KeyNotFoundException($"ViewProvider template '{viewName}' was not found."); + } + + return template; } public override ViewHolder[] GetTemplates() @@ -39,7 +49,10 @@ namespace AlicizaX.UI { throw new NullReferenceException("ViewProvider templates can not null or empty."); } - return dict.Values.ToArray(); + + ViewHolder[] values = new ViewHolder[templatesByName.Count]; + templatesByName.Values.CopyTo(values, 0); + return values; } public override ViewHolder Allocate(string viewName) @@ -60,5 +73,38 @@ namespace AlicizaX.UI (Adapter as IItemRenderCacheOwner)?.ReleaseAllItemRenders(); objectPool.Dispose(); } + + public override void PreparePool() + { + int warmCount = GetRecommendedWarmCount(); + if (warmCount <= 0) + { + return; + } + + int itemCount = GetItemCount(); + int start = Math.Max(0, LayoutManager.GetStartIndex()); + int end = Math.Min(itemCount - 1, start + warmCount - 1); + Dictionary counts = new(StringComparer.Ordinal); + + for (int index = start; index <= end; index++) + { + string viewName = Adapter.GetViewName(index); + if (string.IsNullOrEmpty(viewName)) + { + continue; + } + + counts.TryGetValue(viewName, out int count); + counts[viewName] = count + 1; + } + + foreach (var pair in counts) + { + int targetCount = pair.Value + Math.Max(1, LayoutManager.Unit); + objectPool.EnsureCapacity(pair.Key, targetCount); + objectPool.Warm(pair.Key, targetCount); + } + } } } diff --git a/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs b/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs index 62505ec..46989ee 100644 --- a/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs @@ -4,12 +4,15 @@ namespace AlicizaX.UI { public sealed class SimpleViewProvider : ViewProvider { - private readonly IObjectPool objectPool; + private readonly ObjectPool objectPool; + + public override string PoolStats => + $"hits={objectPool.HitCount}, misses={objectPool.MissCount}, destroys={objectPool.DestroyCount}, active={objectPool.ActiveCount}, inactive={objectPool.InactiveCount}, peakActive={objectPool.PeakActive}, capacity={objectPool.MaxSize}"; public SimpleViewProvider(RecyclerView recyclerView, ViewHolder[] templates) : base(recyclerView, templates) { UnityComponentFactory factory = new(GetTemplate(), recyclerView.Content); - objectPool = new ObjectPool(factory, 100); + objectPool = new ObjectPool(factory, 0, 1); } public override ViewHolder GetTemplate(string viewName = "") @@ -48,5 +51,17 @@ namespace AlicizaX.UI (Adapter as IItemRenderCacheOwner)?.ReleaseAllItemRenders(); objectPool.Dispose(); } + + public override void PreparePool() + { + int warmCount = GetRecommendedWarmCount(); + if (warmCount <= 0) + { + return; + } + + objectPool.EnsureCapacity(warmCount); + objectPool.Warm(warmCount); + } } } diff --git a/Runtime/RecyclerView/ViewProvider/ViewProvider.cs b/Runtime/RecyclerView/ViewProvider/ViewProvider.cs index 934b425..20eb0a4 100644 --- a/Runtime/RecyclerView/ViewProvider/ViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/ViewProvider.cs @@ -1,82 +1,45 @@ using System.Collections.Generic; using UnityEngine; +using UnityEngine.EventSystems; namespace AlicizaX.UI { - /// - /// 提供和管理 ViewHolder - /// 负责 ViewHolder 的创建、回收和复用 - /// public abstract class ViewProvider { private readonly List viewHolders = new(); + private readonly Dictionary viewHoldersByIndex = new(); + private readonly Dictionary> viewHoldersByDataIndex = new(); + private readonly Dictionary viewHolderPositions = new(); - /// - /// 获取或设置数据适配器 - /// public IAdapter Adapter { get; set; } - /// - /// 获取或设置布局管理器 - /// public LayoutManager LayoutManager { get; set; } - /// - /// 获取当前所有活动的 ViewHolder 列表 - /// - public List ViewHolders => viewHolders; + public IReadOnlyList ViewHolders => viewHolders; + + public abstract string PoolStats { get; } protected RecyclerView recyclerView; protected ViewHolder[] templates; - /// - /// 构造函数 - /// - /// 关联的 RecyclerView 实例 - /// ViewHolder 模板数组 public ViewProvider(RecyclerView recyclerView, ViewHolder[] templates) { this.recyclerView = recyclerView; this.templates = templates; } - /// - /// 根据视图名称获取对应的模板(抽象方法,由子类实现) - /// - /// 视图名称 - /// 对应的 ViewHolder 模板 public abstract ViewHolder GetTemplate(string viewName); - /// - /// 获取所有模板(抽象方法,由子类实现) - /// - /// 所有 ViewHolder 模板数组 public abstract ViewHolder[] GetTemplates(); - /// - /// 从对象池中分配一个 ViewHolder(抽象方法,由子类实现) - /// - /// 视图名称 - /// 分配的 ViewHolder 实例 public abstract ViewHolder Allocate(string viewName); - /// - /// 将 ViewHolder 回收到对象池(抽象方法,由子类实现) - /// - /// 视图名称 - /// 要回收的 ViewHolder public abstract void Free(string viewName, ViewHolder viewHolder); - /// - /// 重置 ViewProvider 状态(抽象方法,由子类实现) - /// public abstract void Reset(); - /// - /// 创建指定索引的 ViewHolder - /// 从对象池中获取或创建新的 ViewHolder,并进行布局和数据绑定 - /// - /// 数据索引 + public abstract void PreparePool(); + public void CreateViewHolder(int index) { for (int i = index; i < index + LayoutManager.Unit; i++) @@ -87,18 +50,16 @@ namespace AlicizaX.UI var viewHolder = Allocate(viewName); viewHolder.Name = viewName; viewHolder.Index = i; + viewHolder.DataIndex = i; + viewHolder.RecyclerView = recyclerView; viewHolders.Add(viewHolder); + RegisterViewHolder(viewHolder); LayoutManager.Layout(viewHolder, i); Adapter.OnBindViewHolder(viewHolder, i); } } - /// - /// 移除指定索引的 ViewHolder - /// 将 ViewHolder 从活动列表中移除并回收到对象池 - /// - /// 数据索引 public void RemoveViewHolder(int index) { for (int i = index; i < index + LayoutManager.Unit; i++) @@ -110,83 +71,171 @@ namespace AlicizaX.UI if (viewHolderIndex < 0 || viewHolderIndex >= viewHolders.Count) return; var viewHolder = viewHolders[viewHolderIndex]; + string viewName = viewHolder.Name; viewHolders.RemoveAt(viewHolderIndex); + UnregisterViewHolder(viewHolder); + RebuildViewHolderPositions(viewHolderIndex); Adapter?.OnRecycleViewHolder(viewHolder); viewHolder.OnRecycled(); - Free(viewHolder.Name, viewHolder); + ClearSelectedState(viewHolder); + Free(viewName, viewHolder); } } - /// - /// 根据数据的下标获取对应的 ViewHolder - /// - /// 数据的下标 - /// public ViewHolder GetViewHolder(int index) { - foreach (var viewHolder in viewHolders) - { - if (viewHolder.Index == index) - { - return viewHolder; - } - } - - return null; + return viewHoldersByIndex.TryGetValue(index, out ViewHolder viewHolder) + ? viewHolder + : null; + } + + public ViewHolder GetViewHolderByDataIndex(int dataIndex) + { + return viewHoldersByDataIndex.TryGetValue(dataIndex, out List holders) && + holders is { Count: > 0 } + ? holders[0] + : null; + } + + public bool TryGetViewHoldersByDataIndex(int dataIndex, out IReadOnlyList holders) + { + if (viewHoldersByDataIndex.TryGetValue(dataIndex, out List list) && list.Count > 0) + { + holders = list; + return true; + } + + holders = null; + return false; } - /// - /// 根据数据的下标获取 ViewHolder 的下标 - /// - /// 数据的下标 - /// public int GetViewHolderIndex(int index) { - for (int i = 0; i < viewHolders.Count; i++) - { - if (viewHolders[i].Index == index) - { - return i; - } - } - - return -1; + return viewHolderPositions.TryGetValue(index, out int viewHolderIndex) + ? viewHolderIndex + : -1; } - /// - /// 清空所有 ViewHolder - /// 将所有活动的 ViewHolder 回收到对象池并清空列表 - /// public void Clear() { foreach (var viewHolder in viewHolders) { + string viewName = viewHolder.Name; Adapter?.OnRecycleViewHolder(viewHolder); + UnregisterViewHolder(viewHolder); viewHolder.OnRecycled(); - Free(viewHolder.Name, viewHolder); + ClearSelectedState(viewHolder); + Free(viewName, viewHolder); } viewHolders.Clear(); + viewHoldersByIndex.Clear(); + viewHoldersByDataIndex.Clear(); + viewHolderPositions.Clear(); } - /// - /// 计算 ViewHolder 的尺寸 - /// - /// - /// public Vector2 CalculateViewSize(int index) { Vector2 size = GetTemplate(Adapter.GetViewName(index)).SizeDelta; return size; } - /// - /// 获取数据项总数 - /// - /// 数据项总数,如果没有适配器则返回 0 public int GetItemCount() { return Adapter == null ? 0 : Adapter.GetItemCount(); } + + protected int GetRecommendedWarmCount() + { + if (Adapter == null || LayoutManager == null) + { + return 0; + } + + int itemCount = Adapter.GetItemCount(); + if (itemCount <= 0) + { + return 0; + } + + int start = Mathf.Max(0, LayoutManager.GetStartIndex()); + int end = Mathf.Max(start, LayoutManager.GetEndIndex()); + int visibleCount = end - start + 1; + int bufferCount = Mathf.Max(1, LayoutManager.Unit); + return Mathf.Min(itemCount, visibleCount + bufferCount); + } + + private void RegisterViewHolder(ViewHolder viewHolder) + { + if (viewHolder == null) + { + return; + } + + viewHoldersByIndex[viewHolder.Index] = viewHolder; + viewHolderPositions[viewHolder.Index] = viewHolders.Count - 1; + + if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out List holders)) + { + holders = new List(); + viewHoldersByDataIndex[viewHolder.DataIndex] = holders; + } + + holders.Add(viewHolder); + } + + private void UnregisterViewHolder(ViewHolder viewHolder) + { + if (viewHolder == null) + { + return; + } + + viewHoldersByIndex.Remove(viewHolder.Index); + viewHolderPositions.Remove(viewHolder.Index); + + if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out List holders)) + { + return; + } + + holders.Remove(viewHolder); + if (holders.Count == 0) + { + viewHoldersByDataIndex.Remove(viewHolder.DataIndex); + } + } + + private void RebuildViewHolderPositions(int startIndex) + { + for (int i = startIndex; i < viewHolders.Count; i++) + { + ViewHolder holder = viewHolders[i]; + if (holder != null) + { + viewHolderPositions[holder.Index] = i; + } + } + } + + private static void ClearSelectedState(ViewHolder viewHolder) + { + if (viewHolder == null) + { + return; + } + + EventSystem eventSystem = EventSystem.current; + if (eventSystem == null) + { + return; + } + + GameObject selected = eventSystem.currentSelectedGameObject; + if (selected != null && selected.transform.IsChildOf(viewHolder.transform)) + { + eventSystem.SetSelectedGameObject(null); + } + } } } diff --git a/Runtime/UXComponent/Navigation/UXNavigationScope.cs b/Runtime/UXComponent/Navigation/UXNavigationScope.cs index 993102a..bba65f8 100644 --- a/Runtime/UXComponent/Navigation/UXNavigationScope.cs +++ b/Runtime/UXComponent/Navigation/UXNavigationScope.cs @@ -291,7 +291,7 @@ namespace UnityEngine.UI } } - internal void InvalidateSelectableCache() + public void InvalidateSelectableCache() { _cacheDirty = true; }