增加UXNavigation 用于适配手柄导航 适配UI框架 Scope架构

This commit is contained in:
陈思海 2026-03-20 16:46:06 +08:00
parent 4364f4673d
commit 756be161fd
12 changed files with 1023 additions and 2 deletions

View File

@ -477,7 +477,7 @@ namespace UnityEngine.UI
foreach (var scope in _scopes.Values)
{
if (!IsScopeActive(scope))
if (!IsScopeActive(scope) || !UXNavigationRuntime.IsHolderWithinTopScope(scope.Holder))
{
continue;
}
@ -492,7 +492,9 @@ namespace UnityEngine.UI
foreach (var scope in _scopes.Values)
{
if (IsScopeActive(scope) && !_ancestorHolders.Contains(scope.Holder))
if (IsScopeActive(scope)
&& UXNavigationRuntime.IsHolderWithinTopScope(scope.Holder)
&& !_ancestorHolders.Contains(scope.Holder))
{
_leafScopes.Add(scope);
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 454112a9b68b5d140aa6ba4085af80a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,10 @@
#if INPUTSYSTEM_SUPPORT
namespace UnityEngine.UI
{
public enum UXInputMode : byte
{
Pointer = 0,
Gamepad = 1
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b1d6174ce87303c419a2ca88b7d1fcc8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,169 @@
#if INPUTSYSTEM_SUPPORT
using System;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
namespace UnityEngine.UI
{
internal sealed class UXInputModeService : MonoBehaviour
{
private static UXInputModeService _instance;
private InputAction _pointerAction;
private InputAction _gamepadAction;
public static UXInputMode CurrentMode { get; private set; } = UXInputMode.Pointer;
public static event Action<UXInputMode> OnModeChanged;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Bootstrap()
{
EnsureInstance();
}
internal static UXInputModeService EnsureInstance()
{
if (_instance != null)
{
return _instance;
}
var go = new GameObject("[UXInputModeService]");
go.hideFlags = HideFlags.HideAndDontSave;
DontDestroyOnLoad(go);
_instance = go.AddComponent<UXInputModeService>();
return _instance;
}
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
hideFlags = HideFlags.HideAndDontSave;
}
private void OnEnable()
{
CreateActions();
}
private void OnDisable()
{
DisposeActions();
}
private void OnDestroy()
{
DisposeActions();
if (_instance == this)
{
_instance = null;
}
}
private void CreateActions()
{
if (_pointerAction != null || _gamepadAction != null)
{
return;
}
_pointerAction = new InputAction("UXPointerInput", InputActionType.PassThrough);
_pointerAction.AddBinding("<Keyboard>/anyKey");
_pointerAction.AddBinding("<Mouse>/delta");
_pointerAction.AddBinding("<Mouse>/scroll");
_pointerAction.AddBinding("<Mouse>/leftButton");
_pointerAction.AddBinding("<Mouse>/rightButton");
_pointerAction.AddBinding("<Mouse>/middleButton");
_pointerAction.performed += OnPointerInput;
_pointerAction.Enable();
_gamepadAction = new InputAction("UXGamepadInput", InputActionType.PassThrough);
_gamepadAction.AddBinding("<Gamepad>/*");
_gamepadAction.AddBinding("<Joystick>/*");
_gamepadAction.performed += OnGamepadInput;
_gamepadAction.Enable();
}
private void DisposeActions()
{
if (_pointerAction != null)
{
_pointerAction.performed -= OnPointerInput;
_pointerAction.Disable();
_pointerAction.Dispose();
_pointerAction = null;
}
if (_gamepadAction != null)
{
_gamepadAction.performed -= OnGamepadInput;
_gamepadAction.Disable();
_gamepadAction.Dispose();
_gamepadAction = null;
}
}
private static void OnPointerInput(InputAction.CallbackContext context)
{
if (!IsInputMeaningful(context.control))
{
return;
}
SetMode(UXInputMode.Pointer);
}
private static void OnGamepadInput(InputAction.CallbackContext context)
{
if (!IsInputMeaningful(context.control))
{
return;
}
SetMode(UXInputMode.Gamepad);
}
private static bool IsInputMeaningful(InputControl control)
{
if (control == null || control.device == null || control.synthetic)
{
return false;
}
switch (control)
{
case ButtonControl button:
return button.IsPressed();
case StickControl stick:
return stick.ReadValue().sqrMagnitude >= 0.04f;
case Vector2Control vector2:
return vector2.ReadValue().sqrMagnitude >= 0.04f;
case AxisControl axis:
return Mathf.Abs(axis.ReadValue()) >= 0.2f;
default:
return !control.noisy;
}
}
internal static void SetMode(UXInputMode mode)
{
EnsureInstance();
if (CurrentMode == mode)
{
return;
}
CurrentMode = mode;
OnModeChanged?.Invoke(mode);
}
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca8818b35acd2324198177d2fd3f359b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,20 @@
#if INPUTSYSTEM_SUPPORT
namespace UnityEngine.UI
{
[DisallowMultipleComponent]
internal sealed class UXNavigationLayerWatcher : MonoBehaviour
{
private UXNavigationRuntime _runtime;
internal void Initialize(UXNavigationRuntime runtime)
{
_runtime = runtime;
}
private void OnTransformChildrenChanged()
{
_runtime?.MarkDiscoveryDirty();
}
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0268df3dd46bb194fa4ae7ec48be7702
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,455 @@
#if INPUTSYSTEM_SUPPORT
using System.Collections.Generic;
using AlicizaX;
using AlicizaX.UI.Runtime;
using UnityEngine.EventSystems;
namespace UnityEngine.UI
{
internal sealed class UXNavigationRuntime : MonoBehaviour
{
private static UXNavigationRuntime _instance;
private const float DiscoveryInterval = 0.5f;
private readonly HashSet<UXNavigationScope> _scopeSet = new();
private readonly List<UXNavigationScope> _scopes = new(32);
private readonly HashSet<Transform> _interactiveLayerRoots = new();
private Transform _uiCanvasRoot;
private UXNavigationScope _topScope;
private GameObject _lastObservedSelection;
private float _nextDiscoveryTime;
private bool _discoveryDirty = true;
private ulong _activationSerial;
private bool _missingEventSystemLogged;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Bootstrap()
{
EnsureInstance();
UXInputModeService.EnsureInstance();
}
internal static UXNavigationRuntime EnsureInstance()
{
if (_instance != null)
{
return _instance;
}
var go = new GameObject("[UXNavigationRuntime]");
go.hideFlags = HideFlags.HideAndDontSave;
DontDestroyOnLoad(go);
_instance = go.AddComponent<UXNavigationRuntime>();
return _instance;
}
internal static bool TryGetInstance(out UXNavigationRuntime runtime)
{
runtime = _instance;
return runtime != null;
}
internal static bool IsHolderWithinTopScope(UIHolderObjectBase holder)
{
if (_instance == null || _instance._topScope == null || holder == null)
{
return true;
}
return _instance.IsHolderOwnedByScope(holder, _instance._topScope);
}
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
hideFlags = HideFlags.HideAndDontSave;
}
private void OnEnable()
{
UXInputModeService.OnModeChanged += OnInputModeChanged;
}
private void OnDisable()
{
UXInputModeService.OnModeChanged -= OnInputModeChanged;
}
private void OnDestroy()
{
UXInputModeService.OnModeChanged -= OnInputModeChanged;
if (_instance == this)
{
_instance = null;
}
}
internal void RegisterScope(UXNavigationScope scope)
{
if (scope == null || !_scopeSet.Add(scope))
{
return;
}
_scopes.Add(scope);
MarkDiscoveryDirty();
}
internal void UnregisterScope(UXNavigationScope scope)
{
if (scope == null || !_scopeSet.Remove(scope))
{
return;
}
if (_topScope == scope)
{
_topScope = null;
}
scope.SetNavigationSuppressed(false);
_scopes.Remove(scope);
MarkDiscoveryDirty();
}
internal void MarkDiscoveryDirty()
{
_discoveryDirty = true;
}
private void Update()
{
TryBindUIRoot();
if (_uiCanvasRoot == null)
{
return;
}
if (_discoveryDirty || Time.unscaledTime >= _nextDiscoveryTime)
{
DiscoverScopes();
}
UXNavigationScope newTopScope = FindTopScope();
if (!ReferenceEquals(_topScope, newTopScope))
{
_topScope = newTopScope;
}
ApplyScopeSuppression();
if (UXInputModeService.CurrentMode == UXInputMode.Gamepad)
{
EnsureGamepadSelection();
if (_topScope != null)
{
Cursor.visible = false;
}
}
TrackSelection();
}
private void TryBindUIRoot()
{
if (_uiCanvasRoot != null)
{
return;
}
IUIModule uiModule = ModuleSystem.GetModule<IUIModule>();
if (uiModule?.UICanvasRoot == null)
{
return;
}
_uiCanvasRoot = uiModule.UICanvasRoot;
CacheInteractiveLayers();
DiscoverScopes();
}
private void CacheInteractiveLayers()
{
_interactiveLayerRoots.Clear();
if (_uiCanvasRoot == null)
{
return;
}
string cacheLayerName = $"Layer{(int)UILayer.All}-{UILayer.All}";
for (int i = 0; i < _uiCanvasRoot.childCount; i++)
{
Transform child = _uiCanvasRoot.GetChild(i);
if (child == null || child.name == cacheLayerName)
{
continue;
}
_interactiveLayerRoots.Add(child);
if (child.GetComponent<UXNavigationLayerWatcher>() == null)
{
var watcher = child.gameObject.AddComponent<UXNavigationLayerWatcher>();
watcher.Initialize(this);
}
}
}
private void DiscoverScopes()
{
_discoveryDirty = false;
_nextDiscoveryTime = Time.unscaledTime + DiscoveryInterval;
CacheInteractiveLayers();
bool addedScope = false;
foreach (Transform layerRoot in _interactiveLayerRoots)
{
if (layerRoot == null)
{
continue;
}
UIHolderObjectBase[] holders = layerRoot.GetComponentsInChildren<UIHolderObjectBase>(true);
for (int i = 0; i < holders.Length; i++)
{
UIHolderObjectBase holder = holders[i];
if (holder == null || holder.GetComponent<UXNavigationScope>() != null)
{
continue;
}
holder.gameObject.AddComponent<UXNavigationScope>();
addedScope = true;
}
}
if (addedScope)
{
for (int i = 0; i < _scopes.Count; i++)
{
_scopes[i]?.InvalidateSelectableCache();
}
}
}
private UXNavigationScope FindTopScope()
{
UXNavigationScope bestScope = null;
for (int i = 0; i < _scopes.Count; i++)
{
UXNavigationScope scope = _scopes[i];
if (scope == null)
{
continue;
}
bool available = IsScopeAvailable(scope);
if (scope.WasAvailable != available)
{
scope.WasAvailable = available;
if (available)
{
scope.ActivationSerial = ++_activationSerial;
}
}
if (!available)
{
continue;
}
if (bestScope == null || CompareScopePriority(scope, bestScope) < 0)
{
bestScope = scope;
}
}
return bestScope;
}
private bool IsScopeAvailable(UXNavigationScope scope)
{
if (scope == null || !scope.isActiveAndEnabled || !scope.gameObject.activeInHierarchy)
{
return false;
}
Canvas canvas = scope.Canvas;
if (canvas == null || canvas.gameObject.layer != UIComponent.UIShowLayer)
{
return false;
}
return TryGetInteractiveLayerRoot(scope.transform, out _);
}
private bool TryGetInteractiveLayerRoot(Transform current, out Transform layerRoot)
{
layerRoot = null;
if (current == null || _uiCanvasRoot == null)
{
return false;
}
while (current != null)
{
if (current.parent == _uiCanvasRoot)
{
layerRoot = current;
return _interactiveLayerRoots.Contains(current);
}
current = current.parent;
}
return false;
}
private bool IsHolderOwnedByScope(UIHolderObjectBase holder, UXNavigationScope scope)
{
if (holder == null || scope == null)
{
return false;
}
UXNavigationScope nearestScope = holder.GetComponent<UXNavigationScope>();
if (nearestScope == null)
{
nearestScope = holder.GetComponentInParent<UXNavigationScope>(true);
}
return nearestScope == scope;
}
private void ApplyScopeSuppression()
{
for (int i = 0; i < _scopes.Count; i++)
{
UXNavigationScope scope = _scopes[i];
if (scope == null)
{
continue;
}
bool suppress = IsScopeAvailable(scope)
&& _topScope != null
&& !ReferenceEquals(scope, _topScope)
&& _topScope.BlockLowerScopes
&& CompareScopePriority(_topScope, scope) < 0;
scope.SetNavigationSuppressed(suppress);
}
}
private void EnsureGamepadSelection()
{
EventSystem eventSystem = EventSystem.current;
if (eventSystem == null)
{
if (!_missingEventSystemLogged)
{
Debug.LogWarning("UXNavigationRuntime requires an active EventSystem for gamepad navigation.");
_missingEventSystemLogged = true;
}
return;
}
_missingEventSystemLogged = false;
if (_topScope == null || !_topScope.RequireSelectionWhenGamepad)
{
return;
}
GameObject currentSelected = eventSystem.currentSelectedGameObject;
if (_topScope.IsSelectableOwnedAndValid(currentSelected))
{
_topScope.RecordSelection(currentSelected);
return;
}
Selectable preferred = _topScope.GetPreferredSelectable();
eventSystem.SetSelectedGameObject(preferred != null ? preferred.gameObject : null);
_lastObservedSelection = eventSystem.currentSelectedGameObject;
if (_lastObservedSelection != null)
{
_topScope.RecordSelection(_lastObservedSelection);
}
}
private void TrackSelection()
{
EventSystem eventSystem = EventSystem.current;
if (eventSystem == null)
{
_lastObservedSelection = null;
return;
}
GameObject currentSelected = eventSystem.currentSelectedGameObject;
if (ReferenceEquals(_lastObservedSelection, currentSelected))
{
return;
}
_lastObservedSelection = currentSelected;
if (_topScope != null && _topScope.IsSelectableOwnedAndValid(currentSelected))
{
_topScope.RecordSelection(currentSelected);
}
}
private void OnInputModeChanged(UXInputMode mode)
{
EventSystem eventSystem = EventSystem.current;
if (mode == UXInputMode.Pointer)
{
if (_topScope != null)
{
Cursor.visible = true;
if (eventSystem != null)
{
eventSystem.SetSelectedGameObject(null);
}
}
_lastObservedSelection = null;
return;
}
if (_topScope != null)
{
Cursor.visible = false;
}
EnsureGamepadSelection();
}
private static int CompareScopePriority(UXNavigationScope left, UXNavigationScope right)
{
int leftOrder = left.Canvas != null ? left.Canvas.sortingOrder : int.MinValue;
int rightOrder = right.Canvas != null ? right.Canvas.sortingOrder : int.MinValue;
int orderCompare = rightOrder.CompareTo(leftOrder);
if (orderCompare != 0)
{
return orderCompare;
}
int hierarchyCompare = right.GetHierarchyDepth().CompareTo(left.GetHierarchyDepth());
if (hierarchyCompare != 0)
{
return hierarchyCompare;
}
return right.ActivationSerial.CompareTo(left.ActivationSerial);
}
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4d755cbf1369cfc4288d7b1261c523bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,302 @@
#if INPUTSYSTEM_SUPPORT
using System.Collections.Generic;
using AlicizaX.UI.Runtime;
namespace UnityEngine.UI
{
[DisallowMultipleComponent]
[AddComponentMenu("UI/UX Navigation Scope")]
public sealed class UXNavigationScope : MonoBehaviour
{
[SerializeField, Header("默认选中控件")] private Selectable _defaultSelectable;
[SerializeField, Header("显式导航控件列表")] private List<Selectable> _explicitSelectables = new();
[SerializeField, Header("记住上次选中")] private bool _rememberLastSelection = true;
[SerializeField, Header("手柄模式必须有选中")] private bool _requireSelectionWhenGamepad = true;
[SerializeField, Header("阻断下层导航域")] private bool _blockLowerScopes = true;
[SerializeField, Header("自动选中首个可用控件")] private bool _autoSelectFirstAvailable = true;
private readonly List<Selectable> _cachedSelectables = new(16);
private readonly Dictionary<Selectable, Navigation> _baselineNavigation = new();
private readonly List<Selectable> _removeBuffer = new(8);
private Canvas _cachedCanvas;
private UIHolderObjectBase _cachedHolder;
private Selectable _lastSelected;
private bool _cacheDirty = true;
private bool _navigationSuppressed;
internal ulong ActivationSerial { get; set; }
internal bool WasAvailable { get; set; }
public Selectable DefaultSelectable
{
get => _defaultSelectable;
set => _defaultSelectable = value;
}
public bool RememberLastSelection => _rememberLastSelection;
public bool RequireSelectionWhenGamepad => _requireSelectionWhenGamepad;
public bool BlockLowerScopes => _blockLowerScopes;
internal Canvas Canvas
{
get
{
if (_cachedCanvas == null)
{
_cachedCanvas = GetComponentInParent<Canvas>(true);
}
return _cachedCanvas;
}
}
internal UIHolderObjectBase Holder
{
get
{
if (_cachedHolder == null)
{
_cachedHolder = GetComponent<UIHolderObjectBase>();
if (_cachedHolder == null)
{
_cachedHolder = GetComponentInParent<UIHolderObjectBase>(true);
}
}
return _cachedHolder;
}
}
private void OnEnable()
{
_cacheDirty = true;
UXNavigationRuntime.EnsureInstance().RegisterScope(this);
}
private void OnDisable()
{
SetNavigationSuppressed(false);
if (UXNavigationRuntime.TryGetInstance(out var runtime))
{
runtime.UnregisterScope(this);
}
}
private void OnDestroy()
{
SetNavigationSuppressed(false);
}
private void OnTransformChildrenChanged()
{
_cacheDirty = true;
}
#if UNITY_EDITOR
private void OnValidate()
{
_cacheDirty = true;
}
#endif
internal int GetHierarchyDepth()
{
int depth = 0;
Transform current = transform;
while (current != null)
{
depth++;
current = current.parent;
}
return depth;
}
internal bool Owns(GameObject target)
{
if (target == null)
{
return false;
}
var nearestScope = target.GetComponentInParent<UXNavigationScope>(true);
return nearestScope == this;
}
internal Selectable GetPreferredSelectable()
{
RefreshSelectableCache();
if (_rememberLastSelection && IsSelectableValid(_lastSelected))
{
return _lastSelected;
}
if (IsSelectableValid(_defaultSelectable))
{
return _defaultSelectable;
}
if (!_autoSelectFirstAvailable)
{
return null;
}
for (int i = 0; i < _cachedSelectables.Count; i++)
{
Selectable selectable = _cachedSelectables[i];
if (IsSelectableValid(selectable))
{
return selectable;
}
}
return null;
}
internal void RecordSelection(GameObject selectedObject)
{
if (!_rememberLastSelection || selectedObject == null)
{
return;
}
Selectable selectable = selectedObject.GetComponent<Selectable>();
if (selectable == null)
{
selectable = selectedObject.GetComponentInParent<Selectable>();
}
if (IsSelectableValid(selectable))
{
_lastSelected = selectable;
}
}
internal bool IsSelectableOwnedAndValid(GameObject selectedObject)
{
if (selectedObject == null || !Owns(selectedObject))
{
return false;
}
Selectable selectable = selectedObject.GetComponent<Selectable>();
if (selectable == null)
{
selectable = selectedObject.GetComponentInParent<Selectable>();
}
return IsSelectableValid(selectable);
}
internal void SetNavigationSuppressed(bool suppressed)
{
RefreshSelectableCache();
if (_navigationSuppressed == suppressed)
{
return;
}
_navigationSuppressed = suppressed;
for (int i = 0; i < _cachedSelectables.Count; i++)
{
Selectable selectable = _cachedSelectables[i];
if (selectable == null)
{
continue;
}
if (!_baselineNavigation.ContainsKey(selectable))
{
_baselineNavigation[selectable] = selectable.navigation;
}
if (suppressed)
{
Navigation navigation = selectable.navigation;
navigation.mode = Navigation.Mode.None;
selectable.navigation = navigation;
}
else if (_baselineNavigation.TryGetValue(selectable, out Navigation navigation))
{
selectable.navigation = navigation;
}
}
}
internal void RefreshSelectableCache()
{
if (!_cacheDirty)
{
return;
}
_cacheDirty = false;
_cachedSelectables.Clear();
if (_explicitSelectables != null && _explicitSelectables.Count > 0)
{
for (int i = 0; i < _explicitSelectables.Count; i++)
{
TryAddSelectable(_explicitSelectables[i]);
}
}
else
{
Selectable[] selectables = GetComponentsInChildren<Selectable>(true);
for (int i = 0; i < selectables.Length; i++)
{
TryAddSelectable(selectables[i]);
}
}
_removeBuffer.Clear();
foreach (var pair in _baselineNavigation)
{
if (!_cachedSelectables.Contains(pair.Key))
{
_removeBuffer.Add(pair.Key);
}
}
for (int i = 0; i < _removeBuffer.Count; i++)
{
_baselineNavigation.Remove(_removeBuffer[i]);
}
}
internal void InvalidateSelectableCache()
{
_cacheDirty = true;
}
private void TryAddSelectable(Selectable selectable)
{
if (selectable == null || !Owns(selectable.gameObject))
{
return;
}
_cachedSelectables.Add(selectable);
if (!_baselineNavigation.ContainsKey(selectable) || !_navigationSuppressed)
{
_baselineNavigation[selectable] = selectable.navigation;
}
}
private bool IsSelectableValid(Selectable selectable)
{
return selectable != null
&& selectable.IsActive()
&& selectable.IsInteractable()
&& Owns(selectable.gameObject);
}
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 85e4995cea5647b46995bb92b329207d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: