diff --git a/Editor/RecyclerView/RecyclerViewEditor.cs b/Editor/RecyclerView/RecyclerViewEditor.cs index 69d2dfb..eee8bdd 100644 --- a/Editor/RecyclerView/RecyclerViewEditor.cs +++ b/Editor/RecyclerView/RecyclerViewEditor.cs @@ -66,22 +66,12 @@ namespace AlicizaX.UI.Editor private void OnEnable() { - // 先绑定所有 SerializedProperty InitializeLayoutManagerProperties(); InitializeScrollerProperties(); InitializeBaseProperties(); InitializeTemplateProperties(); - // 确保序列化对象是最新的 serializedObject.Update(); - - // 如果 layoutManager 的 managedReferenceValue 丢失但有记录的 typeName,则尝试恢复实例 - RestoreLayoutManagerFromTypeNameIfMissing(); - - // 如果 scroller 组件丢失但有记录的 typeName,则尝试恢复组件到目标 GameObject 上 - RestoreScrollerFromTypeNameIfMissing(); - - // 应用修改(若有) serializedObject.ApplyModifiedProperties(); } @@ -134,6 +124,7 @@ namespace AlicizaX.UI.Editor serializedObject.Update(); bool isPlaying = Application.isPlaying; + DrawMissingReferenceRepairSection(isPlaying); DrawLayoutManagerSection(isPlaying); DrawBaseSettingsSection(isPlaying); DrawScrollerSection(isPlaying); @@ -144,6 +135,71 @@ namespace AlicizaX.UI.Editor #endregion + + private void DrawMissingReferenceRepairSection(bool isPlaying) + { + if (isPlaying) + { + return; + } + + bool missingLayout = _layoutManager != null && + _layoutManager.managedReferenceValue == null && + _layoutManagerTypeName != null && + !string.IsNullOrEmpty(_layoutManagerTypeName.stringValue); + bool missingScroller = _scroller != null && + _scroller.objectReferenceValue == null && + _scrollerTypeName != null && + !string.IsNullOrEmpty(_scrollerTypeName.stringValue); + RecyclerView recyclerView = target as RecyclerView; + bool missingScrollbarEx = recyclerView != null && + recyclerView.Scrollbar != null && + recyclerView.Scrollbar.GetComponent() == null; +#if UX_NAVIGATION + bool missingNavigationBridge = recyclerView != null && + recyclerView.GetComponent() == null; +#else + bool missingNavigationBridge = false; +#endif + bool templatesNeedRecyclerSelectable = TemplatesNeedRecyclerItemSelectable(); + if (!missingLayout && !missingScroller && !missingScrollbarEx && !missingNavigationBridge && !templatesNeedRecyclerSelectable) + { + return; + } + + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField("Missing References", EditorStyles.boldLabel); + if (missingLayout && GUILayout.Button("Restore Layout Manager")) + { + RestoreLayoutManagerFromTypeNameIfMissing(); + serializedObject.ApplyModifiedProperties(); + } + + if (missingScroller && GUILayout.Button("Restore Scroller Component")) + { + RestoreScrollerFromTypeNameIfMissing(); + serializedObject.ApplyModifiedProperties(); + } + + if (missingScrollbarEx && GUILayout.Button("Add ScrollbarEx")) + { + Undo.AddComponent(recyclerView.Scrollbar.gameObject); + EditorUtility.SetDirty(recyclerView.Scrollbar); + } + + if (missingNavigationBridge && GUILayout.Button("Add RecyclerNavigationBridge")) + { + Undo.AddComponent(recyclerView.gameObject); + EditorUtility.SetDirty(recyclerView); + } + + if (templatesNeedRecyclerSelectable && GUILayout.Button("Add RecyclerItemSelectable To Templates")) + { + AddComponentToTemplates(); + } + EditorGUILayout.EndVertical(); + } + #region Layout Manager Section private void DrawLayoutManagerSection(bool isPlaying) @@ -697,6 +753,106 @@ namespace AlicizaX.UI.Editor return false; } + private bool TemplatesNeedComponent() where TComponent : Component + { + if (_templates == null || !_templates.isArray) + { + return false; + } + + for (int i = 0; i < _templates.arraySize; i++) + { + SerializedProperty item = _templates.GetArrayElementAtIndex(i); + GameObject template = GetTemplateGameObject(item.objectReferenceValue); + if (template != null && template.GetComponent() == null) + { + return true; + } + } + + return false; + } + + private bool TemplatesNeedRecyclerItemSelectable() + { +#if UX_NAVIGATION + if (_templates == null || !_templates.isArray) + { + return false; + } + + for (int i = 0; i < _templates.arraySize; i++) + { + SerializedProperty item = _templates.GetArrayElementAtIndex(i); + GameObject template = GetTemplateGameObject(item.objectReferenceValue); + if (template == null) + { + continue; + } + + ViewHolder holder = template.GetComponent(); + if (holder != null && RequiresSelection(holder.ItemInteractionFlags) && template.GetComponent() == null) + { + return true; + } + } + + return false; +#else + return false; +#endif + } + + private static bool RequiresSelection(ItemInteractionFlags interactionFlags) + { + const ItemInteractionFlags selectionFlags = + ItemInteractionFlags.Select | + ItemInteractionFlags.Deselect | + ItemInteractionFlags.Move | + ItemInteractionFlags.Submit | + ItemInteractionFlags.Cancel; + + return (interactionFlags & selectionFlags) != 0; + } + + private void AddComponentToTemplates() where TComponent : Component + { + if (_templates == null || !_templates.isArray) + { + return; + } + + for (int i = 0; i < _templates.arraySize; i++) + { + SerializedProperty item = _templates.GetArrayElementAtIndex(i); + GameObject template = GetTemplateGameObject(item.objectReferenceValue); + if (template == null || template.GetComponent() != null) + { + continue; + } + + Undo.AddComponent(template); + EditorUtility.SetDirty(template); + } + + serializedObject.ApplyModifiedProperties(); + } + + private static GameObject GetTemplateGameObject(Object templateReference) + { + if (templateReference is GameObject gameObject) + { + return gameObject; + } + + if (templateReference is Component component) + { + return component.gameObject; + } + + return null; + } + #endregion #region Helper Methods diff --git a/Editor/UX/Navigation/UXNavigationScopeEditor.cs b/Editor/UX/Navigation/UXNavigationScopeEditor.cs index 6df99a5..3b9b766 100644 --- a/Editor/UX/Navigation/UXNavigationScopeEditor.cs +++ b/Editor/UX/Navigation/UXNavigationScopeEditor.cs @@ -1,4 +1,4 @@ -#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION +#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION using System.Collections.Generic; using UnityEditor; using UnityEngine; @@ -9,6 +9,7 @@ namespace AlicizaX.UI.Extension.Editor [CustomEditor(typeof(UXNavigationScope))] public sealed class UXNavigationScopeEditor : UnityEditor.Editor { + private readonly List _selectableBuffer = new List(64); private SerializedProperty _defaultSelectable; private SerializedProperty _bakedSelectables; private SerializedProperty _runtimeSelectableCapacity; @@ -55,7 +56,7 @@ namespace AlicizaX.UI.Extension.Editor { using (new EditorGUILayout.HorizontalScope()) { - if (GUILayout.Button("收集子 Selectable")) + if (GUILayout.Button("收集本 Scope Selectable")) { BakeSelectables(); } @@ -70,6 +71,19 @@ namespace AlicizaX.UI.Extension.Editor SortBakedSelectables(); } } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("烘焙当前 Prefab 所有 Scope")) + { + BakeAllScopesInRoot(); + } + + if (GUILayout.Button("校验当前 Prefab 所有 Scope")) + { + ValidateAllScopesInRoot(); + } + } } private void DrawDiagnostics() @@ -105,32 +119,22 @@ namespace AlicizaX.UI.Extension.Editor EditorGUILayout.HelpBox("烘焙列表存在跨 Scope 引用。", MessageType.Error); return; } + + for (int j = i + 1; j < _bakedSelectables.arraySize; j++) + { + if (_bakedSelectables.GetArrayElementAtIndex(j).objectReferenceValue == selectable) + { + EditorGUILayout.HelpBox("烘焙列表存在重复引用。", MessageType.Error); + return; + } + } } } private void BakeSelectables() { UXNavigationScope scope = (UXNavigationScope)target; - Selectable[] allSelectables = scope.GetComponentsInChildren(true); - List ownedSelectables = new List(allSelectables.Length); - for (int i = 0; i < allSelectables.Length; i++) - { - Selectable selectable = allSelectables[i]; - if (selectable != null && selectable.GetComponentInParent(true) == scope) - { - ownedSelectables.Add(selectable); - } - } - - Undo.RecordObject(scope, "Bake UX Navigation Selectables"); - _bakedSelectables.arraySize = ownedSelectables.Count; - for (int i = 0; i < ownedSelectables.Count; i++) - { - _bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue = ownedSelectables[i]; - } - - serializedObject.ApplyModifiedProperties(); - EditorUtility.SetDirty(scope); + BakeScope(scope, serializedObject, _bakedSelectables, _selectableBuffer); } private void RemoveNullEntries() @@ -152,10 +156,85 @@ namespace AlicizaX.UI.Extension.Editor private void SortBakedSelectables() { UXNavigationScope scope = (UXNavigationScope)target; - List selectables = new List(_bakedSelectables.arraySize); - for (int i = 0; i < _bakedSelectables.arraySize; i++) + SortScope(scope, serializedObject, _bakedSelectables); + } + + private void BakeAllScopesInRoot() + { + GameObject root = GetRootGameObject(((UXNavigationScope)target).gameObject); + UXNavigationScope[] scopes = root.GetComponentsInChildren(true); + for (int i = 0; i < scopes.Length; i++) { - Selectable selectable = _bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue as Selectable; + UXNavigationScope scope = scopes[i]; + SerializedObject scopeObject = new SerializedObject(scope); + SerializedProperty bakedSelectables = scopeObject.FindProperty("_bakedSelectables"); + BakeScope(scope, scopeObject, bakedSelectables); + } + } + + private void ValidateAllScopesInRoot() + { + GameObject root = GetRootGameObject(((UXNavigationScope)target).gameObject); + UXNavigationScope[] scopes = root.GetComponentsInChildren(true); + int errorCount = 0; + for (int i = 0; i < scopes.Length; i++) + { + if (!ValidateScope(scopes[i])) + { + errorCount++; + } + } + + if (errorCount == 0) + { + Debug.Log("UXNavigationScope validation passed.", root); + } + else + { + Debug.LogErrorFormat(root, "UXNavigationScope validation failed. Error count: {0}", errorCount); + } + } + + private static readonly List StaticSelectableBuffer = new List(64); + + private static void BakeScope(UXNavigationScope scope, SerializedObject scopeObject, SerializedProperty bakedSelectables) + { + BakeScope(scope, scopeObject, bakedSelectables, StaticSelectableBuffer); + } + + private static void BakeScope(UXNavigationScope scope, SerializedObject scopeObject, SerializedProperty bakedSelectables, List scopeEditorBuffer) + { + Selectable[] allSelectables = scope.GetComponentsInChildren(true); + List ownedSelectables = scopeEditorBuffer; + ownedSelectables.Clear(); + for (int i = 0; i < allSelectables.Length; i++) + { + Selectable selectable = allSelectables[i]; + if (selectable != null && selectable.GetComponentInParent(true) == scope) + { + ownedSelectables.Add(selectable); + } + } + + ownedSelectables.Sort(CompareSiblingPath); + Undo.RecordObject(scope, "Bake UX Navigation Selectables"); + bakedSelectables.arraySize = ownedSelectables.Count; + for (int i = 0; i < ownedSelectables.Count; i++) + { + bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue = ownedSelectables[i]; + } + + scopeObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(scope); + } + + private static void SortScope(UXNavigationScope scope, SerializedObject scopeObject, SerializedProperty bakedSelectables) + { + List selectables = StaticSelectableBuffer; + selectables.Clear(); + for (int i = 0; i < bakedSelectables.arraySize; i++) + { + Selectable selectable = bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue as Selectable; if (selectable != null) { selectables.Add(selectable); @@ -164,16 +243,70 @@ namespace AlicizaX.UI.Extension.Editor selectables.Sort(CompareSiblingPath); Undo.RecordObject(scope, "Sort UX Navigation Selectables"); - _bakedSelectables.arraySize = selectables.Count; + bakedSelectables.arraySize = selectables.Count; for (int i = 0; i < selectables.Count; i++) { - _bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue = selectables[i]; + bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue = selectables[i]; } - serializedObject.ApplyModifiedProperties(); + scopeObject.ApplyModifiedProperties(); EditorUtility.SetDirty(scope); } + private static bool ValidateScope(UXNavigationScope scope) + { + SerializedObject scopeObject = new SerializedObject(scope); + SerializedProperty defaultSelectableProperty = scopeObject.FindProperty("_defaultSelectable"); + SerializedProperty bakedSelectables = scopeObject.FindProperty("_bakedSelectables"); + Selectable defaultSelectable = defaultSelectableProperty.objectReferenceValue as Selectable; + bool valid = true; + + if (defaultSelectable != null && defaultSelectable.GetComponentInParent(true) != scope) + { + Debug.LogError("UXNavigationScope default selectable crosses scope.", scope); + valid = false; + } + + for (int i = 0; i < bakedSelectables.arraySize; i++) + { + Selectable selectable = bakedSelectables.GetArrayElementAtIndex(i).objectReferenceValue as Selectable; + if (selectable == null) + { + Debug.LogWarning("UXNavigationScope baked selectables contain null entry.", scope); + valid = false; + continue; + } + + if (selectable.GetComponentInParent(true) != scope) + { + Debug.LogError("UXNavigationScope baked selectable crosses scope.", selectable); + valid = false; + } + + for (int j = i + 1; j < bakedSelectables.arraySize; j++) + { + if (bakedSelectables.GetArrayElementAtIndex(j).objectReferenceValue == selectable) + { + Debug.LogError("UXNavigationScope baked selectables contain duplicate entry.", selectable); + valid = false; + } + } + } + + return valid; + } + + private static GameObject GetRootGameObject(GameObject gameObject) + { + Transform current = gameObject.transform; + while (current.parent != null) + { + current = current.parent; + } + + return current.gameObject; + } + private static int CompareSiblingPath(Selectable left, Selectable right) { if (left == right) @@ -181,22 +314,67 @@ namespace AlicizaX.UI.Extension.Editor return 0; } - string leftPath = GetSiblingPath(left.transform); - string rightPath = GetSiblingPath(right.transform); - return string.CompareOrdinal(leftPath, rightPath); + Transform leftTransform = left != null ? left.transform : null; + Transform rightTransform = right != null ? right.transform : null; + return CompareSiblingPath(leftTransform, rightTransform); } - private static string GetSiblingPath(Transform transform) + private static int CompareSiblingPath(Transform left, Transform right) { - if (transform == null) + if (left == right) { - return string.Empty; + return 0; } - return transform.parent == null - ? transform.GetSiblingIndex().ToString("D4") - : GetSiblingPath(transform.parent) + "/" + transform.GetSiblingIndex().ToString("D4"); + if (left == null) + { + return -1; + } + + if (right == null) + { + return 1; + } + + int leftDepth = GetDepth(left); + int rightDepth = GetDepth(right); + Transform leftCursor = left; + Transform rightCursor = right; + + while (leftDepth > rightDepth) + { + leftCursor = leftCursor.parent; + leftDepth--; + } + + while (rightDepth > leftDepth) + { + rightCursor = rightCursor.parent; + rightDepth--; + } + + while (leftCursor.parent != rightCursor.parent) + { + leftCursor = leftCursor.parent; + rightCursor = rightCursor.parent; + } + + return leftCursor.GetSiblingIndex().CompareTo(rightCursor.GetSiblingIndex()); + } + + private static int GetDepth(Transform transform) + { + int depth = 0; + Transform current = transform; + while (current != null) + { + depth++; + current = current.parent; + } + + return depth; } } } #endif + diff --git a/Runtime/RecyclerView/Adapter/Adapter.cs b/Runtime/RecyclerView/Adapter/Adapter.cs index 5bb9027..ab47e54 100644 --- a/Runtime/RecyclerView/Adapter/Adapter.cs +++ b/Runtime/RecyclerView/Adapter/Adapter.cs @@ -8,27 +8,18 @@ namespace AlicizaX.UI void ReleaseAllItemRenders(); } - public class Adapter : IAdapter, IItemRenderCacheOwner where T : ISimpleViewData + internal interface IItemRenderPrewarmer { - private sealed class ItemRenderEntry - { - public ItemRenderEntry(string viewName, IItemRender itemRender) - { - ViewName = viewName; - ItemRender = itemRender; - } - - public string ViewName { get; } - - public IItemRender ItemRender { get; } - } + void PrewarmItemRender(ViewHolder viewHolder, string viewName); + } + public class Adapter : IAdapter, IItemRenderCacheOwner, IItemRenderPrewarmer where T : ISimpleViewData + { protected RecyclerView recyclerView; protected List list; protected int choiceIndex = -1; private readonly Dictionary itemRenderDefinitions = new(StringComparer.Ordinal); - private readonly Dictionary itemRenders = new(); private ItemRenderResolver.ItemRenderDefinition defaultItemRenderDefinition; public int ChoiceIndex @@ -84,9 +75,9 @@ namespace AlicizaX.UI return; } - string resolvedViewName = string.IsNullOrEmpty(viewName) ? "" : viewName; - throw new InvalidOperationException( - $"RecyclerView item render is missing for view '{resolvedViewName}'. Holder='{viewHolder.GetType().Name}', Adapter='{GetType().Name}'."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("RecyclerView item render is missing."); +#endif } public virtual void OnRecycleViewHolder(ViewHolder viewHolder) @@ -230,6 +221,41 @@ namespace AlicizaX.UI return removed; } + public bool UnregisterItemRender(Type itemRenderType) + { + if (itemRenderType == null) + { + return false; + } + + bool removed = false; + if (defaultItemRenderDefinition != null && defaultItemRenderDefinition.ItemRenderType == itemRenderType) + { + defaultItemRenderDefinition = null; + ReleaseCachedItemRenders(string.Empty); + removed = true; + } + + string removedViewName = null; + foreach (var pair in itemRenderDefinitions) + { + if (pair.Value != null && pair.Value.ItemRenderType == itemRenderType) + { + removedViewName = pair.Key; + break; + } + } + + if (removedViewName != null) + { + itemRenderDefinitions.Remove(removedViewName); + ReleaseCachedItemRenders(removedViewName); + removed = true; + } + + return removed; + } + public void ClearItemRenderRegistrations() { ReleaseAllItemRenders(); @@ -403,61 +429,47 @@ namespace AlicizaX.UI return viewHolder != null; } - private bool TryGetItemRenderDefinition(string viewName, out ItemRenderResolver.ItemRenderDefinition definition) + private static bool TryGetItemRender(ViewHolder viewHolder, out IItemRender itemRender) { - if (!string.IsNullOrEmpty(viewName) && itemRenderDefinitions.TryGetValue(viewName, out definition)) + itemRender = viewHolder != null ? viewHolder.CachedItemRender : null; + return itemRender != null; + } + + private static void ReleaseItemRender(ViewHolder viewHolder) + { + IItemRender itemRender = viewHolder != null ? viewHolder.CachedItemRender : null; + if (itemRender == null) { - return true; + return; } - definition = defaultItemRenderDefinition; - return definition != null; + itemRender.Unbind(); + if (itemRender is ItemRenderBase itemRenderBase) + { + itemRenderBase.Detach(); + } + + viewHolder.CachedItemRender = null; + viewHolder.CachedItemRenderViewName = null; } private void UpdateSelectionState(ViewHolder viewHolder, bool selected) { - if (viewHolder == null) - { - return; - } - - if (TryGetItemRender(viewHolder, out var itemRender)) + if (TryGetItemRender(viewHolder, out IItemRender itemRender)) { itemRender.UpdateSelection(selected); - return; } - - string viewName = string.IsNullOrEmpty(viewHolder.Name) ? "" : viewHolder.Name; - throw new InvalidOperationException( - $"RecyclerView item render is missing for selection update. View='{viewName}', Holder='{viewHolder.GetType().Name}', Adapter='{GetType().Name}'."); } - private bool TryGetItemRender(ViewHolder viewHolder, out IItemRender itemRender) + private bool TryGetItemRenderDefinition(string viewName, out ItemRenderResolver.ItemRenderDefinition definition) { - if (viewHolder != null && - itemRenders.TryGetValue(viewHolder, out var entry) && - entry.ItemRender != null) + if (!string.IsNullOrEmpty(viewName) && itemRenderDefinitions.TryGetValue(viewName, out definition)) { - itemRender = entry.ItemRender; - return true; + return definition != null; } - itemRender = null; - return false; - } - - private static void ReleaseItemRender(ItemRenderEntry entry) - { - if (entry?.ItemRender == null) - { - return; - } - - entry.ItemRender.Unbind(); - if (entry.ItemRender is ItemRenderBase itemRender) - { - itemRender.Detach(); - } + definition = defaultItemRenderDefinition; + return definition != null; } private bool TryGetOrCreateItemRender(ViewHolder viewHolder, string viewName, out IItemRender itemRender) @@ -468,17 +480,15 @@ namespace AlicizaX.UI return false; } - if (itemRenders.TryGetValue(viewHolder, out var entry)) + if (viewHolder.CachedItemRender != null) { - if (entry.ItemRender != null && string.Equals(entry.ViewName, viewName, StringComparison.Ordinal)) + if (string.Equals(viewHolder.CachedItemRenderViewName, viewName, StringComparison.Ordinal)) { - itemRender = entry.ItemRender; + itemRender = viewHolder.CachedItemRender; return true; } - ReleaseItemRender(entry); - viewHolder.Destroyed -= OnViewHolderDestroyed; - itemRenders.Remove(viewHolder); + ReleaseItemRender(viewHolder); } if (!TryGetItemRenderDefinition(viewName, out var definition)) @@ -488,8 +498,13 @@ namespace AlicizaX.UI } itemRender = definition.Create(viewHolder, recyclerView, this, SetChoiceIndex); - itemRenders[viewHolder] = new ItemRenderEntry(viewName, itemRender); - viewHolder.Destroyed += OnViewHolderDestroyed; + if (itemRender == null) + { + return false; + } + + viewHolder.CachedItemRender = itemRender; + viewHolder.CachedItemRenderViewName = viewName; return true; } @@ -498,64 +513,40 @@ namespace AlicizaX.UI ReleaseAllItemRenders(); } + void IItemRenderPrewarmer.PrewarmItemRender(ViewHolder viewHolder, string viewName) + { + TryGetOrCreateItemRender(viewHolder, viewName, out _); + } + private void ReleaseAllItemRenders() { - foreach (var pair in itemRenders) + if (recyclerView?.ViewProvider == null) { - ReleaseItemRender(pair.Value); - if (pair.Key != null) - { - pair.Key.Destroyed -= OnViewHolderDestroyed; - } + return; } - itemRenders.Clear(); + for (int i = 0; i < recyclerView.ViewProvider.VisibleCount; i++) + { + ReleaseItemRender(recyclerView.ViewProvider.GetVisibleViewHolder(i)); + } } private void ReleaseCachedItemRenders(string viewName) { - if (itemRenders.Count == 0) + if (recyclerView?.ViewProvider == null) { return; } - List viewHoldersToRemove = null; - foreach (var pair in itemRenders) + for (int i = 0; i < recyclerView.ViewProvider.VisibleCount; i++) { - if (!string.Equals(pair.Value.ViewName, viewName, StringComparison.Ordinal)) + ViewHolder viewHolder = recyclerView.ViewProvider.GetVisibleViewHolder(i); + if (viewHolder == null || !string.Equals(viewHolder.CachedItemRenderViewName, viewName, StringComparison.Ordinal)) { continue; } - ReleaseItemRender(pair.Value); - pair.Key.Destroyed -= OnViewHolderDestroyed; - viewHoldersToRemove ??= new List(); - viewHoldersToRemove.Add(pair.Key); - } - - if (viewHoldersToRemove == null) - { - return; - } - - for (int i = 0; i < viewHoldersToRemove.Count; i++) - { - itemRenders.Remove(viewHoldersToRemove[i]); - } - } - - private void OnViewHolderDestroyed(ViewHolder viewHolder) - { - if (viewHolder == null) - { - return; - } - - viewHolder.Destroyed -= OnViewHolderDestroyed; - if (itemRenders.TryGetValue(viewHolder, out var entry)) - { - ReleaseItemRender(entry); - itemRenders.Remove(viewHolder); + ReleaseItemRender(viewHolder); } } diff --git a/Runtime/RecyclerView/Adapter/GroupAdapter.cs b/Runtime/RecyclerView/Adapter/GroupAdapter.cs index f8140e8..fe8f61c 100644 --- a/Runtime/RecyclerView/Adapter/GroupAdapter.cs +++ b/Runtime/RecyclerView/Adapter/GroupAdapter.cs @@ -7,6 +7,7 @@ namespace AlicizaX.UI { private readonly List showList = new(); private readonly string groupViewName; + private readonly Dictionary groupsByType = new(); public GroupAdapter(RecyclerView recyclerView, string groupViewName) : base(recyclerView) { @@ -42,12 +43,16 @@ namespace AlicizaX.UI { if (string.IsNullOrEmpty(groupViewName)) { - throw new InvalidOperationException("GroupAdapter requires a non-empty groupViewName."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("GroupAdapter requires a non-empty groupViewName."); +#endif + return; } if (list == null) { showList.Clear(); + groupsByType.Clear(); base.NotifyDataChanged(); return; } @@ -80,21 +85,24 @@ namespace AlicizaX.UI public override void SetList(List list) { showList.Clear(); + groupsByType.Clear(); base.SetList(list); } private void CreateGroup(int type) { - var groupData = showList.Find(data => data.Type == type && data.TemplateName == groupViewName); - if (groupData == null) + if (groupsByType.ContainsKey(type)) { - groupData = new TData - { - TemplateName = groupViewName, - Type = type - }; - showList.Add(groupData); + return; } + + TData groupData = new TData + { + TemplateName = groupViewName, + Type = type + }; + groupsByType[type] = groupData; + showList.Add(groupData); } public void Expand(int index) diff --git a/Runtime/RecyclerView/Adapter/ItemRender.cs b/Runtime/RecyclerView/Adapter/ItemRender.cs index 9199e0c..d32cf6c 100644 --- a/Runtime/RecyclerView/Adapter/ItemRender.cs +++ b/Runtime/RecyclerView/Adapter/ItemRender.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; +using Cysharp.Text; using UnityEngine.EventSystems; namespace AlicizaX.UI @@ -105,8 +106,6 @@ namespace AlicizaX.UI /// /// 当前持有者上的交互代理组件。 /// - private ItemInteractionProxy interactionProxy; - /// /// 当前项被选中时的回调委托。 /// @@ -170,7 +169,7 @@ namespace AlicizaX.UI /// /// 获取当前渲染项支持的交互能力。 /// - public virtual ItemInteractionFlags InteractionFlags => ItemInteractionFlags.None; + public virtual ItemInteractionFlags InteractionFlags => Holder != null ? Holder.ItemInteractionFlags : ItemInteractionFlags.None; /// /// 由框架交互代理读取当前渲染项的交互能力。 @@ -216,12 +215,9 @@ namespace AlicizaX.UI { ClearSelectionState(); OnClear(); - if (interactionProxy != null) - { - interactionProxy.Clear(); - interactionBindingActive = false; - cachedInteractionFlags = ItemInteractionFlags.None; - } + Holder.ClearInteractionHost(); + interactionBindingActive = false; + cachedInteractionFlags = ItemInteractionFlags.None; Holder.DataIndex = -1; } @@ -258,7 +254,7 @@ namespace AlicizaX.UI CurrentLayoutIndex = Holder.Index; Holder.DataIndex = index; CurrentBindingVersion = Holder.BindingVersion; - BindInteractionProxyIfNeeded(); + BindInteractionHostIfNeeded(); OnBind(itemData, index); } @@ -363,7 +359,7 @@ namespace AlicizaX.UI if (viewHolder is not THolder holder) { throw new InvalidOperationException( - $"RecyclerView item render '{GetType().FullName}' expects holder '{typeof(THolder).FullName}', but got '{viewHolder.GetType().FullName}'."); + ZString.Format("RecyclerView item render '{0}' expects holder '{1}', but got '{2}'.", GetType().FullName, typeof(THolder).FullName, viewHolder.GetType().FullName)); } Holder = holder; @@ -372,11 +368,6 @@ namespace AlicizaX.UI this.selectionHandler = selectionHandler; interactionBindingActive = false; cachedInteractionFlags = ItemInteractionFlags.None; - interactionProxy = Holder.GetComponent(); - if (interactionProxy == null) - { - interactionProxy = Holder.gameObject.AddComponent(); - } OnHolderAttached(); } @@ -391,8 +382,7 @@ namespace AlicizaX.UI } OnHolderDetached(); - interactionProxy?.Clear(); - interactionProxy = null; + Holder.ClearInteractionHost(); selectionHandler = null; Holder = null; RecyclerView = null; @@ -414,7 +404,7 @@ namespace AlicizaX.UI if (Holder == null) { throw new InvalidOperationException( - $"RecyclerView item render '{GetType().FullName}' has not been initialized with a holder."); + ZString.Format("RecyclerView item render '{0}' has not been initialized with a holder.", GetType().FullName)); } } @@ -435,20 +425,15 @@ namespace AlicizaX.UI /// /// 在需要时将当前渲染实例绑定到交互代理。 /// - private void BindInteractionProxyIfNeeded() + private void BindInteractionHostIfNeeded() { - if (interactionProxy == null) - { - return; - } - ItemInteractionFlags interactionFlags = InteractionFlags; if (interactionBindingActive && cachedInteractionFlags == interactionFlags) { return; } - interactionProxy.Bind(this); + Holder.BindInteractionHost(this); cachedInteractionFlags = interactionFlags; interactionBindingActive = true; } @@ -698,19 +683,23 @@ namespace AlicizaX.UI { if (viewHolder == null) { - throw new ArgumentNullException(nameof(viewHolder)); + return null; } if (!HolderType.IsInstanceOfType(viewHolder)) { - throw new InvalidOperationException( - $"RecyclerView item render '{ItemRenderType.FullName}' expects holder '{HolderType.FullName}', but got '{viewHolder.GetType().FullName}'."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError(ZString.Format("RecyclerView item render '{0}' expects holder '{1}', but got '{2}'.", ItemRenderType.FullName, HolderType.FullName, viewHolder.GetType().FullName)); +#endif + return null; } if (createInstance() is not ItemRenderBase itemRender) { - throw new InvalidOperationException( - $"RecyclerView item render '{ItemRenderType.FullName}' could not be created."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError(ZString.Format("RecyclerView item render '{0}' could not be created.", ItemRenderType.FullName)); +#endif + return null; } itemRender.Attach(viewHolder, recyclerView, adapter, selectionHandler); @@ -758,13 +747,13 @@ namespace AlicizaX.UI !typeof(IItemRender).IsAssignableFrom(itemRenderType)) { throw new InvalidOperationException( - $"RecyclerView item render type '{itemRenderType.FullName}' is invalid."); + ZString.Format("RecyclerView item render type '{0}' is invalid.", itemRenderType.FullName)); } if (!TryGetHolderType(itemRenderType, out Type holderType)) { throw new InvalidOperationException( - $"RecyclerView item render '{itemRenderType.FullName}' must inherit from ItemRender."); + ZString.Format("RecyclerView item render '{0}' must inherit from ItemRender.", itemRenderType.FullName)); } ConstructorInfo constructor = itemRenderType.GetConstructor( @@ -776,7 +765,7 @@ namespace AlicizaX.UI if (constructor == null) { throw new InvalidOperationException( - $"RecyclerView item render '{itemRenderType.FullName}' must have a parameterless constructor."); + ZString.Format("RecyclerView item render '{0}' must have a parameterless constructor.", itemRenderType.FullName)); } return new ItemRenderDefinition(itemRenderType, holderType, CreateFactory(constructor)); diff --git a/Runtime/RecyclerView/Layout/CircleLayoutManager.cs b/Runtime/RecyclerView/Layout/CircleLayoutManager.cs index d8f207a..a57e079 100644 --- a/Runtime/RecyclerView/Layout/CircleLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/CircleLayoutManager.cs @@ -107,9 +107,15 @@ namespace AlicizaX.UI public override void DoItemAnimation() { - var viewHolders = viewProvider.ViewHolders; - for (int i = 0; i < viewHolders.Count; i++) + int visibleCount = viewProvider.VisibleCount; + for (int i = 0; i < visibleCount; i++) { + ViewHolder viewHolder = viewProvider.GetVisibleViewHolder(i); + if (viewHolder == null) + { + continue; + } + float angle = i * intervalAngle + initalAngle; angle = circleDirection == CircleDirection.Positive ? angle + ScrollPosition : angle - ScrollPosition; float delta = (angle - initalAngle) % 360; @@ -118,7 +124,7 @@ namespace AlicizaX.UI float scale = delta < intervalAngle ? (1.4f - delta / intervalAngle) : 1; scale = Mathf.Max(scale, 1); - viewHolders[i].RectTransform.localScale = Vector3.one * scale; + viewHolder.RectTransform.localScale = Vector3.one * scale; } } } diff --git a/Runtime/RecyclerView/Layout/LayoutManager.cs b/Runtime/RecyclerView/Layout/LayoutManager.cs index 4e73d18..8f5d848 100644 --- a/Runtime/RecyclerView/Layout/LayoutManager.cs +++ b/Runtime/RecyclerView/Layout/LayoutManager.cs @@ -104,8 +104,15 @@ namespace AlicizaX.UI public void UpdateLayout() { - foreach (var viewHolder in viewProvider.ViewHolders) + int visibleCount = viewProvider.VisibleCount; + for (int i = 0; i < visibleCount; i++) { + ViewHolder viewHolder = viewProvider.GetVisibleViewHolder(i); + if (viewHolder == null) + { + continue; + } + Layout(viewHolder, viewHolder.Index); } } diff --git a/Runtime/RecyclerView/Layout/PageLayoutManager.cs b/Runtime/RecyclerView/Layout/PageLayoutManager.cs index ef1a62c..e36261b 100644 --- a/Runtime/RecyclerView/Layout/PageLayoutManager.cs +++ b/Runtime/RecyclerView/Layout/PageLayoutManager.cs @@ -94,14 +94,20 @@ namespace AlicizaX.UI public override void DoItemAnimation() { - var viewHolders = viewProvider.ViewHolders; - for (int i = 0; i < viewHolders.Count; i++) + int visibleCount = viewProvider.VisibleCount; + for (int i = 0; i < visibleCount; i++) { - float viewPos = direction == Direction.Vertical ? -viewHolders[i].RectTransform.anchoredPosition.y : viewHolders[i].RectTransform.anchoredPosition.x; + ViewHolder viewHolder = viewProvider.GetVisibleViewHolder(i); + if (viewHolder == null) + { + continue; + } + + float viewPos = direction == Direction.Vertical ? -viewHolder.RectTransform.anchoredPosition.y : viewHolder.RectTransform.anchoredPosition.x; float scale = 1 - Mathf.Min(Mathf.Abs(viewPos) * 0.0006f, 1f); scale = Mathf.Max(scale, minScale); - viewHolders[i].RectTransform.localScale = Vector3.one * scale; + viewHolder.RectTransform.localScale = Vector3.one * scale; } } } diff --git a/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs b/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs index ba10e02..e849050 100644 --- a/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs +++ b/Runtime/RecyclerView/ObjectPool/MixedObjectPool.cs @@ -8,6 +8,7 @@ namespace AlicizaX.UI private const int DEFAULT_MAX_SIZE_PER_TYPE = 10; private readonly Dictionary> entries; + private readonly Dictionary activeEntries; private readonly Dictionary typeSize; private readonly Dictionary activeCountByType; private readonly Dictionary peakActiveByType; @@ -17,6 +18,7 @@ namespace AlicizaX.UI private int hitCount; private int missCount; private int destroyCount; + private bool disposed; public MixedObjectPool(IMixedObjectFactory factory) : this(factory, DEFAULT_MAX_SIZE_PER_TYPE) { @@ -33,6 +35,7 @@ namespace AlicizaX.UI } entries = new Dictionary>(StringComparer.Ordinal); + activeEntries = new Dictionary(); typeSize = new Dictionary(StringComparer.Ordinal); activeCountByType = new Dictionary(StringComparer.Ordinal); peakActiveByType = new Dictionary(StringComparer.Ordinal); @@ -40,18 +43,26 @@ namespace AlicizaX.UI public T Allocate(string typeName) { + if (disposed) + { + return null; + } + Stack stack = GetOrCreateStack(typeName); if (stack.Count > 0) { T obj = stack.Pop(); hitCount++; + activeEntries[obj] = typeName; TrackAllocate(typeName); return obj; } missCount++; + T created = factory.Create(typeName); + activeEntries[created] = typeName; TrackAllocate(typeName); - return factory.Create(typeName); + return created; } public void Free(string typeName, T obj) @@ -60,6 +71,16 @@ namespace AlicizaX.UI if (!factory.Validate(typeName, obj)) { + activeEntries.Remove(obj); + factory.Destroy(typeName, obj); + destroyCount++; + TrackFree(typeName); + return; + } + + if (disposed) + { + activeEntries.Remove(obj); factory.Destroy(typeName, obj); destroyCount++; TrackFree(typeName); @@ -70,6 +91,7 @@ namespace AlicizaX.UI Stack stack = GetOrCreateStack(typeName); factory.Reset(typeName, obj); + activeEntries.Remove(obj); TrackFree(typeName); if (stack.Count >= maxSize) @@ -99,6 +121,11 @@ namespace AlicizaX.UI public void EnsureCapacity(string typeName, int value) { + if (disposed) + { + return; + } + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value)); @@ -113,6 +140,11 @@ namespace AlicizaX.UI public void Warm(string typeName, int count) { + if (disposed) + { + return; + } + if (count <= 0) { return; @@ -169,8 +201,14 @@ namespace AlicizaX.UI peakActiveByType.Clear(); } + public void ClearInactive() + { + Clear(); + } + public void Dispose() { + disposed = true; Clear(); GC.SuppressFinalize(this); } diff --git a/Runtime/RecyclerView/ObjectPool/ObjectPool.cs b/Runtime/RecyclerView/ObjectPool/ObjectPool.cs index 27a373b..1652b81 100644 --- a/Runtime/RecyclerView/ObjectPool/ObjectPool.cs +++ b/Runtime/RecyclerView/ObjectPool/ObjectPool.cs @@ -6,6 +6,7 @@ namespace AlicizaX.UI public class ObjectPool : IObjectPool where T : class { private readonly Stack entries; + private readonly HashSet activeEntries; private readonly int initialSize; private int maxSize; protected readonly IObjectFactory factory; @@ -15,6 +16,7 @@ namespace AlicizaX.UI private int missCount; private int destroyCount; private int peakActive; + private bool disposed; public ObjectPool(IObjectFactory factory) : this(factory, Environment.ProcessorCount * 2) { @@ -36,6 +38,7 @@ namespace AlicizaX.UI } entries = new Stack(maxSize); + activeEntries = new HashSet(); Warm(initialSize); } @@ -59,6 +62,11 @@ namespace AlicizaX.UI public virtual T Allocate() { + if (disposed) + { + return null; + } + T value; if (entries.Count > 0) { @@ -67,6 +75,7 @@ namespace AlicizaX.UI { hitCount++; activeCount++; + activeEntries.Add(value); if (activeCount > peakActive) { peakActive = activeCount; @@ -79,6 +88,7 @@ namespace AlicizaX.UI value = factory.Create(); totalCount++; activeCount++; + activeEntries.Add(value); if (activeCount > peakActive) { peakActive = activeCount; @@ -93,6 +103,7 @@ namespace AlicizaX.UI if (!factory.Validate(obj)) { + activeEntries.Remove(obj); factory.Destroy(obj); destroyCount++; if (totalCount > 0) @@ -108,12 +119,29 @@ namespace AlicizaX.UI return; } + if (disposed) + { + activeEntries.Remove(obj); + factory.Destroy(obj); + destroyCount++; + if (totalCount > 0) + { + totalCount--; + } + if (activeCount > 0) + { + activeCount--; + } + return; + } + factory.Reset(obj); if (activeCount > 0) { activeCount--; } + activeEntries.Remove(obj); if (entries.Count < maxSize) { @@ -151,17 +179,29 @@ namespace AlicizaX.UI } } + activeCount = activeEntries.Count; totalCount = activeCount; } + public void ClearInactive() + { + Clear(); + } + public void Dispose() { + disposed = true; Clear(); GC.SuppressFinalize(this); } public void EnsureCapacity(int value) { + if (disposed) + { + return; + } + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value)); @@ -175,6 +215,11 @@ namespace AlicizaX.UI public void Warm(int count) { + if (disposed) + { + return; + } + if (count <= 0) { return; diff --git a/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs index 9c8ca72..a29da99 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityComponentFactory.cs @@ -16,6 +16,11 @@ namespace AlicizaX.UI public T Create() { T obj = Object.Instantiate(template, parent); + if (obj is ViewHolder viewHolder) + { + viewHolder.RefreshInteractionCache(); + } + return obj; } diff --git a/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs b/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs index 55b41f6..c0ec4cb 100644 --- a/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs +++ b/Runtime/RecyclerView/ObjectPool/UnityMixedComponentFactory.cs @@ -50,6 +50,11 @@ namespace AlicizaX.UI obj.transform.localScale = Vector3.one; } + if (obj is ViewHolder viewHolder) + { + viewHolder.RefreshInteractionCache(); + } + return obj; } diff --git a/Runtime/RecyclerView/RecyclerView.cs b/Runtime/RecyclerView/RecyclerView.cs index 71592a1..3f27d5d 100644 --- a/Runtime/RecyclerView/RecyclerView.cs +++ b/Runtime/RecyclerView/RecyclerView.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections; +using System; using System.Collections.Generic; using System.Threading; using UnityEngine; @@ -9,39 +8,39 @@ using UnityEngine.UI; namespace AlicizaX.UI { /// - /// RecyclerView 的核心组件,负责适配器绑定、布局刷新、滚动控制与焦点导航。 + /// RecyclerView 的核心组件,负责适配器绑定、布局刷新、滚动控制与焦点导航�? /// public class RecyclerView : MonoBehaviour { /// - /// 滚动条拖拽手柄允许的最小像素长度。 + /// 滚动条拖拽手柄允许的最小像素长度�? /// private const float MinScrollbarHandlePixels = 18f; /// - /// 记录 Unity 主线程的托管线程标识。 + /// 记录 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; @@ -50,23 +49,23 @@ namespace AlicizaX.UI #region Serialized Fields - Scroll Settings /// - /// 是否启用滚动能力。 + /// 是否启用滚动能力�? /// [HideInInspector] [SerializeField] private bool scroll; /// - /// 是否在停止滚动后自动吸附到最近项。 + /// 是否在停止滚动后自动吸附到最近项�? /// [HideInInspector] [SerializeField] private bool snap; /// - /// 平滑滚动时的速度系数。 + /// 平滑滚动时的速度系数�? /// [HideInInspector] [SerializeField, Range(0.5f, 50f)] private float scrollSpeed = 7f; /// - /// 鼠标滚轮滚动时的速度系数。 + /// 鼠标滚轮滚动时的速度系数�? /// [HideInInspector] [SerializeField, Range(1f, 50f)] private float wheelSpeed = 30f; @@ -76,27 +75,27 @@ namespace AlicizaX.UI #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; @@ -105,22 +104,22 @@ namespace AlicizaX.UI #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; @@ -129,47 +128,50 @@ namespace AlicizaX.UI #region Private Fields /// - /// 负责创建、回收与查询视图持有者的提供器。 + /// 负责创建、回收与查询视图持有者的提供器�? /// private ViewProvider viewProvider; + private bool isValid = true; + private bool validationErrorLogged; + private bool scrollbarVisibleState; + private bool scrollbarInteractableState; + /// - /// 负责处理列表内导航逻辑的控制器。 + /// 负责处理列表内导航逻辑的控制器�? /// private RecyclerNavigationController navigationController; - /// - /// 下一帧恢复 UI 焦点时使用的协程句柄。 - /// - private Coroutine focusRecoveryCoroutine; + private bool hasPendingFocusRecovery; + private GameObject pendingFocusRecoveryTarget; /// - /// 是否存在等待滚动结束后执行的焦点请求。 + /// 是否存在等待滚动结束后执行的焦点请求�? /// private bool hasPendingFocusRequest; /// - /// 挂起焦点请求期望采用的对齐方式。 + /// 挂起焦点请求期望采用的对齐方式�? /// private ScrollAlignment pendingFocusAlignment; /// - /// 挂起焦点请求对应的数据索引。 + /// 挂起焦点请求对应的数据索引�? /// private int pendingFocusIndex = -1; /// - /// 当前可见区间的起始布局索引。 + /// 当前可见区间的起始布局索引�? /// private int startIndex; /// - /// 当前可见区间的结束布局索引。 + /// 当前可见区间的结束布局索引�? /// private int endIndex; /// - /// 当前记录的逻辑选中索引。 + /// 当前记录的逻辑选中索引�? /// private int currentIndex; @@ -178,7 +180,7 @@ namespace AlicizaX.UI #region Public Properties - Layout Settings /// - /// 获取或设置列表的主滚动方向。 + /// 获取或设置列表的主滚动方向�? /// public Direction Direction { @@ -187,7 +189,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表项在交叉轴上的对齐方式。 + /// 获取或设置列表项在交叉轴上的对齐方式�? /// public Alignment Alignment { @@ -196,7 +198,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表项之间的间距。 + /// 获取或设置列表项之间的间距�? /// public Vector2 Spacing { @@ -205,7 +207,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置列表内容区域的内边距。 + /// 获取或设置列表内容区域的内边距�? /// public Vector2 Padding { @@ -218,7 +220,7 @@ namespace AlicizaX.UI #region Public Properties - Scroll Settings /// - /// 获取或设置是否启用滚动能力。 + /// 获取或设置是否启用滚动能力�? /// public bool Scroll { @@ -230,22 +232,18 @@ namespace AlicizaX.UI if (scroller != null) { - scroller.enabled = scroll; + scroller.InputEnabled = scroll; scroller.WheelSpeed = wheelSpeed; scroller.Snap = snap; } - { - scrollbar.gameObject.SetActive(showScrollBar && scroll); - } - RequestLayout(); } } /// - /// 获取或设置是否在停止滚动后自动吸附到最近项。 + /// 获取或设置是否在停止滚动后自动吸附到最近项�? /// public bool Snap { @@ -261,12 +259,12 @@ namespace AlicizaX.UI scroller.Snap = snap; } - // 如需在启用吸附后立即校正位置,可在此触发最近项吸附。 + // 如需在启用吸附后立即校正位置,可在此触发最近项吸附�? } } /// - /// 获取或设置平滑滚动速度系数。 + /// 获取或设置平滑滚动速度系数�? /// public float ScrollSpeed { @@ -284,7 +282,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置鼠标滚轮滚动速度系数。 + /// 获取或设置鼠标滚轮滚动速度系数�? /// public float WheelSpeed { @@ -302,7 +300,7 @@ namespace AlicizaX.UI } /// - /// 获取或设置是否仅在内容可滚动时显示滚动条。 + /// 获取或设置是否仅在内容可滚动时显示滚动条�? /// public bool ShowScrollBarOnlyWhenScrollable { @@ -321,7 +319,7 @@ namespace AlicizaX.UI #region Public Properties - Components /// - /// 获取或设置用于创建列表项的模板集合。 + /// 获取或设置用于创建列表项的模板集合�? /// public ViewHolder[] Templates { @@ -330,7 +328,7 @@ namespace AlicizaX.UI } /// - /// 获取内容节点;未显式指定时会尝试从首个子节点推断。 + /// 获取内容节点;未显式指定时会尝试从首个子节点推断�? /// public RectTransform Content { @@ -340,13 +338,19 @@ namespace AlicizaX.UI { if (transform.childCount == 0) { - throw new InvalidOperationException("RecyclerView content is not assigned and no child RectTransform exists."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("RecyclerView content is missing."); +#endif + return null; } content = transform.GetChild(0).GetComponent(); if (content == null) { - throw new InvalidOperationException("RecyclerView content child must have a RectTransform component."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("RecyclerView content RectTransform is missing."); +#endif + return null; } } @@ -355,17 +359,17 @@ namespace AlicizaX.UI } /// - /// 获取当前绑定的滚动条组件。 + /// 获取当前绑定的滚动条组件�? /// public Scrollbar Scrollbar => scrollbar; /// - /// 获取当前绑定的滚动器实例。 + /// 获取当前绑定的滚动器实例�? /// public Scroller Scroller => scroller; /// - /// 获取视图提供器;首次访问时根据模板数量自动创建。 + /// 获取视图提供器;首次访问时根据模板数量自动创建�? /// public ViewProvider ViewProvider { @@ -375,7 +379,10 @@ namespace AlicizaX.UI { if (templates == null || templates.Length == 0) { - throw new InvalidOperationException("RecyclerView templates can not be null or empty."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("RecyclerView templates are missing."); +#endif + return null; } viewProvider = templates.Length > 1 @@ -388,17 +395,27 @@ namespace AlicizaX.UI } /// - /// 获取当前对象池的统计信息文本。 + /// 获取当前对象池的统计信息文本�? /// - public string PoolStats => viewProvider?.PoolStats ?? string.Empty; + public string PoolStats + { + get + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + return viewProvider?.PoolStats ?? string.Empty; +#else + return string.Empty; +#endif + } + } /// - /// 获取当前布局管理器实例。 + /// 获取当前布局管理器实例�? /// public LayoutManager LayoutManager => layoutManager; /// - /// 获取导航控制器;首次访问时自动创建。 + /// 获取导航控制器;首次访问时自动创建�? /// public RecyclerNavigationController NavigationController => navigationController ??= new RecyclerNavigationController(this); @@ -407,14 +424,14 @@ namespace AlicizaX.UI #region Public Properties - State /// - /// 获取或设置当前绑定的适配器实例。 + /// 获取或设置当前绑定的适配器实例�? /// internal IAdapter RecyclerViewAdapter { get; private set; } /// - /// 获取当前记录的内部逻辑索引。 - /// 仅供框架内部的导航与布局逻辑使用;业务层请改用 维护自身状态, - /// 或使用适配器上的 ChoiceIndex 表示业务选中项。 + /// 获取当前记录的内部逻辑索引�? + /// 仅供框架内部的导航与布局逻辑使用;业务层请改�? 维护自身状态, + /// 或使用适配器上�?ChoiceIndex 表示业务选中项�? /// internal int CurrentIndex => currentIndex; @@ -423,22 +440,22 @@ namespace AlicizaX.UI #region Events /// - /// 当当前逻辑索引发生变化时触发。 + /// 当当前逻辑索引发生变化时触发�? /// public Action OnFocusIndexChanged; /// - /// 当滚动位置发生变化时触发。 + /// 当滚动位置发生变化时触发�? /// public Action OnScrollValueChanged; /// - /// 当滚动停止时触发。 + /// 当滚动停止时触发�? /// public Action OnScrollStopped; /// - /// 当拖拽状态变化时触发。 + /// 当拖拽状态变化时触发�? /// public Action OnScrollDraggingChanged; @@ -447,7 +464,7 @@ namespace AlicizaX.UI #region Unity Lifecycle /// - /// 初始化模板、滚动器、滚动条与导航桥接组件。 + /// 初始化模板、滚动器、滚动条与导航桥接组件�? /// private void Awake() { @@ -456,18 +473,52 @@ namespace AlicizaX.UI mainThreadId = Thread.CurrentThread.ManagedThreadId; } + ValidateConfiguration(); + if (!isValid) + { + return; + } + InitializeTemplates(); ConfigureScroller(); ConfigureScrollbar(); EnsureNavigationBridge(); } + private void LateUpdate() + { + ProcessPendingFocusRecovery(); + } + + private void OnDestroy() + { + if (scroller != null) + { + scroller.OnValueChanged.RemoveListener(OnScrollChanged); + scroller.OnMoveStoped.RemoveListener(OnMoveStoped); + scroller.OnDragging.RemoveListener(OnScrollerDraggingChanged); + } + + if (scrollbar != null) + { + scrollbar.onValueChanged.RemoveListener(OnScrollbarChanged); + ScrollbarEx scrollbarEx = scrollbar.gameObject.GetComponent(); + if (scrollbarEx != null && scrollbarEx.OnDragEnd == OnScrollbarDragEnd) + { + scrollbarEx.OnDragEnd = null; + } + } + + hasPendingFocusRecovery = false; + pendingFocusRecoveryTarget = null; + } + #endregion #region Initialization /// - /// 初始化所有模板实例并将其隐藏,避免模板对象直接参与显示。 + /// 初始化所有模板实例并将其隐藏,避免模板对象直接参与显示�? /// private void InitializeTemplates() { @@ -483,34 +534,118 @@ namespace AlicizaX.UI } /// - /// 确保当前对象挂载用于导航事件桥接的组件。 + /// 确保当前对象挂载用于导航事件桥接的组件�? /// + private void ValidateConfiguration() + { + isValid = true; + + if (content == null) + { + if (transform.childCount == 0) + { + LogValidationError("RecyclerView content is missing."); + isValid = false; + return; + } + + content = transform.GetChild(0).GetComponent(); + if (content == null) + { + LogValidationError("RecyclerView content RectTransform is missing."); + isValid = false; + return; + } + } + + if (templates == null || templates.Length == 0) + { + LogValidationError("RecyclerView templates are missing."); + isValid = false; + return; + } + + for (int i = 0; i < templates.Length; i++) + { + ViewHolder template = templates[i]; + if (template == null) + { + LogValidationError("RecyclerView template is null."); + isValid = false; + return; + } + + if (templates.Length > 1 && string.IsNullOrEmpty(template.GetType().Name)) + { + LogValidationError("RecyclerView mixed template name is missing."); + isValid = false; + return; + } + + if (templates.Length > 1 && HasDuplicateTemplateType(i, template.GetType())) + { + LogValidationError("RecyclerView mixed template type duplicated."); + isValid = false; + return; + } + } + } + + private bool HasDuplicateTemplateType(int currentIndex, Type templateType) + { + for (int i = 0; i < currentIndex; i++) + { + ViewHolder template = templates[i]; + if (template != null && template.GetType() == templateType) + { + return true; + } + } + + return false; + } + + private void LogValidationError(string message) + { + if (validationErrorLogged) + { + return; + } + + validationErrorLogged = true; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError(message); +#endif + } + private void EnsureNavigationBridge() { #if UX_NAVIGATION if (GetComponent() == null) { - gameObject.AddComponent(); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("RecyclerNavigationBridge is missing. Add it in prefab/editor setup."); +#endif } #endif } /// - /// 查找当前可见列表边缘对应的数据索引。 + /// 查找当前可见列表边缘对应的数据索引�? /// - /// 表示查找最大的布局索引;否则查找最小的布局索引。 - /// 找到的边缘数据索引;不存在可见项时返回 -1 + /// 表示查找最大的布局索引;否则查找最小的布局索引�?/param> + /// 找到的边缘数据索引;不存在可见项时返�?-1�?/returns> private int FindVisibleEdgeDataIndex(bool useMax) { - if (ViewProvider.ViewHolders.Count == 0) + if (ViewProvider.VisibleCount == 0) { return -1; } int best = useMax ? int.MinValue : int.MaxValue; - for (int i = 0; i < ViewProvider.ViewHolders.Count; i++) + for (int i = 0; i < ViewProvider.VisibleCount; i++) { - ViewHolder holder = ViewProvider.ViewHolders[i]; + ViewHolder holder = ViewProvider.GetVisibleViewHolder(i); if (holder == null || holder.Index < 0) { continue; @@ -533,7 +668,7 @@ namespace AlicizaX.UI } /// - /// 配置滚动器参数并注册滚动回调。 + /// 配置滚动器参数并注册滚动回调�? /// private void ConfigureScroller() { @@ -549,7 +684,7 @@ namespace AlicizaX.UI } /// - /// 配置滚动条监听与拖拽结束回调。 + /// 配置滚动条监听与拖拽结束回调�? /// private void ConfigureScrollbar() { @@ -560,7 +695,10 @@ namespace AlicizaX.UI var scrollbarEx = scrollbar.gameObject.GetComponent(); if (scrollbarEx == null) { - scrollbarEx = scrollbar.gameObject.AddComponent(); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("ScrollbarEx is missing. Add it in prefab/editor setup."); +#endif + return; } scrollbarEx.OnDragEnd = OnScrollbarDragEnd; @@ -572,9 +710,9 @@ namespace AlicizaX.UI #region Public Methods - Setup /// - /// 绑定新的适配器,并重建 RecyclerView 与布局管理器之间的关联关系。 + /// 绑定新的适配器,并重�?RecyclerView 与布局管理器之间的关联关系�? /// - /// 要绑定的适配器实例。 + /// 要绑定的适配器实例�?/param> internal void SetAdapter(IAdapter adapter) { if (!EnsureMainThread(nameof(SetAdapter))) @@ -584,13 +722,17 @@ namespace AlicizaX.UI if (adapter == null) { +#if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogError("Adapter cannot be null"); +#endif return; } if (layoutManager == null) { +#if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogError("LayoutManager cannot be null"); +#endif return; } @@ -619,11 +761,11 @@ namespace AlicizaX.UI } /// - /// 尝试获取当前可见区域内指定索引对应的视图持有者。 + /// 尝试获取当前可见区域内指定索引对应的视图持有者�? /// - /// 目标布局索引。 - /// 返回找到的视图持有者。 - /// 找到且该持有者仍处于可见范围内时返回 ;否则返回 + /// 目标布局索引�?/param> + /// 返回找到的视图持有者�?/param> + /// 找到且该持有者仍处于可见范围内时返回 ;否则返�?�?/returns> internal bool TryGetVisibleViewHolder(int index, out ViewHolder viewHolder) { viewHolder = ViewProvider.GetViewHolder(index); @@ -631,12 +773,12 @@ namespace AlicizaX.UI } /// - /// 尝试将焦点移动到指定索引对应的列表项。 + /// 尝试将焦点移动到指定索引对应的列表项�? /// - /// 目标数据索引。 - /// 是否先以平滑滚动方式将目标项滚入可见区域。 - /// 目标项滚动完成后的对齐方式。 - /// 成功定位并应用焦点时返回 ;否则返回 + /// 目标数据索引�?/param> + /// 是否先以平滑滚动方式将目标项滚入可见区域�?/param> + /// 目标项滚动完成后的对齐方式�?/param> + /// 成功定位并应用焦点时返回 ;否则返�?�?/returns> public bool TryFocusIndex(int index, bool smooth = false, ScrollAlignment alignment = ScrollAlignment.Center) { if (RecyclerViewAdapter == null || RecyclerViewAdapter.GetItemCount() <= 0 || index < 0 || index >= RecyclerViewAdapter.GetItemCount()) @@ -684,10 +826,10 @@ namespace AlicizaX.UI } /// - /// 按进入方向尝试将焦点移入当前列表。 + /// 按进入方向尝试将焦点移入当前列表�? /// - /// 焦点进入列表时的方向。 - /// 成功聚焦某个列表项时返回 ;否则返回 + /// 焦点进入列表时的方向�?/param> + /// 成功聚焦某个列表项时返回 ;否则返�?�?/returns> public bool TryFocusEntry( MoveDirection entryDirection, bool smooth = false, @@ -720,11 +862,11 @@ namespace AlicizaX.UI } /// - /// 解析指定持有者最终应被聚焦的目标对象。 + /// 解析指定持有者最终应被聚焦的目标对象�? /// - /// 目标视图持有者。 - /// 返回解析得到的焦点对象。 - /// 成功解析到可聚焦对象时返回 ;否则返回 + /// 目标视图持有者�?/param> + /// 返回解析得到的焦点对象�?/param> + /// 成功解析到可聚焦对象时返�?;否则返�?�?/returns> internal bool TryResolveFocusTarget(ViewHolder holder, out GameObject target) { target = null; @@ -733,48 +875,14 @@ namespace AlicizaX.UI return false; } - ItemInteractionProxy proxy = holder.GetComponent(); - if (proxy != null) - { - return proxy.TryGetFocusTarget(out target); - } - - Selectable selectable = holder.GetComponent(); - if (!IsSelectableFocusable(selectable)) - { - Selectable[] selectables = holder.GetComponentsInChildren(true); - for (int i = 0; i < selectables.Length; i++) - { - if (IsSelectableFocusable(selectables[i])) - { - selectable = selectables[i]; - break; - } - } - } - - if (IsSelectableFocusable(selectable)) - { - target = selectable.gameObject; - return true; - } - - if (ExecuteEvents.CanHandleEvent(holder.gameObject) || - ExecuteEvents.CanHandleEvent(holder.gameObject) || - ExecuteEvents.CanHandleEvent(holder.gameObject)) - { - target = holder.gameObject; - return true; - } - - return false; + return holder.TryGetFocusTarget(out target); } /// - /// 判断指定持有者是否已经完整处于当前视口内。 + /// 判断指定持有者是否已经完整处于当前视口内�? /// - /// 待检测的视图持有者。 - /// 完整可见时返回 ;否则返回 + /// 待检测的视图持有者�?/param> + /// 完整可见时返�?;否则返�?�?/returns> private bool IsFullyVisible(ViewHolder holder) { if (holder == null) @@ -811,9 +919,9 @@ namespace AlicizaX.UI } /// - /// 将 EventSystem 焦点切换到指定目标,并在下一帧做一次恢复校正。 + /// �?EventSystem 焦点切换到指定目标,并在下一帧做一次恢复校正�? /// - /// 目标焦点对象。 + /// 目标焦点对象�?/param> private void ApplyFocus(GameObject target) { if (target == null) @@ -845,12 +953,8 @@ namespace AlicizaX.UI private void ScheduleFocusRecovery(GameObject target) { - if (focusRecoveryCoroutine != null) - { - StopCoroutine(focusRecoveryCoroutine); - } - - focusRecoveryCoroutine = StartCoroutine(RecoverFocusNextFrame(target)); + pendingFocusRecoveryTarget = target; + hasPendingFocusRecovery = target != null; } private bool TryFocusIndexRange(int startIndex, int step, int itemCount, bool smooth, ScrollAlignment alignment) @@ -881,35 +985,41 @@ namespace AlicizaX.UI selectable.IsInteractable(); } - /// - /// 在下一帧尝试恢复目标对象的焦点,避免布局刷新期间焦点丢失。 - /// - /// 需要恢复焦点的目标对象。 - /// 用于协程调度的枚举器。 - private IEnumerator RecoverFocusNextFrame(GameObject target) + private void ProcessPendingFocusRecovery() { - yield return null; - focusRecoveryCoroutine = null; + if (!hasPendingFocusRecovery) + { + return; + } + + GameObject target = pendingFocusRecoveryTarget; + hasPendingFocusRecovery = false; + pendingFocusRecoveryTarget = null; if (target == null || !target.activeInHierarchy) { - yield break; + return; } EventSystem eventSystem = EventSystem.current; if (eventSystem == null || ReferenceEquals(eventSystem.currentSelectedGameObject, target)) { - yield break; + return; } eventSystem.SetSelectedGameObject(target); } /// - /// 重置视图池、滚动位置与当前索引状态。 + /// 重置视图池、滚动位置与当前索引状态�? /// internal void Reset() { + if (!isValid) + { + return; + } + if (!EnsureMainThread(nameof(Reset))) { return; @@ -938,10 +1048,15 @@ namespace AlicizaX.UI #region Public Methods - Layout /// - /// 按当前滚动位置重新创建可见范围内的所有视图持有者。 + /// 按当前滚动位置重新创建可见范围内的所有视图持有者�? /// internal void Refresh() { + if (!isValid) + { + return; + } + if (!EnsureMainThread(nameof(Refresh))) { return; @@ -971,10 +1086,15 @@ namespace AlicizaX.UI } /// - /// 重新计算内容尺寸、滚动能力与对象池预热状态。 + /// 重新计算内容尺寸、滚动能力与对象池预热状态�? /// internal void RequestLayout() { + if (!isValid) + { + return; + } + if (!EnsureMainThread(nameof(RequestLayout))) { return; @@ -1011,19 +1131,19 @@ namespace AlicizaX.UI #region Public Methods - Scrolling /// - /// 获取当前滚动位置。 + /// 获取当前滚动位置�? /// - /// 当前滚动偏移量;未启用滚动器时返回 0 + /// 当前滚动偏移量;未启用滚动器时返�?0�?/returns> public float GetScrollPosition() { return scroller != null ? scroller.Position : 0; } /// - /// 将列表滚动到指定索引对应的位置。 + /// 将列表滚动到指定索引对应的位置�? /// - /// 目标数据索引。 - /// 是否使用平滑滚动。 + /// 目标数据索引�?/param> + /// 是否使用平滑滚动�?/param> public void ScrollTo(int index, bool smooth = false) { if (!scroll || scroller == null) return; @@ -1039,13 +1159,13 @@ namespace AlicizaX.UI } /// - /// 将列表滚动到指定索引,并按给定对齐方式定位。 + /// 将列表滚动到指定索引,并按给定对齐方式定位�? /// - /// 目标数据索引。 - /// 目标项滚动完成后的对齐方式。 - /// 在对齐基础上的额外偏移量。 - /// 是否使用平滑滚动。 - /// 平滑滚动时长,单位为秒。 + /// 目标数据索引�?/param> + /// 目标项滚动完成后的对齐方式�?/param> + /// 在对齐基础上的额外偏移量�?/param> + /// 是否使用平滑滚动�?/param> + /// 平滑滚动时长,单位为秒�?/param> public void ScrollToWithAlignment(int index, ScrollAlignment alignment, float offset = 0f, bool smooth = false, float duration = 0.3f) { if (!scroll || scroller == null) return; @@ -1071,12 +1191,12 @@ namespace AlicizaX.UI } /// - /// 计算指定索引在目标对齐方式下应滚动到的位置。 + /// 计算指定索引在目标对齐方式下应滚动到的位置�? /// - /// 目标数据索引。 - /// 目标项滚动完成后的对齐方式。 - /// 在对齐基础上的额外偏移量。 - /// 计算得到的滚动位置,结果会被限制在合法范围内。 + /// 目标数据索引�?/param> + /// 目标项滚动完成后的对齐方式�?/param> + /// 在对齐基础上的额外偏移量�?/param> + /// 计算得到的滚动位置,结果会被限制在合法范围内�?/returns> private float CalculateScrollPositionWithAlignment(int index, ScrollAlignment alignment, float offset) { if (RecyclerViewAdapter == null || index < 0 || index >= RecyclerViewAdapter.GetItemCount()) @@ -1088,7 +1208,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; - // 计算目标项的原始位置,不在此阶段做范围限制。 + // 计算目标项的原始位置,不在此阶段做范围限制�? float itemPosition = CalculateRawItemPosition(index); float targetPosition = alignment switch @@ -1099,36 +1219,36 @@ namespace AlicizaX.UI _ => itemPosition }; - // 叠加调用方传入的额外偏移量。 + // 叠加调用方传入的额外偏移量�? targetPosition += offset; - // 将结果限制在可滚动范围内。 + // 将结果限制在可滚动范围内�? return Mathf.Clamp(targetPosition, 0, scroller.MaxPosition); } /// - /// 计算指定索引对应项在内容区域中的原始起始位置。 + /// 计算指定索引对应项在内容区域中的原始起始位置�? /// - /// 目标数据索引。 - /// 未做边界限制的原始滚动位置。 + /// 目标数据索引�?/param> + /// 未做边界限制的原始滚动位置�?/returns> private float CalculateRawItemPosition(int index) { - // 根据滚动方向选择对应轴向的间距与内边距。 + // 根据滚动方向选择对应轴向的间距与内边距�? 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; - // 直接基于索引、尺寸与间距推导原始位置。 + // 直接基于索引、尺寸与间距推导原始位置�? return index * (itemSize + spacingValue) + paddingValue; } /// - /// 获取指定索引对应项在主滚动轴上的尺寸。 + /// 获取指定索引对应项在主滚动轴上的尺寸�? /// - /// 目标数据索引。 - /// 目标项在主滚动轴上的尺寸值。 + /// 目标数据索引�?/param> + /// 目标项在主滚动轴上的尺寸值�?/returns> private float GetItemSize(int index) { Vector2 itemSize = ViewProvider.CalculateViewSize(index); @@ -1140,9 +1260,9 @@ namespace AlicizaX.UI #region Private Methods - Scroll Callbacks /// - /// 响应滚动器位置变化,更新布局、滚动条与可见区间。 + /// 响应滚动器位置变化,更新布局、滚动条与可见区间�? /// - /// 当前滚动位置。 + /// 当前滚动位置�?/param> private void OnScrollChanged(float position) { layoutManager.UpdateLayout(); @@ -1153,7 +1273,7 @@ namespace AlicizaX.UI } /// - /// 响应滚动器停止移动事件,处理吸附与挂起焦点请求。 + /// 响应滚动器停止移动事件,处理吸附与挂起焦点请求�? /// private void OnMoveStoped() { @@ -1167,18 +1287,18 @@ namespace AlicizaX.UI } /// - /// 响应滚动器拖拽状态变化。 + /// 响应滚动器拖拽状态变化�? /// - /// 当前是否正在拖拽。 + /// 当前是否正在拖拽�?/param> private void OnScrollerDraggingChanged(bool dragging) { OnScrollDraggingChanged?.Invoke(dragging); } /// - /// 响应滚动条值变化并同步滚动器位置。 + /// 响应滚动条值变化并同步滚动器位置�? /// - /// 滚动条归一化值。 + /// 滚动条归一化值�?/param> private void OnScrollbarChanged(float ratio) { if (scroller != null) @@ -1188,7 +1308,7 @@ namespace AlicizaX.UI } /// - /// 响应滚动条拖拽结束事件,并在需要时触发吸附。 + /// 响应滚动条拖拽结束事件,并在需要时触发吸附�? /// private void OnScrollbarDragEnd() { @@ -1205,9 +1325,9 @@ namespace AlicizaX.UI #region Private Methods - Scroll Helpers /// - /// 根据当前滚动位置同步滚动条显示值。 + /// 根据当前滚动位置同步滚动条显示值�? /// - /// 当前滚动位置。 + /// 当前滚动位置�?/param> private void UpdateScrollbarValue(float position) { if (scrollbar != null && scroller != null) @@ -1218,7 +1338,7 @@ namespace AlicizaX.UI } /// - /// 根据当前滚动位置增量更新可见区间内的视图持有者。 + /// 根据当前滚动位置增量更新可见区间内的视图持有者�? /// private void UpdateVisibleRange() { @@ -1227,7 +1347,7 @@ namespace AlicizaX.UI return; } - // 处理可见区间起始端的回收与补充。 + // 处理可见区间起始端的回收与补充�? if (layoutManager.IsFullInvisibleStart(startIndex)) { viewProvider.RemoveViewHolder(startIndex); @@ -1237,7 +1357,7 @@ namespace AlicizaX.UI { if (startIndex == 0) { - // TODO: 在滚动到列表起始端时补充刷新逻辑。 + // TODO: 在滚动到列表起始端时补充刷新逻辑�? } else { @@ -1246,7 +1366,7 @@ namespace AlicizaX.UI } } - // 处理可见区间末端的回收与补充。 + // 处理可见区间末端的回收与补充�? if (layoutManager.IsFullInvisibleEnd(endIndex)) { viewProvider.RemoveViewHolder(endIndex); @@ -1256,7 +1376,7 @@ namespace AlicizaX.UI { if (endIndex >= viewProvider.GetItemCount() - layoutManager.Unit) { - // TODO: 在滚动到列表末端时补充加载更多逻辑。 + // TODO: 在滚动到列表末端时补充加载更多逻辑�? } else { @@ -1265,7 +1385,7 @@ namespace AlicizaX.UI } } - // 若增量更新后的区间与实际可见区不一致,则退化为全量刷新。 + // 若增量更新后的区间与实际可见区不一致,则退化为全量刷新�? if (!layoutManager.IsVisible(startIndex) || !layoutManager.IsVisible(endIndex)) { Refresh(); @@ -1273,7 +1393,7 @@ namespace AlicizaX.UI } /// - /// 根据当前状态更新滚动条的显示与交互能力。 + /// 根据当前状态更新滚动条的显示与交互能力�? /// private void UpdateScrollbarVisibility() { @@ -1283,8 +1403,17 @@ namespace AlicizaX.UI } bool shouldShow = ShouldShowScrollbar(); - scrollbar.gameObject.SetActive(shouldShow); - scrollbar.interactable = shouldShow; + if (scrollbarVisibleState != shouldShow || scrollbar.gameObject.activeSelf != shouldShow) + { + scrollbarVisibleState = shouldShow; + scrollbar.gameObject.SetActive(shouldShow); + } + + if (scrollbarInteractableState != shouldShow || scrollbar.interactable != shouldShow) + { + scrollbarInteractableState = shouldShow; + scrollbar.interactable = shouldShow; + } if (shouldShow) { @@ -1294,9 +1423,9 @@ namespace AlicizaX.UI } /// - /// 判断当前是否应显示滚动条。 + /// 判断当前是否应显示滚动条�? /// - /// 应显示滚动条时返回 ;否则返回 + /// 应显示滚动条时返�?;否则返�?�?/returns> private bool ShouldShowScrollbar() { if (!showScrollBar || !scroll || scrollbar == null || scroller == null || layoutManager == null || !SupportsOverflowCheck()) @@ -1318,7 +1447,7 @@ namespace AlicizaX.UI } /// - /// 根据列表方向设置滚动条方向。 + /// 根据列表方向设置滚动条方向�? /// private void ConfigureScrollbarDirection() { @@ -1328,7 +1457,7 @@ namespace AlicizaX.UI } /// - /// 根据内容尺寸与视口尺寸更新滚动条手柄长度。 + /// 根据内容尺寸与视口尺寸更新滚动条手柄长度�? /// private void ConfigureScrollbarSize() { @@ -1364,10 +1493,10 @@ namespace AlicizaX.UI } /// - /// 获取滚动条轨道的像素长度。 + /// 获取滚动条轨道的像素长度�? /// - /// 是否按垂直滚动条计算。 - /// 滚动条轨道长度;无法获取时返回 0 + /// 是否按垂直滚动条计算�?/param> + /// 滚动条轨道长度;无法获取时返�?0�?/returns> private float GetScrollbarTrackLength(bool vertical) { if (scrollbar == null) @@ -1386,7 +1515,7 @@ namespace AlicizaX.UI } /// - /// 根据当前内容是否可滚动来更新滚动器启用状态。 + /// 根据当前内容是否可滚动来更新滚动器启用状态�? /// private void UpdateScrollerState() { @@ -1395,22 +1524,22 @@ namespace AlicizaX.UI return; } - scroller.enabled = scroll && (!ShouldLimitScrollToOverflow() || HasScrollableContent()); + scroller.InputEnabled = scroll && (!ShouldLimitScrollToOverflow() || HasScrollableContent()); } /// - /// 判断是否需要将滚动能力限制在内容溢出场景下才启用。 + /// 判断是否需要将滚动能力限制在内容溢出场景下才启用�? /// - /// 需要仅在内容溢出时启用滚动返回 ;否则返回 + /// 需要仅在内容溢出时启用滚动返回 ;否则返�?�?/returns> private bool ShouldLimitScrollToOverflow() { return showScrollBar && showScrollBarOnlyWhenScrollable && SupportsOverflowCheck(); } /// - /// 判断当前内容尺寸是否超过视口尺寸。 + /// 判断当前内容尺寸是否超过视口尺寸�? /// - /// 内容可滚动时返回 ;否则返回 + /// 内容可滚动时返回 ;否则返�?�?/returns> private bool HasScrollableContent() { if (layoutManager == null) @@ -1432,18 +1561,18 @@ namespace AlicizaX.UI } /// - /// 判断当前方向是否支持溢出检测。 + /// 判断当前方向是否支持溢出检测�? /// - /// 支持垂直或水平溢出检测时返回 ;否则返回 + /// 支持垂直或水平溢出检测时返回 ;否则返�?�?/returns> private bool SupportsOverflowCheck() { return direction == Direction.Vertical || direction == Direction.Horizontal; } /// - /// 判断当前布局是否已经具备有效的滚动尺寸信息。 + /// 判断当前布局是否已经具备有效的滚动尺寸信息�? /// - /// 内容尺寸与视口尺寸均有效时返回 ;否则返回 + /// 内容尺寸与视口尺寸均有效时返�?;否则返�?�?/returns> private bool HasValidScrollMetrics() { if (direction == Direction.Vertical) @@ -1460,9 +1589,9 @@ namespace AlicizaX.UI } /// - /// 将滚动位置吸附到最近的列表项。 + /// 将滚动位置吸附到最近的列表项�? /// - /// 触发了新的吸附滚动时返回 ;否则返回 + /// 触发了新的吸附滚动时返回 ;否则返�?�?/returns> private bool SnapToNearestItem() { int index = layoutManager.PositionToIndex(GetScrollPosition()); @@ -1477,9 +1606,9 @@ namespace AlicizaX.UI } /// - /// 更新当前逻辑索引,并在变化时触发事件通知。 + /// 更新当前逻辑索引,并在变化时触发事件通知�? /// - /// 新的候选索引。 + /// 新的候选索引�?/param> private void UpdateCurrentIndex(int index) { if (RecyclerViewAdapter == null) return; @@ -1512,15 +1641,15 @@ namespace AlicizaX.UI } /// - /// 重新绑定当前可见区域内指定数据索引对应的所有持有者。 + /// 重新绑定当前可见区域内指定数据索引对应的所有持有者�? /// - /// 要重绑的数据索引。 - /// 实际完成重绑的持有者数量。 + /// 要重绑的数据索引�?/param> + /// 实际完成重绑的持有者数量�?/returns> internal int RebindVisibleDataIndex(int dataIndex) { if (!EnsureMainThread(nameof(RebindVisibleDataIndex)) || RecyclerViewAdapter == null || - !ViewProvider.TryGetViewHoldersByDataIndex(dataIndex, out IReadOnlyList holders)) + !ViewProvider.TryGetViewHolderBucket(dataIndex, out ViewProvider.ViewHolderBucket holders)) { return 0; } @@ -1542,11 +1671,11 @@ namespace AlicizaX.UI } /// - /// 重新绑定当前可见区域内指定数据区间对应的所有持有者。 + /// 重新绑定当前可见区域内指定数据区间对应的所有持有者�? /// - /// 起始数据索引。 - /// 需要重绑的数据项数量。 - /// 实际完成重绑的持有者总数。 + /// 起始数据索引�?/param> + /// 需要重绑的数据项数量�?/param> + /// 实际完成重绑的持有者总数�?/returns> internal int RebindVisibleDataRange(int startDataIndex, int count) { if (count <= 0) @@ -1565,10 +1694,10 @@ namespace AlicizaX.UI } /// - /// 缓存一条等待滚动结束后执行的焦点请求。 + /// 缓存一条等待滚动结束后执行的焦点请求�? /// - /// 待聚焦的数据索引。 - /// 目标对齐方式。 + /// 待聚焦的数据索引�?/param> + /// 目标对齐方式�?/param> private void QueueFocusRequest(int index, ScrollAlignment alignment) { hasPendingFocusRequest = true; @@ -1577,7 +1706,7 @@ namespace AlicizaX.UI } /// - /// 清除当前缓存的焦点请求。 + /// 清除当前缓存的焦点请求�? /// private void ClearPendingFocusRequest() { @@ -1587,7 +1716,7 @@ namespace AlicizaX.UI } /// - /// 尝试执行当前缓存的焦点请求。 + /// 尝试执行当前缓存的焦点请求�? /// private void TryProcessPendingFocusRequest() { @@ -1603,19 +1732,19 @@ namespace AlicizaX.UI } /// - /// 判断当前调用线程是否为 Unity 主线程。 + /// 判断当前调用线程是否�?Unity 主线程�? /// - /// 当前线程为主线程时返回 ;否则返回 + /// 当前线程为主线程时返�?;否则返�?�?/returns> private static bool IsMainThread() { return mainThreadId < 0 || Thread.CurrentThread.ManagedThreadId == mainThreadId; } /// - /// 校验当前调用是否发生在 Unity 主线程上。 + /// 校验当前调用是否发生�?Unity 主线程上�? /// - /// 发起校验的调用方名称。 - /// 位于主线程时返回 ;否则返回 + /// 发起校验的调用方名称�?/param> + /// 位于主线程时返回 ;否则返�?�?/returns> private bool EnsureMainThread(string caller) { if (IsMainThread()) @@ -1623,7 +1752,9 @@ namespace AlicizaX.UI return true; } - Debug.LogError($"RecyclerView.{caller} must run on Unity main thread."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogError("RecyclerView method must run on Unity main thread."); +#endif return false; } diff --git a/Runtime/RecyclerView/Scroller/CircleScroller.cs b/Runtime/RecyclerView/Scroller/CircleScroller.cs index 818698b..adb9b96 100644 --- a/Runtime/RecyclerView/Scroller/CircleScroller.cs +++ b/Runtime/RecyclerView/Scroller/CircleScroller.cs @@ -7,8 +7,9 @@ namespace AlicizaX.UI { private Vector2 centerPosition; - private void Awake() + protected override void Awake() { + base.Awake(); RectTransform rectTransform = GetComponent(); Vector2 position = transform.position; Vector2 size = rectTransform.sizeDelta; diff --git a/Runtime/RecyclerView/Scroller/ScrollbarEx.cs b/Runtime/RecyclerView/Scroller/ScrollbarEx.cs index 155b5fb..6546486 100644 --- a/Runtime/RecyclerView/Scroller/ScrollbarEx.cs +++ b/Runtime/RecyclerView/Scroller/ScrollbarEx.cs @@ -14,11 +14,13 @@ namespace AlicizaX.UI private bool dragging; private bool hovering; + private float targetHandleScale = 1f; private void Awake() { scrollbar = GetComponent(); handle = scrollbar.handleRect; + targetHandleScale = GetCurrentHandleScale(); } public void OnBeginDrag(PointerEventData eventData) @@ -31,23 +33,7 @@ namespace AlicizaX.UI dragging = false; if (!hovering) { - if (scrollbar.direction == Scrollbar.Direction.TopToBottom || - scrollbar.direction == Scrollbar.Direction.BottomToTop) - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleX(handle, 1f, 0.2f); -#else - handle.localScale = new Vector3(1,handle.localScale.y,handle.localScale.z); -#endif - } - else - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleY(handle, 1f, 0.2f); -#else - handle.localScale = new Vector3(handle.localScale.x,1,handle.localScale.z); -#endif - } + SetHandleScale(1f); } OnDragEnd?.Invoke(); @@ -56,24 +42,7 @@ namespace AlicizaX.UI public void OnPointerEnter(PointerEventData eventData) { hovering = true; - if (scrollbar.direction == Scrollbar.Direction.TopToBottom || - scrollbar.direction == Scrollbar.Direction.BottomToTop) - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleX(handle, 2f, 0.2f); -#else - handle.localScale = new Vector3(2,handle.localScale.y,handle.localScale.z); -#endif - } - else - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleY(handle, 2f, 0.2f); -#else - handle.localScale = new Vector3(handle.localScale.x, 2, handle.localScale.z); -#endif - - } + SetHandleScale(2f); } public void OnPointerExit(PointerEventData eventData) @@ -81,24 +50,58 @@ namespace AlicizaX.UI hovering = false; if (!dragging) { - if (scrollbar.direction == Scrollbar.Direction.TopToBottom || - scrollbar.direction == Scrollbar.Direction.BottomToTop) - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleX(handle, 1f, 0.2f); -#else - handle.localScale = new Vector3(1,handle.localScale.y,handle.localScale.z); -#endif - } - else - { -#if PRIMETWEEN_SUPPORT - PrimeTween.Tween.ScaleY(handle, 1f, 0.2f); -#else - handle.localScale = new Vector3(handle.localScale.x,1,handle.localScale.z); -#endif - } + SetHandleScale(1f); } } + + private void SetHandleScale(float target) + { + if (handle == null || Mathf.Approximately(targetHandleScale, target)) + { + return; + } + + targetHandleScale = target; + bool vertical = IsVerticalScrollbar(); +#if PRIMETWEEN_SUPPORT + if (vertical) + { + PrimeTween.Tween.ScaleX(handle, target, 0.2f); + } + else + { + PrimeTween.Tween.ScaleY(handle, target, 0.2f); + } +#else + Vector3 scale = handle.localScale; + if (vertical) + { + scale.x = target; + } + else + { + scale.y = target; + } + + handle.localScale = scale; +#endif + } + + private float GetCurrentHandleScale() + { + if (handle == null) + { + return 1f; + } + + return IsVerticalScrollbar() ? handle.localScale.x : handle.localScale.y; + } + + private bool IsVerticalScrollbar() + { + return scrollbar != null && + (scrollbar.direction == Scrollbar.Direction.TopToBottom || + scrollbar.direction == Scrollbar.Direction.BottomToTop); + } } } diff --git a/Runtime/RecyclerView/Scroller/Scroller.cs b/Runtime/RecyclerView/Scroller/Scroller.cs index 3332f0e..9c9bdc3 100644 --- a/Runtime/RecyclerView/Scroller/Scroller.cs +++ b/Runtime/RecyclerView/Scroller/Scroller.cs @@ -1,4 +1,3 @@ -using System.Collections; using UnityEngine; using UnityEngine.EventSystems; @@ -6,16 +5,27 @@ namespace AlicizaX.UI { public class Scroller : MonoBehaviour, IScroller, IBeginDragHandler, IEndDragHandler, IDragHandler, IScrollHandler { - private static readonly WaitForEndOfFrame EndOfFrameYield = new(); - private Coroutine movementCoroutine; + protected enum MotionState + { + Idle, + Smooth, + Duration, + Inertia + } protected float position; - public float Position { get => position; set => position = value; } + + public float Position + { + get => position; + set => position = value; + } protected float velocity; public float Velocity => velocity; protected Direction direction; + public Direction Direction { get => direction; @@ -23,6 +33,7 @@ namespace AlicizaX.UI } protected Vector2 contentSize; + public Vector2 ContentSize { get => contentSize; @@ -30,6 +41,7 @@ namespace AlicizaX.UI } protected Vector2 viewSize; + public Vector2 ViewSize { get => viewSize; @@ -37,6 +49,7 @@ namespace AlicizaX.UI } protected float scrollSpeed = 1f; + public float ScrollSpeed { get => scrollSpeed; @@ -44,6 +57,7 @@ namespace AlicizaX.UI } protected float wheelSpeed = 30f; + public float WheelSpeed { get => wheelSpeed; @@ -51,6 +65,7 @@ namespace AlicizaX.UI } protected bool snap; + public bool Snap { get => snap; @@ -61,20 +76,72 @@ namespace AlicizaX.UI protected MoveStopEvent moveStopEvent = new(); protected DraggingEvent draggingEvent = new(); - public float MaxPosition => direction == Direction.Vertical ? - Mathf.Max(contentSize.y - viewSize.y, 0) : - Mathf.Max(contentSize.x - viewSize.x, 0); + private MotionState motionState; + private float motionStartPosition; + private float motionTargetPosition; + private float motionElapsed; + private float motionDuration; + private float motionSpeed; + private float inertiaVelocity; + + public float MaxPosition => direction == Direction.Vertical ? Mathf.Max(contentSize.y - viewSize.y, 0) : Mathf.Max(contentSize.x - viewSize.x, 0); public float ViewLength => direction == Direction.Vertical ? viewSize.y : viewSize.x; - public ScrollerEvent OnValueChanged { get => scrollerEvent; set => scrollerEvent = value; } + public ScrollerEvent OnValueChanged + { + get => scrollerEvent; + set => scrollerEvent = value; + } - public MoveStopEvent OnMoveStoped { get => moveStopEvent; set => moveStopEvent = value; } + public MoveStopEvent OnMoveStoped + { + get => moveStopEvent; + set => moveStopEvent = value; + } - public DraggingEvent OnDragging { get => draggingEvent; set => draggingEvent = value; } + public DraggingEvent OnDragging + { + get => draggingEvent; + set => draggingEvent = value; + } public float dragStopTime = 0f; + public bool InputEnabled { get; set; } = true; + + protected virtual void Awake() + { + } + + protected virtual void Update() + { + if (motionState == MotionState.Idle) + { + return; + } + + TickMotion(Time.deltaTime); + } + + private void TickMotion(float deltaTime) + { + switch (motionState) + { + case MotionState.Smooth: + TickSmooth(deltaTime); + break; + case MotionState.Duration: + TickDuration(deltaTime); + break; + case MotionState.Inertia: + TickInertia(deltaTime); + break; + default: + motionState = MotionState.Idle; + break; + } + } public virtual void ScrollTo(float position, bool smooth = false) { @@ -86,11 +153,10 @@ namespace AlicizaX.UI { this.position = position; OnValueChanged?.Invoke(this.position); + return; } - else - { - movementCoroutine = StartCoroutine(RunMotion(MoveTo(position))); - } + + StartPositionMotion(position, scrollSpeed); } public virtual void ScrollToDuration(float position, float duration) @@ -108,7 +174,11 @@ namespace AlicizaX.UI return; } - movementCoroutine = StartCoroutine(RunMotion(ToPositionByDuration(position, duration))); + motionState = MotionState.Duration; + motionStartPosition = this.position; + motionTargetPosition = position; + motionDuration = Mathf.Max(duration, 0.0001f); + motionElapsed = 0f; } public virtual void ScrollToRatio(float ratio) @@ -118,12 +188,22 @@ namespace AlicizaX.UI public void OnBeginDrag(PointerEventData eventData) { + if (!InputEnabled) + { + return; + } + OnDragging?.Invoke(true); StopMovement(); } public void OnEndDrag(PointerEventData eventData) { + if (!InputEnabled) + { + return; + } + Inertia(); Elastic(); OnDragging?.Invoke(false); @@ -131,6 +211,11 @@ namespace AlicizaX.UI public void OnDrag(PointerEventData eventData) { + if (!InputEnabled) + { + return; + } + dragStopTime = Time.time; velocity = GetDelta(eventData); @@ -141,6 +226,11 @@ namespace AlicizaX.UI public void OnScroll(PointerEventData eventData) { + if (!InputEnabled) + { + return; + } + StopMovement(); float rate = GetScrollRate() * wheelSpeed; @@ -158,32 +248,41 @@ namespace AlicizaX.UI return direction == Direction.Vertical ? eventData.delta.y * rate : -eventData.delta.x * rate; } - private float GetScrollRate() + protected float GetScrollRate() { float rate = 1f; + float viewLength = ViewLength; + if (viewLength <= 0f) + { + return rate; + } + if (position < 0) { - rate = Mathf.Max(0, 1 - (Mathf.Abs(position) / ViewLength)); + rate = Mathf.Max(0, 1 - (Mathf.Abs(position) / viewLength)); } else if (position > MaxPosition) { - rate = Mathf.Max(0, 1 - (Mathf.Abs(position - MaxPosition) / ViewLength)); + rate = Mathf.Max(0, 1 - (Mathf.Abs(position - MaxPosition) / viewLength)); } + return rate; } protected virtual void Inertia() { + if (Mathf.Abs(velocity) <= 0.1f) + { + CompleteMotion(true); + return; + } - if (Mathf.Abs(velocity) > 0.1f) - { - StopMovement(); - movementCoroutine = StartCoroutine(RunMotion(InertiaTo())); - } - else - { - OnMoveStoped?.Invoke(); - } + StopMovement(); + motionState = MotionState.Inertia; + motionStartPosition = position; + motionElapsed = 0f; + motionDuration = snap ? 0.1f : 1f; + inertiaVelocity = velocity > 0 ? Mathf.Min(velocity, 100) : Mathf.Max(velocity, -100); } protected virtual void Elastic() @@ -191,98 +290,105 @@ namespace AlicizaX.UI if (position < 0) { StopMovement(); - movementCoroutine = StartCoroutine(RunMotion(ElasticTo(0))); + StartPositionMotion(0, 7f); } else if (position > MaxPosition) { StopMovement(); - movementCoroutine = StartCoroutine(RunMotion(ElasticTo(MaxPosition))); + StartPositionMotion(MaxPosition, 7f); } } - IEnumerator InertiaTo() + protected void StopMovement() { - float timer = 0f; - float p = position; - float v = velocity > 0 ? Mathf.Min(velocity, 100) : Mathf.Max(velocity, -100); - float duration = snap ? 0.1f : 1f; - while (timer < duration) + motionState = MotionState.Idle; + } + + private void StartPositionMotion(float targetPosition, float speed) + { + motionState = MotionState.Smooth; + motionStartPosition = position; + motionTargetPosition = targetPosition; + motionElapsed = Time.deltaTime; + motionSpeed = speed; + } + + private void TickSmooth(float deltaTime) + { + if (Mathf.Abs(motionTargetPosition - position) <= 0.1f) { - float y = (float)EaseUtil.EaseOutCirc(timer) * 40; - timer += Time.deltaTime; - position = p + y * v; - - Elastic(); - + position = motionTargetPosition; OnValueChanged?.Invoke(position); - - yield return EndOfFrameYield; - } - - OnMoveStoped?.Invoke(); - } - - IEnumerator ElasticTo(float targetPos) - { - yield return ToPosition(targetPos, 7); - } - - IEnumerator MoveTo(float targetPos) - { - 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; - float time = Time.deltaTime; - while (Mathf.Abs(targetPos - position) > 0.1f) - { - position = Mathf.Lerp(startPos, targetPos, time * speed); - OnValueChanged?.Invoke(position); - - time += Time.deltaTime; - - yield return EndOfFrameYield; - } - - position = targetPos; - OnValueChanged?.Invoke(position); - } - - private IEnumerator RunMotion(IEnumerator motion) - { - yield return motion; - movementCoroutine = null; - } - - private void StopMovement() - { - if (movementCoroutine == null) - { + CompleteMotion(false); return; } - StopCoroutine(movementCoroutine); - movementCoroutine = null; + position = Mathf.Lerp(motionStartPosition, motionTargetPosition, motionElapsed * motionSpeed); + motionElapsed += deltaTime; + OnValueChanged?.Invoke(position); + } + + private void TickDuration(float deltaTime) + { + motionElapsed += deltaTime; + float t = Mathf.Clamp01(motionElapsed / motionDuration); + position = Mathf.Lerp(motionStartPosition, motionTargetPosition, t); + OnValueChanged?.Invoke(position); + + if (t >= 1f) + { + position = motionTargetPosition; + OnValueChanged?.Invoke(position); + CompleteMotion(false); + } + } + + private void TickInertia(float deltaTime) + { + motionElapsed += deltaTime; + float t = Mathf.Clamp01(motionElapsed / motionDuration); + float y = (float)EaseUtil.EaseOutCirc(t) * 40f; + float nextPosition = motionStartPosition + y * inertiaVelocity; + float maxPosition = MaxPosition; + + if (nextPosition < 0f) + { + position = 0f; + OnValueChanged?.Invoke(position); + StopMovement(); + StartPositionMotion(0f, 7f); + return; + } + + if (nextPosition > maxPosition) + { + position = maxPosition; + OnValueChanged?.Invoke(position); + StopMovement(); + StartPositionMotion(maxPosition, 7f); + return; + } + + position = nextPosition; + OnValueChanged?.Invoke(position); + + if (t >= 1f) + { + CompleteMotion(true); + } + } + + private void CompleteMotion(bool notifyStopped) + { + motionState = MotionState.Idle; + velocity = 0f; + + if (notifyStopped) + { + OnMoveStoped?.Invoke(); + } } } + + } diff --git a/Runtime/RecyclerView/UGList.cs b/Runtime/RecyclerView/UGList.cs index 7cd6d49..f21fbcd 100644 --- a/Runtime/RecyclerView/UGList.cs +++ b/Runtime/RecyclerView/UGList.cs @@ -165,7 +165,7 @@ namespace AlicizaX.UI public bool UnregisterItemRender(Type itemRenderType) { - return UnregisterItemRender(nameof(itemRenderType)); + return _adapter.UnregisterItemRender(itemRenderType); } diff --git a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs b/Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs similarity index 56% rename from Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs rename to Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs index e931216..bf962e7 100644 --- a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs +++ b/Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs @@ -1,11 +1,11 @@ using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; +using Cysharp.Text; namespace AlicizaX.UI { - [DisallowMultipleComponent] - public sealed class ItemInteractionProxy : MonoBehaviour, + public abstract partial class ViewHolder : IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler, @@ -23,22 +23,31 @@ namespace AlicizaX.UI ICancelHandler #endif { - private IItemInteractionHost host; - private ItemInteractionFlags flags; - private Selectable focusAnchor; + [SerializeField] + private ItemInteractionFlags itemInteractionFlags = ItemInteractionFlags.None; + + private IItemInteractionHost interactionHost; + private ItemInteractionFlags activeInteractionFlags; private RecyclerItemSelectable ownedSelectable; private Scroller parentScroller; + private bool missingSelectableLogged; - internal void Bind(IItemInteractionHost interactionHost) + public ItemInteractionFlags ItemInteractionFlags { - host = interactionHost; - flags = interactionHost?.InteractionFlags ?? ItemInteractionFlags.None; - parentScroller = GetComponentInParent(); + get => itemInteractionFlags; + set => itemInteractionFlags = value; + } + + internal void BindInteractionHost(IItemInteractionHost host) + { + interactionHost = host; + activeInteractionFlags = host?.InteractionFlags ?? itemInteractionFlags; + parentScroller = RecyclerView != null ? RecyclerView.Scroller : null; EnsureFocusAnchor(); if (ownedSelectable != null) { - bool requiresSelection = RequiresSelection(flags); + bool requiresSelection = RequiresSelection(activeInteractionFlags); ownedSelectable.interactable = requiresSelection; ownedSelectable.enabled = requiresSelection; } @@ -46,10 +55,10 @@ namespace AlicizaX.UI InvalidateNavigationScope(); } - public void Clear() + internal void ClearInteractionHost() { - host = null; - flags = ItemInteractionFlags.None; + interactionHost = null; + activeInteractionFlags = ItemInteractionFlags.None; parentScroller = null; if (ownedSelectable != null) @@ -61,45 +70,27 @@ namespace AlicizaX.UI InvalidateNavigationScope(); } - public Selectable GetSelectable() - { - EnsureFocusAnchor(); - return focusAnchor; - } - - internal bool TryGetFocusTarget(out GameObject target) - { - target = null; - if (!TryGetFocusableSelectable(out Selectable selectable)) - { - return false; - } - - target = selectable.gameObject; - return target != null; - } - public void OnPointerClick(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.PointerClick) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.PointerClick) != 0) { - host?.HandlePointerClick(eventData); + interactionHost?.HandlePointerClick(eventData); } } public void OnPointerEnter(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.PointerEnter) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.PointerEnter) != 0) { - host?.HandlePointerEnter(eventData); + interactionHost?.HandlePointerEnter(eventData); } } public void OnPointerExit(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.PointerExit) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.PointerExit) != 0) { - host?.HandlePointerExit(eventData); + interactionHost?.HandlePointerExit(eventData); } } @@ -109,9 +100,9 @@ namespace AlicizaX.UI #if INPUTSYSTEM_SUPPORT UXNavigationRuntime.NotifySelection(gameObject); #endif - if ((flags & ItemInteractionFlags.Select) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Select) != 0) { - host?.HandleSelect(eventData); + interactionHost?.HandleSelect(eventData); } #endif } @@ -119,9 +110,9 @@ namespace AlicizaX.UI public void OnDeselect(BaseEventData eventData) { #if UX_NAVIGATION - if ((flags & ItemInteractionFlags.Deselect) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Deselect) != 0) { - host?.HandleDeselect(eventData); + interactionHost?.HandleDeselect(eventData); } #endif } @@ -129,18 +120,18 @@ namespace AlicizaX.UI public void OnMove(AxisEventData eventData) { #if UX_NAVIGATION - if ((flags & ItemInteractionFlags.Move) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Move) != 0) { - host?.HandleMove(eventData); + interactionHost?.HandleMove(eventData); } #endif } public void OnBeginDrag(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.BeginDrag) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.BeginDrag) != 0) { - host?.HandleBeginDrag(eventData); + interactionHost?.HandleBeginDrag(eventData); return; } @@ -149,9 +140,9 @@ namespace AlicizaX.UI public void OnDrag(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.Drag) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Drag) != 0) { - host?.HandleDrag(eventData); + interactionHost?.HandleDrag(eventData); return; } @@ -160,9 +151,9 @@ namespace AlicizaX.UI public void OnEndDrag(PointerEventData eventData) { - if ((flags & ItemInteractionFlags.EndDrag) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.EndDrag) != 0) { - host?.HandleEndDrag(eventData); + interactionHost?.HandleEndDrag(eventData); return; } @@ -172,9 +163,9 @@ namespace AlicizaX.UI public void OnSubmit(BaseEventData eventData) { #if UX_NAVIGATION - if ((flags & ItemInteractionFlags.Submit) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Submit) != 0) { - host?.HandleSubmit(eventData); + interactionHost?.HandleSubmit(eventData); } #endif } @@ -182,9 +173,9 @@ namespace AlicizaX.UI public void OnCancel(BaseEventData eventData) { #if UX_NAVIGATION - if ((flags & ItemInteractionFlags.Cancel) != 0) + if ((activeInteractionFlags & ItemInteractionFlags.Cancel) != 0) { - host?.HandleCancel(eventData); + interactionHost?.HandleCancel(eventData); } #endif } @@ -210,28 +201,35 @@ namespace AlicizaX.UI #if UX_NAVIGATION ownedSelectable = GetComponent(); - if (ownedSelectable == null) +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (ownedSelectable == null && RequiresSelection(activeInteractionFlags) && !missingSelectableLogged) { - ownedSelectable = gameObject.AddComponent(); + missingSelectableLogged = true; + UnityEngine.Debug.LogError(ZString.Format("RecyclerItemSelectable is missing on '{0}'. Add it in prefab/editor setup.", GetHierarchyPath(transform))); } - +#endif focusAnchor = ownedSelectable; #endif } - private bool TryGetFocusableSelectable(out Selectable selectable) + private bool TryGetInteractionFocusTarget(out GameObject target) { + target = null; EnsureFocusAnchor(); if (IsSelectableFocusable(focusAnchor)) { - selectable = focusAnchor; - return true; + target = focusAnchor.gameObject; + return target != null; } - Selectable[] selectables = GetComponentsInChildren(true); - for (int i = 0; i < selectables.Length; i++) + if (selectableCache.Count == 0) { - Selectable candidate = selectables[i]; + RefreshInteractionCache(); + } + + for (int i = 0; i < selectableCache.Count; i++) + { + Selectable candidate = selectableCache[i]; if (candidate == focusAnchor) { continue; @@ -245,22 +243,14 @@ namespace AlicizaX.UI #endif if (IsSelectableFocusable(candidate)) { - selectable = candidate; - return true; + target = candidate.gameObject; + return target != null; } } - selectable = null; return false; } - private static bool IsSelectableFocusable(Selectable selectable) - { - return selectable != null && - selectable.IsActive() && - selectable.IsInteractable(); - } - private static bool RequiresSelection(ItemInteractionFlags interactionFlags) { #if !UX_NAVIGATION @@ -277,6 +267,26 @@ namespace AlicizaX.UI #endif } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + private static string GetHierarchyPath(Transform target) + { + if (target == null) + { + return ""; + } + + string path = target.name; + Transform parent = target.parent; + while (parent != null) + { + path = ZString.Format("{0}/{1}", parent.name, path); + parent = parent.parent; + } + + return path; + } +#endif + [System.Diagnostics.Conditional("INPUTSYSTEM_SUPPORT")] private void InvalidateNavigationScope() { diff --git a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta b/Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs.meta similarity index 83% rename from Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta rename to Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs.meta index 9237f6f..5bd5b7c 100644 --- a/Runtime/RecyclerView/Interaction/ItemInteractionProxy.cs.meta +++ b/Runtime/RecyclerView/ViewHolder/ViewHolder.Interaction.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ac4f0b81367e72b408b7d4a0148d39c3 +guid: 6114223b5ccfeaa499630f5e4b71120c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/RecyclerView/ViewHolder/ViewHolder.cs b/Runtime/RecyclerView/ViewHolder/ViewHolder.cs index 46e1ee6..2bfc34f 100644 --- a/Runtime/RecyclerView/ViewHolder/ViewHolder.cs +++ b/Runtime/RecyclerView/ViewHolder/ViewHolder.cs @@ -1,13 +1,19 @@ -using System; +using System.Collections.Generic; using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; namespace AlicizaX.UI { - public abstract class ViewHolder : MonoBehaviour + public abstract partial class ViewHolder : MonoBehaviour { private RectTransform rectTransform; + private Selectable focusAnchor; + private readonly List selectableCache = new(); + private bool interactionCacheReady; - internal event Action Destroyed; + internal IItemRender CachedItemRender; + internal string CachedItemRenderViewName; public RectTransform RectTransform { @@ -40,6 +46,57 @@ namespace AlicizaX.UI return BindingVersion; } + internal void RefreshInteractionCache() + { + if (interactionCacheReady) + { + return; + } + + focusAnchor = GetComponent(); + selectableCache.Clear(); + GetComponentsInChildren(true, selectableCache); + interactionCacheReady = true; + } + + internal bool TryGetFocusTarget(out GameObject target) + { + if (TryGetInteractionFocusTarget(out target)) + { + return true; + } + + Selectable selectable = IsSelectableFocusable(focusAnchor) ? focusAnchor : null; + if (selectable == null) + { + for (int i = 0; i < selectableCache.Count; i++) + { + if (IsSelectableFocusable(selectableCache[i])) + { + selectable = selectableCache[i]; + break; + } + } + } + + if (selectable != null) + { + target = selectable.gameObject; + return true; + } + + if (ExecuteEvents.CanHandleEvent(gameObject) || + ExecuteEvents.CanHandleEvent(gameObject) || + ExecuteEvents.CanHandleEvent(gameObject)) + { + target = gameObject; + return true; + } + + target = null; + return false; + } + protected internal virtual void OnRecycled() { AdvanceBindingVersion(); @@ -49,10 +106,12 @@ namespace AlicizaX.UI RecyclerView = null; } - protected virtual void OnDestroy() + private static bool IsSelectableFocusable(Selectable selectable) { - Destroyed?.Invoke(this); - Destroyed = null; + return selectable != null && + selectable.IsActive() && + selectable.IsInteractable(); } + } } diff --git a/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs b/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs index d61c382..d81604a 100644 --- a/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/MixedViewProvider.cs @@ -1,19 +1,46 @@ using System; using System.Collections.Generic; +using Cysharp.Text; namespace AlicizaX.UI { public class MixedViewProvider : ViewProvider { private readonly MixedObjectPool objectPool; + private readonly Dictionary templateIdsByName = new(StringComparer.Ordinal); private readonly Dictionary templatesByName = new(StringComparer.Ordinal); - private readonly Dictionary warmCounts = new(StringComparer.Ordinal); + private readonly ViewHolder[] cachedTemplates; + private readonly string[] templateNames; + private readonly int[] warmCountsByType; - public override string PoolStats => - $"hits={objectPool.HitCount}, misses={objectPool.MissCount}, destroys={objectPool.DestroyCount}"; + public override string PoolStats + { + get + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + return ZString.Format("hits={0}, misses={1}, destroys={2}", objectPool.HitCount, objectPool.MissCount, objectPool.DestroyCount); +#else + return string.Empty; +#endif + } + } public MixedViewProvider(RecyclerView recyclerView, ViewHolder[] templates) : base(recyclerView, templates) { + int count = 0; + for (int i = 0; i < templates.Length; i++) + { + if (templates[i] != null) + { + count++; + } + } + + cachedTemplates = new ViewHolder[count]; + templateNames = new string[count]; + warmCountsByType = new int[count]; + + int typeId = 0; for (int i = 0; i < templates.Length; i++) { ViewHolder template = templates[i]; @@ -22,7 +49,12 @@ namespace AlicizaX.UI continue; } - templatesByName[template.GetType().Name] = template; + string templateName = template.GetType().Name; + templateIdsByName[templateName] = typeId; + templatesByName[templateName] = template; + cachedTemplates[typeId] = template; + templateNames[typeId] = templateName; + typeId++; } UnityMixedComponentFactory factory = new(templatesByName, recyclerView.Content); @@ -31,31 +63,17 @@ namespace AlicizaX.UI public override ViewHolder GetTemplate(string viewName) { - if (templates == null || templates.Length == 0) - { - throw new NullReferenceException("ViewProvider templates can not null or empty."); - } - if (!templatesByName.TryGetValue(viewName, out ViewHolder template)) { - throw new KeyNotFoundException($"ViewProvider template '{viewName}' was not found."); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("ViewProvider template was not found."); +#endif + return null; } return template; } - public override ViewHolder[] GetTemplates() - { - if (templates == null || templates.Length == 0) - { - throw new NullReferenceException("ViewProvider templates can not null or empty."); - } - - ViewHolder[] values = new ViewHolder[templatesByName.Count]; - templatesByName.Values.CopyTo(values, 0); - return values; - } - public override ViewHolder Allocate(string viewName) { var viewHolder = objectPool.Allocate(viewName); @@ -72,7 +90,7 @@ namespace AlicizaX.UI { Clear(); (Adapter as IItemRenderCacheOwner)?.ReleaseAllItemRenders(); - objectPool.Dispose(); + objectPool.ClearInactive(); } public override void PreparePool() @@ -83,28 +101,36 @@ namespace AlicizaX.UI return; } + PrepareBucketPool(warmCount); + int itemCount = GetItemCount(); int start = Math.Max(0, LayoutManager.GetStartIndex()); int end = Math.Min(itemCount - 1, start + warmCount - 1); - warmCounts.Clear(); + Array.Clear(warmCountsByType, 0, warmCountsByType.Length); for (int index = start; index <= end; index++) { string viewName = Adapter.GetViewName(index); - if (string.IsNullOrEmpty(viewName)) + if (string.IsNullOrEmpty(viewName) || !templateIdsByName.TryGetValue(viewName, out int typeId)) { continue; } - warmCounts.TryGetValue(viewName, out int count); - warmCounts[viewName] = count + 1; + warmCountsByType[typeId]++; } - foreach (var pair in warmCounts) + for (int typeId = 0; typeId < warmCountsByType.Length; typeId++) { - int targetCount = pair.Value + Math.Max(1, LayoutManager.Unit); - objectPool.EnsureCapacity(pair.Key, targetCount); - objectPool.Warm(pair.Key, targetCount); + int count = warmCountsByType[typeId]; + if (count <= 0) + { + continue; + } + + string typeName = templateNames[typeId]; + int targetCount = count + Math.Max(1, LayoutManager.Unit); + objectPool.EnsureCapacity(typeName, targetCount); + objectPool.Warm(typeName, targetCount); } } } diff --git a/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs b/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs index 46989ee..b689d54 100644 --- a/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/SimpleViewProvider.cs @@ -1,13 +1,29 @@ -using System; - namespace AlicizaX.UI { + using Cysharp.Text; + public sealed class SimpleViewProvider : ViewProvider { 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 override string PoolStats + { + get + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + return ZString.Format("hits={0}, misses={1}, destroys={2}, active={3}, inactive={4}, peakActive={5}, capacity={6}", + objectPool.HitCount, + objectPool.MissCount, + objectPool.DestroyCount, + objectPool.ActiveCount, + objectPool.InactiveCount, + objectPool.PeakActive, + objectPool.MaxSize); +#else + return string.Empty; +#endif + } + } public SimpleViewProvider(RecyclerView recyclerView, ViewHolder[] templates) : base(recyclerView, templates) { @@ -17,20 +33,7 @@ namespace AlicizaX.UI public override ViewHolder GetTemplate(string viewName = "") { - if (templates == null || templates.Length == 0) - { - throw new NullReferenceException("ViewProvider templates can not null or empty."); - } - return templates[0]; - } - - public override ViewHolder[] GetTemplates() - { - if (templates == null || templates.Length == 0) - { - throw new NullReferenceException("ViewProvider templates can not null or empty."); - } - return templates; + return templates != null && templates.Length > 0 ? templates[0] : null; } public override ViewHolder Allocate(string viewName) @@ -49,7 +52,7 @@ namespace AlicizaX.UI { Clear(); (Adapter as IItemRenderCacheOwner)?.ReleaseAllItemRenders(); - objectPool.Dispose(); + objectPool.ClearInactive(); } public override void PreparePool() @@ -60,6 +63,8 @@ namespace AlicizaX.UI return; } + PrepareBucketPool(warmCount); + objectPool.EnsureCapacity(warmCount); objectPool.Warm(warmCount); } diff --git a/Runtime/RecyclerView/ViewProvider/ViewProvider.cs b/Runtime/RecyclerView/ViewProvider/ViewProvider.cs index b63f088..cd200a1 100644 --- a/Runtime/RecyclerView/ViewProvider/ViewProvider.cs +++ b/Runtime/RecyclerView/ViewProvider/ViewProvider.cs @@ -6,16 +6,26 @@ namespace AlicizaX.UI { public abstract class ViewProvider { - private readonly List viewHolders = new(); + private ViewHolder[] visibleHolders = new ViewHolder[8]; + private ViewHolder[] removeBuffer = new ViewHolder[4]; + private ViewHolderBucket[] bucketPool = new ViewHolderBucket[8]; + private int visibleHead; + private int visibleCount; + private int bucketPoolCount; private readonly Dictionary viewHoldersByIndex = new(); - private readonly Dictionary> viewHoldersByDataIndex = new(); + private readonly Dictionary viewHoldersByDataIndex = new(); private readonly Dictionary viewHolderPositions = new(); public IAdapter Adapter { get; set; } public LayoutManager LayoutManager { get; set; } - public IReadOnlyList ViewHolders => viewHolders; + public int VisibleCount => visibleCount; + + public ViewHolder GetVisibleViewHolder(int index) + { + return index >= 0 && index < visibleCount ? visibleHolders[GetVisibleSlot(index)] : null; + } public abstract string PoolStats { get; } @@ -30,8 +40,6 @@ namespace AlicizaX.UI public abstract ViewHolder GetTemplate(string viewName); - public abstract ViewHolder[] GetTemplates(); - public abstract ViewHolder Allocate(string viewName); public abstract void Free(string viewName, ViewHolder viewHolder); @@ -52,8 +60,19 @@ namespace AlicizaX.UI viewHolder.Index = i; viewHolder.DataIndex = i; viewHolder.RecyclerView = recyclerView; - viewHolders.Add(viewHolder); - RegisterViewHolder(viewHolder); + (Adapter as IItemRenderPrewarmer)?.PrewarmItemRender(viewHolder, viewName); + if (!AddVisibleHolder(viewHolder)) + { + Free(viewName, viewHolder); + continue; + } + + if (!RegisterViewHolder(viewHolder)) + { + RemoveVisibleHolder(viewHolder); + Free(viewName, viewHolder); + continue; + } LayoutManager.Layout(viewHolder, i); Adapter.OnBindViewHolder(viewHolder, i); @@ -62,19 +81,37 @@ namespace AlicizaX.UI public void RemoveViewHolder(int index) { - for (int i = index; i < index + LayoutManager.Unit; i++) + int removeCount = 0; + int end = index + LayoutManager.Unit; + EnsureRemoveBufferCapacity(LayoutManager.Unit); + for (int i = index; i < end; i++) { - if (i > Adapter.GetItemCount() - 1) break; + if (i > Adapter.GetItemCount() - 1) + { + break; + } int viewHolderIndex = GetViewHolderIndex(i); + if (viewHolderIndex < 0 || viewHolderIndex >= visibleCount) + { + continue; + } - if (viewHolderIndex < 0 || viewHolderIndex >= viewHolders.Count) return; + removeBuffer[removeCount++] = visibleHolders[GetVisibleSlot(viewHolderIndex)]; + } + + for (int i = 0; i < removeCount; i++) + { + ViewHolder viewHolder = removeBuffer[i]; + removeBuffer[i] = null; + if (viewHolder == null) + { + continue; + } - var viewHolder = viewHolders[viewHolderIndex]; string viewName = viewHolder.Name; - viewHolders.RemoveAt(viewHolderIndex); + RemoveVisibleHolder(viewHolder); UnregisterViewHolder(viewHolder); - RebuildViewHolderPositions(viewHolderIndex); Adapter?.OnRecycleViewHolder(viewHolder); viewHolder.OnRecycled(); ClearSelectedState(viewHolder); @@ -91,22 +128,14 @@ namespace AlicizaX.UI public ViewHolder GetViewHolderByDataIndex(int dataIndex) { - return viewHoldersByDataIndex.TryGetValue(dataIndex, out List holders) && - holders is { Count: > 0 } - ? holders[0] + return viewHoldersByDataIndex.TryGetValue(dataIndex, out ViewHolderBucket bucket) && bucket.Count > 0 + ? bucket[0] : null; } - public bool TryGetViewHoldersByDataIndex(int dataIndex, out IReadOnlyList holders) + public bool TryGetViewHolderBucket(int dataIndex, out ViewHolderBucket bucket) { - if (viewHoldersByDataIndex.TryGetValue(dataIndex, out List list) && list.Count > 0) - { - holders = list; - return true; - } - - holders = null; - return false; + return viewHoldersByDataIndex.TryGetValue(dataIndex, out bucket) && bucket.Count > 0; } public int GetViewHolderIndex(int index) @@ -118,8 +147,14 @@ namespace AlicizaX.UI public void Clear() { - foreach (var viewHolder in viewHolders) + for (int i = 0; i < visibleCount; i++) { + ViewHolder viewHolder = visibleHolders[GetVisibleSlot(i)]; + if (viewHolder == null) + { + continue; + } + string viewName = viewHolder.Name; Adapter?.OnRecycleViewHolder(viewHolder); UnregisterViewHolder(viewHolder); @@ -128,8 +163,14 @@ namespace AlicizaX.UI Free(viewName, viewHolder); } - viewHolders.Clear(); + System.Array.Clear(visibleHolders, 0, visibleHolders.Length); + visibleHead = 0; + visibleCount = 0; viewHoldersByIndex.Clear(); + foreach (var pair in viewHoldersByDataIndex) + { + ReleaseBucket(pair.Value); + } viewHoldersByDataIndex.Clear(); viewHolderPositions.Clear(); } @@ -161,27 +202,47 @@ namespace AlicizaX.UI 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); + int unit = Mathf.Max(1, LayoutManager.Unit); + int bufferCount = unit * 2; return Mathf.Min(itemCount, visibleCount + bufferCount); } - private void RegisterViewHolder(ViewHolder viewHolder) + protected void PrepareVisibleStorage(int warmCount) + { + int capacity = Mathf.Max(Mathf.Max(1, LayoutManager != null ? LayoutManager.Unit : 1), warmCount); + if (visibleHolders.Length < capacity) + { + visibleHolders = new ViewHolder[capacity]; + } + + if (removeBuffer.Length < capacity) + { + removeBuffer = new ViewHolder[capacity]; + } + } + + private bool RegisterViewHolder(ViewHolder viewHolder) { if (viewHolder == null) { - return; + return false; + } + + if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out ViewHolderBucket bucket)) + { + bucket = AllocateBucket(); + if (bucket == null) + { + return false; + } + + viewHoldersByDataIndex[viewHolder.DataIndex] = bucket; } viewHoldersByIndex[viewHolder.Index] = viewHolder; - viewHolderPositions[viewHolder.Index] = viewHolders.Count - 1; - - if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out List holders)) - { - holders = new List(1); - viewHoldersByDataIndex[viewHolder.DataIndex] = holders; - } - - holders.Add(viewHolder); + viewHolderPositions[viewHolder.Index] = GetVisibleSlot(visibleCount - 1); + bucket.Add(viewHolder); + return true; } private void UnregisterViewHolder(ViewHolder viewHolder) @@ -194,35 +255,72 @@ namespace AlicizaX.UI viewHoldersByIndex.Remove(viewHolder.Index); viewHolderPositions.Remove(viewHolder.Index); - if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out List holders)) + if (!viewHoldersByDataIndex.TryGetValue(viewHolder.DataIndex, out ViewHolderBucket bucket)) { return; } - // 用末尾元素覆盖目标项再移除末位,避免 List.Remove 的线性搜索+内存搬移。 - int idx = holders.LastIndexOf(viewHolder); - if (idx >= 0) - { - holders[idx] = holders[holders.Count - 1]; - holders.RemoveAt(holders.Count - 1); - } - - if (holders.Count == 0) + bucket.Remove(viewHolder); + if (bucket.Count == 0) { viewHoldersByDataIndex.Remove(viewHolder.DataIndex); + ReleaseBucket(bucket); } } - private void RebuildViewHolderPositions(int startIndex) + private bool AddVisibleHolder(ViewHolder viewHolder) { - for (int i = startIndex; i < viewHolders.Count; i++) + if (visibleCount == visibleHolders.Length) { - ViewHolder holder = viewHolders[i]; - if (holder != null) - { - viewHolderPositions[holder.Index] = i; - } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("RecyclerView visible holder capacity exceeded. Increase warm count."); +#endif + return false; } + + visibleHolders[GetVisibleSlot(visibleCount)] = viewHolder; + visibleCount++; + return true; + } + + private void RemoveVisibleHolder(ViewHolder viewHolder) + { + if (!viewHolderPositions.TryGetValue(viewHolder.Index, out int slot)) + { + return; + } + + int last = GetVisibleSlot(visibleCount - 1); + ViewHolder lastHolder = visibleHolders[last]; + visibleHolders[slot] = lastHolder; + visibleHolders[last] = null; + visibleCount--; + if (visibleCount == 0) + { + visibleHead = 0; + } + + if (lastHolder != null && lastHolder != viewHolder) + { + viewHolderPositions[lastHolder.Index] = slot; + } + } + + private int GetVisibleSlot(int index) + { + return (visibleHead + index) % visibleHolders.Length; + } + + private void EnsureRemoveBufferCapacity(int required) + { + if (required <= removeBuffer.Length) + { + return; + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("RecyclerView remove buffer capacity exceeded. Increase warm count."); +#endif } private static void ClearSelectedState(ViewHolder viewHolder) @@ -244,5 +342,132 @@ namespace AlicizaX.UI eventSystem.SetSelectedGameObject(null); } } + + + protected void PrepareBucketPool(int count) + { + PrepareVisibleStorage(count); + EnsureBucketPoolCapacity(count); + int capacity = GetBucketCapacity(); + while (bucketPoolCount < count) + { + bucketPool[bucketPoolCount++] = new ViewHolderBucket(capacity); + } + } + + private ViewHolderBucket AllocateBucket() + { + if (bucketPoolCount > 0) + { + ViewHolderBucket bucket = bucketPool[--bucketPoolCount]; + bucketPool[bucketPoolCount] = null; + bucket.Clear(); + return bucket; + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("ViewHolderBucket pool is empty. Increase RecyclerView warm count."); +#endif + return null; + } + + private void ReleaseBucket(ViewHolderBucket bucket) + { + if (bucket == null) + { + return; + } + + bucket.Clear(); + EnsureBucketPoolCapacity(bucketPoolCount + 1); + bucketPool[bucketPoolCount++] = bucket; + } + + private int GetBucketCapacity() + { + return Mathf.Max(4, LayoutManager != null ? LayoutManager.Unit + 1 : 4); + } + + private void EnsureBucketPoolCapacity(int required) + { + if (required <= bucketPool.Length) + { + return; + } + + int capacity = bucketPool.Length; + while (capacity < required) + { + capacity <<= 1; + } + + ViewHolderBucket[] next = new ViewHolderBucket[capacity]; + System.Array.Copy(bucketPool, next, bucketPoolCount); + bucketPool = next; + } + + public sealed class ViewHolderBucket + { + private readonly ViewHolder[] holders; + + public ViewHolderBucket(int capacity) + { + holders = new ViewHolder[capacity]; + } + + public int Count { get; private set; } + + public ViewHolder this[int index] + { + get + { + if (index < 0 || index >= Count) + { + return null; + } + + return holders[index]; + } + } + + public void Add(ViewHolder holder) + { + if (Count == holders.Length) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + UnityEngine.Debug.LogError("ViewHolderBucket capacity exceeded."); +#endif + return; + } + + holders[Count++] = holder; + } + + public void Remove(ViewHolder holder) + { + for (int i = 0; i < Count; i++) + { + if (holders[i] != holder) + { + continue; + } + + Count--; + holders[i] = holders[Count]; + holders[Count] = null; + return; + } + } + + public void Clear() + { + for (int i = 0; i < Count; i++) + { + holders[i] = null; + } + + Count = 0; + } + } } } diff --git a/Runtime/UXComponent/Navigation/UXInputModeService.cs b/Runtime/UXComponent/Navigation/UXInputModeService.cs index 9f07b11..28be0a2 100644 --- a/Runtime/UXComponent/Navigation/UXInputModeService.cs +++ b/Runtime/UXComponent/Navigation/UXInputModeService.cs @@ -1,4 +1,4 @@ -#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION +#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION using System; using UnityEngine.InputSystem; using UnityEngine.InputSystem.Controls; @@ -10,6 +10,30 @@ namespace UnityEngine.UI private const float StickThresholdSqr = 0.04f; private const float AxisThreshold = 0.2f; + private const string PointerActionName = "UXPointerInput"; + private const string KeyboardActionName = "UXKeyboardInput"; + private const string GamepadActionName = "UXGamepadInput"; + private const string TouchActionName = "UXTouchInput"; + private const string MouseDeltaBinding = "/delta"; + private const string MouseScrollBinding = "/scroll"; + private const string MouseLeftButtonBinding = "/leftButton"; + private const string MouseRightButtonBinding = "/rightButton"; + private const string MouseMiddleButtonBinding = "/middleButton"; + private const string KeyboardAnyKeyBinding = "/anyKey"; + private const string GamepadButtonSouthBinding = "/buttonSouth"; + private const string GamepadButtonNorthBinding = "/buttonNorth"; + private const string GamepadButtonEastBinding = "/buttonEast"; + private const string GamepadButtonWestBinding = "/buttonWest"; + private const string GamepadStartButtonBinding = "/startButton"; + private const string GamepadSelectButtonBinding = "/selectButton"; + private const string GamepadLeftShoulderBinding = "/leftShoulder"; + private const string GamepadRightShoulderBinding = "/rightShoulder"; + private const string GamepadDpadBinding = "/dpad"; + private const string GamepadLeftStickBinding = "/leftStick"; + private const string GamepadRightStickBinding = "/rightStick"; + private const string TouchPressBinding = "/primaryTouch/press"; + private const string TouchDeltaBinding = "/primaryTouch/delta"; + private static UXInputModeService _instance; private InputAction _pointerAction; @@ -21,12 +45,6 @@ namespace UnityEngine.UI public static event Action OnModeChanged; - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] - private static void Bootstrap() - { - EnsureInstance(); - } - internal static UXInputModeService EnsureInstance() { if (_instance != null) @@ -81,35 +99,35 @@ namespace UnityEngine.UI return; } - _pointerAction = new InputAction("UXPointerInput", InputActionType.PassThrough); - _pointerAction.AddBinding("/delta"); - _pointerAction.AddBinding("/scroll"); - _pointerAction.AddBinding("/leftButton"); - _pointerAction.AddBinding("/rightButton"); - _pointerAction.AddBinding("/middleButton"); + _pointerAction = new InputAction(PointerActionName, InputActionType.PassThrough); + _pointerAction.AddBinding(MouseDeltaBinding); + _pointerAction.AddBinding(MouseScrollBinding); + _pointerAction.AddBinding(MouseLeftButtonBinding); + _pointerAction.AddBinding(MouseRightButtonBinding); + _pointerAction.AddBinding(MouseMiddleButtonBinding); _pointerAction.performed += OnPointerInput; - _keyboardAction = new InputAction("UXKeyboardInput", InputActionType.PassThrough); - _keyboardAction.AddBinding("/anyKey"); + _keyboardAction = new InputAction(KeyboardActionName, InputActionType.PassThrough); + _keyboardAction.AddBinding(KeyboardAnyKeyBinding); _keyboardAction.performed += OnKeyboardInput; - _gamepadAction = new InputAction("UXGamepadInput", InputActionType.PassThrough); - _gamepadAction.AddBinding("/buttonSouth"); - _gamepadAction.AddBinding("/buttonNorth"); - _gamepadAction.AddBinding("/buttonEast"); - _gamepadAction.AddBinding("/buttonWest"); - _gamepadAction.AddBinding("/startButton"); - _gamepadAction.AddBinding("/selectButton"); - _gamepadAction.AddBinding("/leftShoulder"); - _gamepadAction.AddBinding("/rightShoulder"); - _gamepadAction.AddBinding("/dpad"); - _gamepadAction.AddBinding("/leftStick"); - _gamepadAction.AddBinding("/rightStick"); + _gamepadAction = new InputAction(GamepadActionName, InputActionType.PassThrough); + _gamepadAction.AddBinding(GamepadButtonSouthBinding); + _gamepadAction.AddBinding(GamepadButtonNorthBinding); + _gamepadAction.AddBinding(GamepadButtonEastBinding); + _gamepadAction.AddBinding(GamepadButtonWestBinding); + _gamepadAction.AddBinding(GamepadStartButtonBinding); + _gamepadAction.AddBinding(GamepadSelectButtonBinding); + _gamepadAction.AddBinding(GamepadLeftShoulderBinding); + _gamepadAction.AddBinding(GamepadRightShoulderBinding); + _gamepadAction.AddBinding(GamepadDpadBinding); + _gamepadAction.AddBinding(GamepadLeftStickBinding); + _gamepadAction.AddBinding(GamepadRightStickBinding); _gamepadAction.performed += OnGamepadInput; - _touchAction = new InputAction("UXTouchInput", InputActionType.PassThrough); - _touchAction.AddBinding("/primaryTouch/press"); - _touchAction.AddBinding("/primaryTouch/delta"); + _touchAction = new InputAction(TouchActionName, InputActionType.PassThrough); + _touchAction.AddBinding(TouchPressBinding); + _touchAction.AddBinding(TouchDeltaBinding); _touchAction.performed += OnTouchInput; } @@ -222,7 +240,6 @@ namespace UnityEngine.UI internal static void SetMode(UXInputMode mode) { - EnsureInstance(); if (CurrentMode == mode) { return; @@ -234,3 +251,4 @@ namespace UnityEngine.UI } } #endif + diff --git a/Runtime/UXComponent/Navigation/UXNavigationRuntime.cs b/Runtime/UXComponent/Navigation/UXNavigationRuntime.cs index e81cee6..1c1f1b8 100644 --- a/Runtime/UXComponent/Navigation/UXNavigationRuntime.cs +++ b/Runtime/UXComponent/Navigation/UXNavigationRuntime.cs @@ -1,5 +1,4 @@ -#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION -using AlicizaX; +#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION using AlicizaX.UI.Runtime; using UnityEngine.EventSystems; @@ -7,39 +6,28 @@ namespace UnityEngine.UI { public interface IUXNavigationCursorPolicy { - void OnInputModeChanged(UXInputMode mode, UXNavigationScope topScope); + void OnNavigationContextChanged(UXInputMode mode, UXNavigationScope previousTopScope, UXNavigationScope currentTopScope); } public sealed class UXNavigationRuntime : MonoBehaviour { - private const int InitialScopeCapacity = 64; + private const int ScopeCapacity = 128; private const int InvalidIndex = -1; private static UXNavigationRuntime _instance; private static IUXNavigationCursorPolicy _cursorPolicy; - private UXNavigationScope[] _scopes = new UXNavigationScope[InitialScopeCapacity]; - private int[] _freeIndices = new int[InitialScopeCapacity]; - private int _freeCount; + private readonly UXNavigationScope[] _scopes = new UXNavigationScope[ScopeCapacity]; private int _scopeCount; - private int _scopeCapacityHighWater; private UXNavigationScope _topScope; private ulong _activationSerial; private bool _stateDirty = true; private bool _suppressionDirty = true; - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] - private static void Bootstrap() - { - if (!AppServices.TryGetApp(out var uiService) || uiService == null) - { - return; - } - - EnsureInstance(); - UXInputModeService.EnsureInstance(); - } + private bool _isFlushingState; + private bool _isEnsuringSelection; + private bool _contextNotificationDirty; + private UXNavigationScope _pendingPreviousTopScope; internal static UXNavigationRuntime EnsureInstance() { @@ -64,6 +52,10 @@ namespace UnityEngine.UI public static void SetCursorPolicy(IUXNavigationCursorPolicy cursorPolicy) { _cursorPolicy = cursorPolicy; + if (_instance != null) + { + _cursorPolicy?.OnNavigationContextChanged(UXInputModeService.CurrentMode, _instance._topScope, _instance._topScope); + } } public static void NotifySelection(GameObject selectedObject) @@ -119,6 +111,7 @@ namespace UnityEngine.UI if (_instance == this) { _instance = null; + _cursorPolicy = null; } } @@ -129,25 +122,16 @@ namespace UnityEngine.UI return; } - int index; - if (_freeCount > 0) + if (_scopeCount >= _scopes.Length) { - index = _freeIndices[--_freeCount]; - } - else - { - if (_scopeCapacityHighWater >= _scopes.Length) - { - ReportCapacityExceeded("UXNavigationRuntime scope capacity exceeded."); - return; - } - - index = _scopeCapacityHighWater++; + ReportCapacityExceeded(); + return; } + int index = _scopeCount++; _scopes[index] = scope; scope.RuntimeIndex = index; - _scopeCount++; + UXInputModeService.EnsureInstance(); MarkStateDirty(); } @@ -159,23 +143,30 @@ namespace UnityEngine.UI } int index = scope.RuntimeIndex; - if (index < 0 || index >= _scopes.Length || _scopes[index] != scope) + if (index < 0 || index >= _scopeCount || _scopes[index] != scope) { return; } if (_topScope == scope) { - _topScope = null; + SetTopScope(null, false); } scope.IsAvailable = false; scope.WasAvailable = false; scope.SetNavigationSuppressed(false); scope.RuntimeIndex = InvalidIndex; - _scopes[index] = null; - _freeIndices[_freeCount++] = index; - _scopeCount--; + + int last = --_scopeCount; + UXNavigationScope movedScope = _scopes[last]; + _scopes[last] = null; + if (index != last) + { + _scopes[index] = movedScope; + movedScope.RuntimeIndex = index; + } + MarkStateDirty(); } @@ -192,29 +183,28 @@ namespace UnityEngine.UI internal void InvalidateSkipCaches() { - for (int i = 0; i < _scopeCapacityHighWater; i++) + for (int i = 0; i < _scopeCount; i++) { - UXNavigationScope scope = _scopes[i]; - if (scope != null) - { - scope.InvalidateSkipCacheOnly(); - } + _scopes[i].InvalidateSkipCacheOnly(); } MarkStateDirty(); } - private void FlushStateIfDirty() + private void FlushStateIfDirty(bool notifyContext) { + if (_isFlushingState) + { + return; + } + + _isFlushingState = true; + UXNavigationScope previousTopScope = _topScope; if (_stateDirty) { UXNavigationScope newTopScope = FindTopScope(); _stateDirty = false; - if (!ReferenceEquals(_topScope, newTopScope)) - { - _topScope = newTopScope; - _suppressionDirty = true; - } + SetTopScope(newTopScope, false); } if (_suppressionDirty) @@ -227,19 +217,24 @@ namespace UnityEngine.UI { EnsureNavigationSelection(); } + + _isFlushingState = false; + if (notifyContext) + { + NotifyContextIfChanged(previousTopScope, _topScope); + } + else if (!ReferenceEquals(previousTopScope, _topScope)) + { + QueueContextNotification(previousTopScope); + } } private UXNavigationScope FindTopScope() { UXNavigationScope bestScope = null; - for (int i = 0; i < _scopeCapacityHighWater; i++) + for (int i = 0; i < _scopeCount; i++) { UXNavigationScope scope = _scopes[i]; - if (scope == null) - { - continue; - } - bool available = IsScopeAvailable(scope); scope.IsAvailable = available; if (scope.WasAvailable != available) @@ -278,14 +273,9 @@ namespace UnityEngine.UI private void ApplyScopeSuppression() { - for (int i = 0; i < _scopeCapacityHighWater; i++) + for (int i = 0; i < _scopeCount; i++) { UXNavigationScope scope = _scopes[i]; - if (scope == null) - { - continue; - } - bool suppress = scope.IsAvailable && _topScope != null && scope != _topScope @@ -311,7 +301,9 @@ namespace UnityEngine.UI } Selectable preferred = _topScope.GetPreferredSelectable(); + _isEnsuringSelection = true; eventSystem.SetSelectedGameObject(preferred != null ? preferred.gameObject : null); + _isEnsuringSelection = false; GameObject selectedObject = eventSystem.currentSelectedGameObject; if (selectedObject != null) { @@ -321,9 +313,9 @@ namespace UnityEngine.UI private void RecordSelection(GameObject selectedObject) { - if (_stateDirty || _suppressionDirty) + if (!_isEnsuringSelection && (_stateDirty || _suppressionDirty)) { - FlushStateIfDirty(); + FlushStateIfDirty(true); } if (_topScope != null && _topScope.IsSelectableOwnedAndValid(selectedObject)) @@ -334,12 +326,54 @@ namespace UnityEngine.UI private void OnInputModeChanged(UXInputMode mode) { - _cursorPolicy?.OnInputModeChanged(mode, _topScope); + UXNavigationScope previousTopScope = _topScope; if (mode == UXInputMode.Gamepad || mode == UXInputMode.Keyboard) { - FlushStateIfDirty(); - EnsureNavigationSelection(); + FlushStateIfDirty(false); } + + NotifyContextIfChanged(previousTopScope, _topScope); + } + + private void SetTopScope(UXNavigationScope topScope, bool notifyContext) + { + if (ReferenceEquals(_topScope, topScope)) + { + return; + } + + UXNavigationScope previousTopScope = _topScope; + _topScope = topScope; + _suppressionDirty = true; + if (notifyContext) + { + NotifyContextIfChanged(previousTopScope, _topScope); + } + else + { + QueueContextNotification(previousTopScope); + } + } + + private void QueueContextNotification(UXNavigationScope previousTopScope) + { + if (!_contextNotificationDirty) + { + _pendingPreviousTopScope = previousTopScope; + _contextNotificationDirty = true; + } + } + + private void NotifyContextIfChanged(UXNavigationScope previousTopScope, UXNavigationScope currentTopScope) + { + if (_contextNotificationDirty) + { + previousTopScope = _pendingPreviousTopScope; + _pendingPreviousTopScope = null; + _contextNotificationDirty = false; + } + + _cursorPolicy?.OnNavigationContextChanged(UXInputModeService.CurrentMode, previousTopScope, currentTopScope); } private static bool IsHigherPriority(UXNavigationScope left, UXNavigationScope right) @@ -361,10 +395,10 @@ namespace UnityEngine.UI return left.ActivationSerial > right.ActivationSerial; } - private static void ReportCapacityExceeded(string message) + private static void ReportCapacityExceeded() { #if UNITY_EDITOR || DEVELOPMENT_BUILD - Debug.LogError(message); + Debug.LogError("UXNavigationRuntime scope capacity exceeded."); #endif } } diff --git a/Runtime/UXComponent/Navigation/UXNavigationScope.cs b/Runtime/UXComponent/Navigation/UXNavigationScope.cs index da540d4..577ee6a 100644 --- a/Runtime/UXComponent/Navigation/UXNavigationScope.cs +++ b/Runtime/UXComponent/Navigation/UXNavigationScope.cs @@ -1,4 +1,4 @@ -#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION +#if INPUTSYSTEM_SUPPORT && UX_NAVIGATION using AlicizaX.UI.Runtime; using UnityEngine.EventSystems; @@ -30,6 +30,14 @@ namespace UnityEngine.UI private bool _cachedIsSkipped; private bool _isSkippedCacheValid; private int _runtimeSelectableCount; + private int[] _bakedSelectableHashIds = System.Array.Empty(); + private int[] _bakedSelectableHashIndices = System.Array.Empty(); + private int[] _runtimeSelectableHashIds = System.Array.Empty(); + private int[] _runtimeSelectableHashIndices = System.Array.Empty(); + private bool _selectableSetDirty = true; + private bool _selectableAvailabilityDirty = true; + private int _availableSelectableCount; + private Selectable _firstAvailableSelectable; internal int RuntimeIndex { get; set; } = InvalidIndex; internal ulong ActivationSerial { get; set; } @@ -45,6 +53,7 @@ namespace UnityEngine.UI set { _defaultSelectable = value; + MarkSelectableAvailabilityDirty(); MarkRuntimeStateDirty(); } } @@ -59,7 +68,7 @@ namespace UnityEngine.UI { if (!_isSkippedCacheValid) { - _cachedIsSkipped = GetComponentInParent(true) != null; + _cachedIsSkipped = HasSkipInParents(); _isSkippedCacheValid = true; } @@ -67,6 +76,22 @@ namespace UnityEngine.UI } } + private bool HasSkipInParents() + { + Transform current = transform; + while (current != null) + { + if (current.TryGetComponent(out UXNavigationSkip skip)) + { + return true; + } + + current = current.parent; + } + + return false; + } + internal Canvas Canvas { get @@ -99,14 +124,14 @@ namespace UnityEngine.UI private void Awake() { - EnsureRuntimeBuffers(); - CaptureAllBaselines(); + EnsureRuntimeBuffers(false); + RefreshBaselineWhenUnsuppressed(); } private void OnEnable() { - EnsureRuntimeBuffers(); - CaptureAllBaselines(); + EnsureRuntimeBuffers(false); + RefreshBaselineWhenUnsuppressed(); UXNavigationRuntime.EnsureInstance().RegisterScope(this); } @@ -126,6 +151,7 @@ namespace UnityEngine.UI private void OnTransformChildrenChanged() { + MarkSelectableAvailabilityDirty(); MarkRuntimeStateDirty(); } @@ -155,21 +181,21 @@ namespace UnityEngine.UI return false; } - EnsureRuntimeBuffers(); + EnsureRuntimeBuffers(true); if (_runtimeSelectableCount >= _runtimeSelectables.Length) { ReportCapacityExceeded(); return false; } - _runtimeSelectables[_runtimeSelectableCount] = selectable; - _runtimeBaselineNavigation[_runtimeSelectableCount] = selectable.navigation; - _runtimeSelectableCount++; + int index = _runtimeSelectableCount++; + _runtimeSelectables[index] = selectable; + _runtimeBaselineNavigation[index] = selectable.navigation; + MarkSelectableSetDirty(); if (_navigationSuppressed) { - SetSelectableSuppressed(selectable, true); + SetSelectableSuppressed(selectable); } - MarkRuntimeStateDirty(); return true; } @@ -181,39 +207,51 @@ namespace UnityEngine.UI return false; } - for (int i = 0; i < _runtimeSelectableCount; i++) + int index = FindRuntimeIndex(selectable); + if (index < 0) { - if (_runtimeSelectables[i] != selectable) - { - continue; - } - - if (_navigationSuppressed) - { - selectable.navigation = _runtimeBaselineNavigation[i]; - } - - int last = _runtimeSelectableCount - 1; - _runtimeSelectables[i] = _runtimeSelectables[last]; - _runtimeBaselineNavigation[i] = _runtimeBaselineNavigation[last]; - _runtimeSelectables[last] = null; - _runtimeBaselineNavigation[last] = default(Navigation); - _runtimeSelectableCount--; - if (_lastSelected == selectable) - { - _lastSelected = null; - } - - MarkRuntimeStateDirty(); - return true; + return false; } - return false; + if (_navigationSuppressed) + { + selectable.navigation = _runtimeBaselineNavigation[index]; + } + + int last = --_runtimeSelectableCount; + Selectable movedSelectable = _runtimeSelectables[last]; + Navigation movedNavigation = _runtimeBaselineNavigation[last]; + _runtimeSelectables[last] = null; + _runtimeBaselineNavigation[last] = default(Navigation); + if (index != last) + { + _runtimeSelectables[index] = movedSelectable; + _runtimeBaselineNavigation[index] = movedNavigation; + } + + if (_lastSelected == selectable) + { + _lastSelected = null; + } + MarkSelectableSetDirty(); + MarkRuntimeStateDirty(); + return true; } public void InvalidateSelectableCache() { - CaptureAllBaselines(); + if (_navigationSuppressed) + { + ApplySuppression(_bakedSelectables, _bakedBaselineNavigation, BakedSelectableCount, false); + ApplySuppression(_runtimeSelectables, _runtimeBaselineNavigation, _runtimeSelectableCount, false); + CaptureBaselineBeforeSuppress(); + ApplySuppression(_bakedSelectables, _bakedBaselineNavigation, BakedSelectableCount, true); + ApplySuppression(_runtimeSelectables, _runtimeBaselineNavigation, _runtimeSelectableCount, true); + } + else + { + RefreshBaselineWhenUnsuppressed(); + } MarkRuntimeStateDirty(); } @@ -260,6 +298,7 @@ namespace UnityEngine.UI internal Selectable GetPreferredSelectable() { + RefreshSelectableAvailabilityIfDirty(); if (_rememberLastSelection && IsSelectableValid(_lastSelected)) { return _lastSelected; @@ -270,25 +309,18 @@ namespace UnityEngine.UI return _defaultSelectable; } - if (!_autoSelectFirstAvailable) - { - return null; - } - - Selectable selectable = FirstUsable(_bakedSelectables, BakedSelectableCount); - if (selectable != null) - { - return selectable; - } - - return FirstUsable(_runtimeSelectables, _runtimeSelectableCount); + return _autoSelectFirstAvailable ? _firstAvailableSelectable : null; } internal bool HasAvailableSelectable() { - return IsSelectableValid(_defaultSelectable) - || FirstUsable(_bakedSelectables, BakedSelectableCount) != null - || FirstUsable(_runtimeSelectables, _runtimeSelectableCount) != null; + RefreshSelectableAvailabilityIfDirty(); + if (IsSelectableValid(_defaultSelectable)) + { + return true; + } + + return _autoSelectFirstAvailable && _availableSelectableCount > 0; } internal void RecordSelection(GameObject selectedObject) @@ -317,36 +349,78 @@ namespace UnityEngine.UI return; } - CaptureAllBaselines(); + if (suppressed) + { + CaptureBaselineBeforeSuppress(); + } + _navigationSuppressed = suppressed; ApplySuppression(_bakedSelectables, _bakedBaselineNavigation, BakedSelectableCount, suppressed); ApplySuppression(_runtimeSelectables, _runtimeBaselineNavigation, _runtimeSelectableCount, suppressed); } - private void EnsureRuntimeBuffers() + private void EnsureRuntimeBuffers(bool preserveRuntimeSelectables) { int capacity = _runtimeSelectableCapacity > 0 ? _runtimeSelectableCapacity : 0; if (_runtimeSelectables == null || _runtimeSelectables.Length != capacity) { + Selectable[] previousSelectables = _runtimeSelectables; + Navigation[] previousBaseline = _runtimeBaselineNavigation; + int previousCount = _runtimeSelectableCount; _runtimeSelectables = capacity > 0 ? new Selectable[capacity] : System.Array.Empty(); _runtimeBaselineNavigation = capacity > 0 ? new Navigation[capacity] : System.Array.Empty(); + CreateRuntimeHash(capacity); _runtimeSelectableCount = 0; + + if (preserveRuntimeSelectables && previousSelectables != null && capacity > 0) + { + int copyCount = previousCount < capacity ? previousCount : capacity; + for (int i = 0; i < copyCount; i++) + { + Selectable selectable = previousSelectables[i]; + if (selectable == null) + { + continue; + } + + _runtimeSelectables[_runtimeSelectableCount] = selectable; + _runtimeBaselineNavigation[_runtimeSelectableCount] = previousBaseline != null && i < previousBaseline.Length + ? previousBaseline[i] + : selectable.navigation; + _runtimeSelectableCount++; + } + } + + MarkSelectableSetDirty(); } int bakedCount = BakedSelectableCount; if (_bakedBaselineNavigation == null || _bakedBaselineNavigation.Length != bakedCount) { _bakedBaselineNavigation = bakedCount > 0 ? new Navigation[bakedCount] : System.Array.Empty(); + CreateBakedHash(bakedCount); + MarkSelectableSetDirty(); } } - private void CaptureAllBaselines() + private void CaptureBaselineBeforeSuppress() { - EnsureRuntimeBuffers(); + EnsureRuntimeBuffers(true); + RefreshSelectableHashesIfDirty(); CaptureBaseline(_bakedSelectables, _bakedBaselineNavigation, BakedSelectableCount); CaptureBaseline(_runtimeSelectables, _runtimeBaselineNavigation, _runtimeSelectableCount); } + private void RefreshBaselineWhenUnsuppressed() + { + if (_navigationSuppressed) + { + return; + } + + CaptureBaselineBeforeSuppress(); + } + private static void CaptureBaseline(Selectable[] selectables, Navigation[] baseline, int count) { if (selectables == null || baseline == null) @@ -381,7 +455,7 @@ namespace UnityEngine.UI if (suppressed) { - SetSelectableSuppressed(selectable, true); + SetSelectableSuppressed(selectable); } else { @@ -390,9 +464,9 @@ namespace UnityEngine.UI } } - private static void SetSelectableSuppressed(Selectable selectable, bool suppressed) + private static void SetSelectableSuppressed(Selectable selectable) { - if (selectable == null || !suppressed) + if (selectable == null) { return; } @@ -404,8 +478,15 @@ namespace UnityEngine.UI private bool ContainsSelectable(Selectable selectable) { - return IndexOf(_bakedSelectables, BakedSelectableCount, selectable) >= 0 - || IndexOf(_runtimeSelectables, _runtimeSelectableCount, selectable) >= 0; + if (selectable == null) + { + return false; + } + + RefreshSelectableHashesIfDirty(); + int instanceId = selectable.GetInstanceID(); + return FindHashIndex(_bakedSelectableHashIds, _bakedSelectableHashIndices, instanceId) >= 0 + || FindHashIndex(_runtimeSelectableHashIds, _runtimeSelectableHashIndices, instanceId) >= 0; } private bool IsSelectableValid(Selectable selectable) @@ -413,25 +494,204 @@ namespace UnityEngine.UI return IsSelectableUsable(selectable) && ContainsSelectable(selectable); } - private static Selectable FirstUsable(Selectable[] selectables, int count) + private void MarkSelectableSetDirty() + { + _selectableSetDirty = true; + MarkSelectableAvailabilityDirty(); + } + + private void MarkSelectableAvailabilityDirty() + { + _selectableAvailabilityDirty = true; + } + + public void NotifySelectableStateChanged() + { + MarkSelectableAvailabilityDirty(); + MarkRuntimeStateDirty(); + } + + private void RefreshSelectableAvailabilityIfDirty() + { + if (!_selectableAvailabilityDirty) + { + return; + } + + _availableSelectableCount = 0; + _firstAvailableSelectable = null; + AccumulateAvailableSelectables(_bakedSelectables, BakedSelectableCount); + AccumulateAvailableSelectables(_runtimeSelectables, _runtimeSelectableCount); + _selectableAvailabilityDirty = false; + } + + private void AccumulateAvailableSelectables(Selectable[] selectables, int count) { if (selectables == null) { - return null; + return; } for (int i = 0; i < count; i++) { Selectable selectable = selectables[i]; - if (IsSelectableUsable(selectable)) + if (!IsSelectableUsable(selectable)) { - return selectable; + continue; } + + if (_firstAvailableSelectable == null) + { + _firstAvailableSelectable = selectable; + } + + _availableSelectableCount++; + } + } + private void RefreshSelectableHashesIfDirty() + { + if (!_selectableSetDirty) + { + return; } - return null; + RebuildBakedHash(); + RebuildRuntimeHash(); + _selectableSetDirty = false; } + private void RebuildBakedHash() + { + ClearHash(_bakedSelectableHashIds, _bakedSelectableHashIndices); + for (int i = 0; i < BakedSelectableCount; i++) + { + Selectable selectable = _bakedSelectables[i]; + if (selectable != null) + { + AddHash(_bakedSelectableHashIds, _bakedSelectableHashIndices, selectable.GetInstanceID(), i); + } + } + } + + private void RebuildRuntimeHash() + { + ClearHash(_runtimeSelectableHashIds, _runtimeSelectableHashIndices); + for (int i = 0; i < _runtimeSelectableCount; i++) + { + Selectable selectable = _runtimeSelectables[i]; + if (selectable != null) + { + AddHash(_runtimeSelectableHashIds, _runtimeSelectableHashIndices, selectable.GetInstanceID(), i); + } + } + } + + private void CreateBakedHash(int itemCapacity) + { + int hashCapacity = GetHashCapacity(itemCapacity); + _bakedSelectableHashIds = hashCapacity > 0 ? new int[hashCapacity] : System.Array.Empty(); + _bakedSelectableHashIndices = hashCapacity > 0 ? new int[hashCapacity] : System.Array.Empty(); + } + + private void CreateRuntimeHash(int itemCapacity) + { + int hashCapacity = GetHashCapacity(itemCapacity); + _runtimeSelectableHashIds = hashCapacity > 0 ? new int[hashCapacity] : System.Array.Empty(); + _runtimeSelectableHashIndices = hashCapacity > 0 ? new int[hashCapacity] : System.Array.Empty(); + } + + private static int GetHashCapacity(int itemCapacity) + { + if (itemCapacity <= 0) + { + return 0; + } + + int hashCapacity = 1; + int required = itemCapacity << 1; + while (hashCapacity < required) + { + hashCapacity <<= 1; + } + + return hashCapacity; + } + + private static void ClearHash(int[] ids, int[] indices) + { + if (ids == null || indices == null) + { + return; + } + + for (int i = 0; i < ids.Length; i++) + { + ids[i] = 0; + indices[i] = InvalidIndex; + } + } + + private static int FindHashIndex(int[] ids, int[] indices, int instanceId) + { + if (ids == null || indices == null || ids.Length == 0 || instanceId == 0) + { + return InvalidIndex; + } + + int mask = ids.Length - 1; + int index = instanceId & mask; + for (int i = 0; i < ids.Length; i++) + { + int storedId = ids[index]; + if (storedId == 0) + { + return InvalidIndex; + } + + if (storedId == instanceId) + { + return indices[index]; + } + + index = (index + 1) & mask; + } + + return InvalidIndex; + } + + private static void AddHash(int[] ids, int[] indices, int instanceId, int selectableIndex) + { + if (ids == null || indices == null || ids.Length == 0 || instanceId == 0) + { + return; + } + + int mask = ids.Length - 1; + int index = instanceId & mask; + for (int i = 0; i < ids.Length; i++) + { + int storedId = ids[index]; + if (storedId == 0 || storedId == instanceId) + { + ids[index] = instanceId; + indices[index] = selectableIndex; + return; + } + + index = (index + 1) & mask; + } + } + + private int FindRuntimeIndex(Selectable selectable) + { + if (selectable == null) + { + return InvalidIndex; + } + + RefreshSelectableHashesIfDirty(); + return FindHashIndex(_runtimeSelectableHashIds, _runtimeSelectableHashIndices, selectable.GetInstanceID()); + } private static int IndexOf(Selectable[] selectables, int count, Selectable selectable) { if (selectables == null || selectable == null) @@ -484,3 +744,11 @@ namespace UnityEngine.UI } } #endif + + + + + + + +