This commit is contained in:
陈思海 2026-04-03 15:21:13 +08:00
parent 589da39636
commit 2177fd8334
7 changed files with 214 additions and 39 deletions

View File

@ -83,7 +83,8 @@ namespace AlicizaX.UI
itemRender.Bind(data, index); itemRender.Bind(data, index);
} }
itemRender.UpdateSelection(index == choiceIndex); bool selected = index == choiceIndex;
itemRender.SyncSelection(selected);
return; return;
} }
@ -98,7 +99,6 @@ namespace AlicizaX.UI
if (TryGetItemRender(viewHolder, out var itemRender)) if (TryGetItemRender(viewHolder, out var itemRender))
{ {
itemRender.UpdateSelection(false);
itemRender.Unbind(); itemRender.Unbind();
} }
} }
@ -373,6 +373,7 @@ namespace AlicizaX.UI
} }
if (index == choiceIndex) return; if (index == choiceIndex) return;
int previousChoice = choiceIndex;
if (choiceIndex != -1 && TryGetViewHolder(choiceIndex, out var oldHolder)) if (choiceIndex != -1 && TryGetViewHolder(choiceIndex, out var oldHolder))
{ {

View File

@ -24,6 +24,8 @@ namespace AlicizaX.UI
/// <param name="selected">是否处于选中状态。</param> /// <param name="selected">是否处于选中状态。</param>
void UpdateSelection(bool selected); void UpdateSelection(bool selected);
void SyncSelection(bool selected);
/// <summary> /// <summary>
/// 清理当前渲染实例上的绑定状态。 /// 清理当前渲染实例上的绑定状态。
/// </summary> /// </summary>
@ -76,6 +78,8 @@ namespace AlicizaX.UI
/// <param name="selected">是否处于选中状态。</param> /// <param name="selected">是否处于选中状态。</param>
internal abstract void UpdateSelectionInternal(bool selected); internal abstract void UpdateSelectionInternal(bool selected);
internal abstract void SyncSelectionInternal(bool selected);
/// <summary> /// <summary>
/// 清理当前绑定产生的临时状态。 /// 清理当前绑定产生的临时状态。
/// </summary> /// </summary>
@ -100,6 +104,11 @@ namespace AlicizaX.UI
UpdateSelectionInternal(selected); UpdateSelectionInternal(selected);
} }
void IItemRender.SyncSelection(bool selected)
{
SyncSelectionInternal(selected);
}
/// <summary> /// <summary>
/// 由框架内部调用,清理当前渲染实例的绑定状态。 /// 由框架内部调用,清理当前渲染实例的绑定状态。
/// </summary> /// </summary>
@ -229,9 +238,12 @@ namespace AlicizaX.UI
/// <param name="selected">是否处于选中状态。</param> /// <param name="selected">是否处于选中状态。</param>
internal override void UpdateSelectionInternal(bool selected) internal override void UpdateSelectionInternal(bool selected)
{ {
EnsureHolder(); SetSelectionState(selected, true);
IsSelected = selected; }
OnSelectionChanged(selected);
internal override void SyncSelectionInternal(bool selected)
{
SetSelectionState(selected, true);
} }
/// <summary> /// <summary>
@ -241,12 +253,7 @@ namespace AlicizaX.UI
{ {
if (Holder != null) if (Holder != null)
{ {
if (IsSelected) ClearSelectionState();
{
IsSelected = false;
OnSelectionChanged(false);
}
OnClear(); OnClear();
if (interactionProxy != null) if (interactionProxy != null)
{ {
@ -262,7 +269,6 @@ namespace AlicizaX.UI
CurrentIndex = -1; CurrentIndex = -1;
CurrentLayoutIndex = -1; CurrentLayoutIndex = -1;
CurrentBindingVersion = 0; CurrentBindingVersion = 0;
IsSelected = false;
} }
/// <summary> /// <summary>
@ -295,6 +301,33 @@ namespace AlicizaX.UI
OnBind(itemData, index); OnBind(itemData, index);
} }
private void SetSelectionState(bool selected, bool notify)
{
EnsureHolder();
bool previousSelected = IsSelected;
if (previousSelected == selected)
{
return;
}
IsSelected = selected;
if (notify)
{
OnSelectionChanged(IsSelected);
}
}
private void ClearSelectionState()
{
if (!IsSelected)
{
return;
}
IsSelected = false;
OnSelectionChanged(false);
}
/// <summary> /// <summary>
/// 每次当前持有者绑定到新的数据项时调用。 /// 每次当前持有者绑定到新的数据项时调用。
/// 仅用于执行数据驱动的界面刷新,例如文本、图片与状态更新。 /// 仅用于执行数据驱动的界面刷新,例如文本、图片与状态更新。

View File

@ -62,6 +62,18 @@ namespace AlicizaX.UI
return focusAnchor; 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) public void OnPointerClick(PointerEventData eventData)
{ {
if ((flags & ItemInteractionFlags.PointerClick) != 0) if ((flags & ItemInteractionFlags.PointerClick) != 0)
@ -181,6 +193,42 @@ namespace AlicizaX.UI
focusAnchor = ownedSelectable; focusAnchor = ownedSelectable;
} }
private bool TryGetFocusableSelectable(out Selectable selectable)
{
EnsureFocusAnchor();
if (IsSelectableFocusable(focusAnchor))
{
selectable = focusAnchor;
return true;
}
Selectable[] selectables = GetComponentsInChildren<Selectable>(true);
for (int i = 0; i < selectables.Length; i++)
{
Selectable candidate = selectables[i];
if (candidate == focusAnchor)
{
continue;
}
if (IsSelectableFocusable(candidate))
{
selectable = candidate;
return true;
}
}
selectable = null;
return false;
}
private static bool IsSelectableFocusable(Selectable selectable)
{
return selectable != null &&
selectable.IsActive() &&
selectable.IsInteractable();
}
private static bool RequiresSelection(ItemInteractionFlags interactionFlags) private static bool RequiresSelection(ItemInteractionFlags interactionFlags)
{ {
const ItemInteractionFlags selectionFlags = const ItemInteractionFlags selectionFlags =

View File

@ -1,3 +1,4 @@
using UnityEngine.EventSystems;
using UnityEngine.UI; using UnityEngine.UI;
namespace AlicizaX.UI namespace AlicizaX.UI
@ -12,5 +13,9 @@ namespace AlicizaX.UI
disabledNavigation.mode = Navigation.Mode.None; disabledNavigation.mode = Navigation.Mode.None;
navigation = disabledNavigation; navigation = disabledNavigation;
} }
public override void OnMove(AxisEventData eventData)
{
}
} }
} }

View File

@ -36,23 +36,37 @@ namespace AlicizaX.UI
bool isLoopSource = itemCount != realCount; bool isLoopSource = itemCount != realCount;
bool allowWrap = isLoopSource && options.Wrap; bool allowWrap = isLoopSource && options.Wrap;
int nextLayoutIndex = currentHolder.Index + step; int originalIndex = currentHolder.Index;
if (allowWrap) int currentIndex = currentHolder.Index;
{ int nextLayoutIndex = currentIndex;
nextLayoutIndex = WrapIndex(nextLayoutIndex, realCount); int maxAttempts = allowWrap ? realCount : itemCount;
}
else
{
nextLayoutIndex = Mathf.Clamp(nextLayoutIndex, 0, itemCount - 1);
}
if (!allowWrap && nextLayoutIndex == currentHolder.Index)
{
return false;
}
ScrollAlignment alignment = ResolveAlignment(direction, options.Alignment); ScrollAlignment alignment = ResolveAlignment(direction, options.Alignment);
return recyclerView.TryFocusIndex(nextLayoutIndex, options.SmoothScroll, alignment); for (int attempt = 0; attempt < maxAttempts; attempt++)
{
nextLayoutIndex += step;
if (allowWrap)
{
nextLayoutIndex = WrapIndex(nextLayoutIndex, realCount);
}
else
{
nextLayoutIndex = Mathf.Clamp(nextLayoutIndex, 0, itemCount - 1);
if (nextLayoutIndex == currentIndex)
{
break;
}
}
if (recyclerView.TryFocusIndex(nextLayoutIndex, options.SmoothScroll, alignment))
{
return true;
}
currentIndex = nextLayoutIndex;
}
recyclerView.TryFocusIndex(originalIndex, false, alignment);
return false;
} }
private int GetStep(MoveDirection direction) private int GetStep(MoveDirection direction)

View File

@ -686,7 +686,10 @@ namespace AlicizaX.UI
/// </summary> /// </summary>
/// <param name="entryDirection">焦点进入列表时的方向。</param> /// <param name="entryDirection">焦点进入列表时的方向。</param>
/// <returns>成功聚焦某个列表项时返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns> /// <returns>成功聚焦某个列表项时返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public bool TryFocusEntry(MoveDirection entryDirection) public bool TryFocusEntry(
MoveDirection entryDirection,
bool smooth = false,
ScrollAlignment alignment = ScrollAlignment.Center)
{ {
if (RecyclerViewAdapter == null) if (RecyclerViewAdapter == null)
{ {
@ -710,7 +713,8 @@ namespace AlicizaX.UI
: Mathf.Clamp(CurrentIndex, 0, realCount - 1); : Mathf.Clamp(CurrentIndex, 0, realCount - 1);
} }
return TryFocusIndex(targetIndex); int step = entryDirection is MoveDirection.Up or MoveDirection.Left ? -1 : 1;
return TryFocusIndexRange(targetIndex, step, realCount, smooth, alignment);
} }
/// <summary> /// <summary>
@ -728,14 +732,40 @@ namespace AlicizaX.UI
} }
ItemInteractionProxy proxy = holder.GetComponent<ItemInteractionProxy>(); ItemInteractionProxy proxy = holder.GetComponent<ItemInteractionProxy>();
Selectable selectable = proxy != null ? proxy.GetSelectable() : holder.GetComponent<Selectable>(); if (proxy != null)
if (selectable == null)
{ {
selectable = holder.GetComponentInChildren<Selectable>(true); return proxy.TryGetFocusTarget(out target);
} }
target = selectable != null ? selectable.gameObject : holder.gameObject; Selectable selectable = holder.GetComponent<Selectable>();
return target != null; if (!IsSelectableFocusable(selectable))
{
Selectable[] selectables = holder.GetComponentsInChildren<Selectable>(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<IMoveHandler>(holder.gameObject) ||
ExecuteEvents.CanHandleEvent<ISelectHandler>(holder.gameObject) ||
ExecuteEvents.CanHandleEvent<ISubmitHandler>(holder.gameObject))
{
target = holder.gameObject;
return true;
}
return false;
} }
/// <summary> /// <summary>
@ -795,11 +825,24 @@ namespace AlicizaX.UI
return; return;
} }
if (!ReferenceEquals(eventSystem.currentSelectedGameObject, target)) if (ReferenceEquals(eventSystem.currentSelectedGameObject, target))
{ {
eventSystem.SetSelectedGameObject(target); ScheduleFocusRecovery(target);
return;
} }
if (eventSystem.alreadySelecting)
{
ScheduleFocusRecovery(target);
return;
}
eventSystem.SetSelectedGameObject(target);
ScheduleFocusRecovery(target);
}
private void ScheduleFocusRecovery(GameObject target)
{
if (focusRecoveryCoroutine != null) if (focusRecoveryCoroutine != null)
{ {
StopCoroutine(focusRecoveryCoroutine); StopCoroutine(focusRecoveryCoroutine);
@ -808,6 +851,34 @@ namespace AlicizaX.UI
focusRecoveryCoroutine = StartCoroutine(RecoverFocusNextFrame(target)); focusRecoveryCoroutine = StartCoroutine(RecoverFocusNextFrame(target));
} }
private bool TryFocusIndexRange(int startIndex, int step, int itemCount, bool smooth, ScrollAlignment alignment)
{
if (itemCount <= 0 || step == 0)
{
return false;
}
int index = Mathf.Clamp(startIndex, 0, itemCount - 1);
while (index >= 0 && index < itemCount)
{
if (TryFocusIndex(index, smooth, alignment))
{
return true;
}
index += step;
}
return false;
}
private static bool IsSelectableFocusable(Selectable selectable)
{
return selectable != null &&
selectable.IsActive() &&
selectable.IsInteractable();
}
/// <summary> /// <summary>
/// 在下一帧尝试恢复目标对象的焦点,避免布局刷新期间焦点丢失。 /// 在下一帧尝试恢复目标对象的焦点,避免布局刷新期间焦点丢失。
/// </summary> /// </summary>

View File

@ -195,9 +195,12 @@ namespace AlicizaX.UI
return index >= 0 && TryFocus(index, smooth, alignment); return index >= 0 && TryFocus(index, smooth, alignment);
} }
public bool TryFocusEntry(MoveDirection entryDirection) public bool TryFocusEntry(
MoveDirection entryDirection,
bool smooth = false,
ScrollAlignment alignment = ScrollAlignment.Center)
{ {
return _recyclerView != null && _recyclerView.TryFocusEntry(entryDirection); return _recyclerView != null && _recyclerView.TryFocusEntry(entryDirection, smooth, alignment);
} }
public void ScrollToIndex(int index, bool smooth = false) public void ScrollToIndex(int index, bool smooth = false)