From 74aa459a386f517b0401bae9c32568f6ffdd0d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=80=9D=E6=B5=B7?= <1464576565@qq.com> Date: Wed, 3 Dec 2025 17:26:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EUXSelectable=20=20=E3=80=82?= =?UTF-8?q?=E5=AF=B9UXButton=E7=BB=84=E4=BB=B6=E7=9A=84=E6=89=8B=E6=9F=84?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Editor/UX/UXButtonEditor.cs | 117 +++++- Runtime/UXComponent/Button/UXButton.cs | 388 ++++++++++++++++-- Runtime/UXComponent/Group/UXGroup.cs | 3 +- Runtime/UXComponent/Selectable.meta | 3 + .../Selectable}/SetPropertyUtility.cs | 2 +- .../Selectable/SetPropertyUtility.cs.meta | 3 + .../UXComponent/Selectable/UXNavigation.cs | 86 ++++ .../Selectable/UXNavigation.cs.meta | 3 + .../UXComponent/Selectable/UXSelectable.cs | 267 ++++++++++++ .../Selectable/UXSelectable.cs.meta | 3 + Runtime/Utility.meta | 3 - Runtime/Utility/SetPropertyUtility.cs.meta | 3 - 12 files changed, 841 insertions(+), 40 deletions(-) create mode 100644 Runtime/UXComponent/Selectable.meta rename Runtime/{Utility => UXComponent/Selectable}/SetPropertyUtility.cs (96%) create mode 100644 Runtime/UXComponent/Selectable/SetPropertyUtility.cs.meta create mode 100644 Runtime/UXComponent/Selectable/UXNavigation.cs create mode 100644 Runtime/UXComponent/Selectable/UXNavigation.cs.meta create mode 100644 Runtime/UXComponent/Selectable/UXSelectable.cs create mode 100644 Runtime/UXComponent/Selectable/UXSelectable.cs.meta delete mode 100644 Runtime/Utility.meta delete mode 100644 Runtime/Utility/SetPropertyUtility.cs.meta diff --git a/Editor/UX/UXButtonEditor.cs b/Editor/UX/UXButtonEditor.cs index 71e022c..8379b1f 100644 --- a/Editor/UX/UXButtonEditor.cs +++ b/Editor/UX/UXButtonEditor.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; +using AlicizaX.UI.Extension; using AlicizaX.UI.Extension.Editor; using UnityEditor; +using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEditor.UI; using UnityEngine; @@ -19,6 +22,11 @@ internal class UXButtonEditor : Editor Event } + GUIContent m_VisualizeNavigation = EditorGUIUtility.TrTextContent("Visualize", "Show navigation flows between selectable UI elements."); + private static bool s_ShowNavigation = false; + private static string s_ShowNavigationKey = "SelectableEditor.ShowNavigation"; + private static List s_Editors = new List(); + private SerializedProperty m_Interactable; private SerializedProperty m_Mode; private SerializedProperty m_OnValueChanged; @@ -40,6 +48,8 @@ internal class UXButtonEditor : Editor private SerializedProperty hoverAudioClip; private SerializedProperty clickAudioClip; + + private SerializedProperty m_Navigation; private UXButton mTarget; private void OnEnable() @@ -54,13 +64,103 @@ internal class UXButtonEditor : Editor m_TransitionData = serializedObject.FindProperty("m_TransitionData"); m_ChildTransitions = serializedObject.FindProperty("m_ChildTransitions"); m_SelectionState = serializedObject.FindProperty("m_SelectionState"); - + m_Navigation = serializedObject.FindProperty("m_Navigation"); hoverAudioClip = serializedObject.FindProperty("hoverAudioClip"); clickAudioClip = serializedObject.FindProperty("clickAudioClip"); CreateChildTransitionList(); + + s_Editors.Add(this); + RegisterStaticOnSceneGUI(); + s_ShowNavigation = EditorPrefs.GetBool(s_ShowNavigationKey); } + protected virtual void OnDisable() + { + s_Editors.Remove(this); + RegisterStaticOnSceneGUI(); + } + + #region Navigation + + private void RegisterStaticOnSceneGUI() + { + SceneView.duringSceneGui -= StaticOnSceneGUI; + if (s_Editors.Count > 0) + SceneView.duringSceneGui += StaticOnSceneGUI; + } + + + private static void StaticOnSceneGUI(SceneView view) + { + if (!s_ShowNavigation) + return; + + UXSelectable[] selectables = UXSelectable.allSelectablesArray; + + for (int i = 0; i < selectables.Length; i++) + { + UXSelectable s = selectables[i]; + if (StageUtility.IsGameObjectRenderedByCamera(s.gameObject, Camera.current)) + DrawNavigationForSelectable(s); + } + } + + private static void DrawNavigationForSelectable(UXSelectable sel) + { + if (sel == null) + return; + + Transform transform = sel.transform; + bool active = Selection.transforms.Any(e => e == transform); + + Handles.color = new Color(1.0f, 0.6f, 0.2f, active ? 1.0f : 0.4f); + DrawNavigationArrow(-Vector2.right, sel, sel.FindSelectableOnLeft()); + DrawNavigationArrow(Vector2.up, sel, sel.FindSelectableOnUp()); + + Handles.color = new Color(1.0f, 0.9f, 0.1f, active ? 1.0f : 0.4f); + DrawNavigationArrow(Vector2.right, sel, sel.FindSelectableOnRight()); + DrawNavigationArrow(-Vector2.up, sel, sel.FindSelectableOnDown()); + } + + const float kArrowThickness = 2.5f; + const float kArrowHeadSize = 1.2f; + + private static void DrawNavigationArrow(Vector2 direction, UXSelectable fromObj, UXSelectable toObj) + { + if (fromObj == null || toObj == null) + return; + Transform fromTransform = fromObj.transform; + Transform toTransform = toObj.transform; + + Vector2 sideDir = new Vector2(direction.y, -direction.x); + Vector3 fromPoint = fromTransform.TransformPoint(GetPointOnRectEdge(fromTransform as RectTransform, direction)); + Vector3 toPoint = toTransform.TransformPoint(GetPointOnRectEdge(toTransform as RectTransform, -direction)); + float fromSize = HandleUtility.GetHandleSize(fromPoint) * 0.05f; + float toSize = HandleUtility.GetHandleSize(toPoint) * 0.05f; + fromPoint += fromTransform.TransformDirection(sideDir) * fromSize; + toPoint += toTransform.TransformDirection(sideDir) * toSize; + float length = Vector3.Distance(fromPoint, toPoint); + Vector3 fromTangent = fromTransform.rotation * direction * length * 0.3f; + Vector3 toTangent = toTransform.rotation * -direction * length * 0.3f; + + Handles.DrawBezier(fromPoint, toPoint, fromPoint + fromTangent, toPoint + toTangent, Handles.color, null, kArrowThickness); + Handles.DrawAAPolyLine(kArrowThickness, toPoint, toPoint + toTransform.rotation * (-direction - sideDir) * toSize * kArrowHeadSize); + Handles.DrawAAPolyLine(kArrowThickness, toPoint, toPoint + toTransform.rotation * (-direction + sideDir) * toSize * kArrowHeadSize); + } + + private static Vector3 GetPointOnRectEdge(RectTransform rect, Vector2 dir) + { + if (rect == null) + return Vector3.zero; + if (dir != Vector2.zero) + dir /= Mathf.Max(Mathf.Abs(dir.x), Mathf.Abs(dir.y)); + dir = rect.rect.center + Vector2.Scale(rect.rect.size, dir * 0.5f); + return dir; + } + + #endregion + private void CreateChildTransitionList() { m_ChildTransitionList = new ReorderableList(serializedObject, m_ChildTransitions, true, false, true, true); @@ -198,8 +298,23 @@ internal class UXButtonEditor : Editor m_Mode.enumValueIndex = (int)modeType; } + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.PropertyField(m_Navigation.FindPropertyRelative("m_Mode"),new GUIContent("Navigation")); + Rect toggleRect = EditorGUILayout.GetControlRect(); + toggleRect.xMin += EditorGUIUtility.labelWidth; + s_ShowNavigation = GUI.Toggle(toggleRect, s_ShowNavigation, m_VisualizeNavigation, EditorStyles.miniButton); + + + + if (EditorGUI.EndChangeCheck()) + { + EditorPrefs.SetBool(s_ShowNavigationKey, s_ShowNavigation); + SceneView.RepaintAll(); + } + var interactable = GUILayoutHelper.DrawToggle(m_Interactable.boolValue, customSkin, "Interactable"); if (interactable != m_Interactable.boolValue) { diff --git a/Runtime/UXComponent/Button/UXButton.cs b/Runtime/UXComponent/Button/UXButton.cs index 89d0082..abbdf35 100644 --- a/Runtime/UXComponent/Button/UXButton.cs +++ b/Runtime/UXComponent/Button/UXButton.cs @@ -2,7 +2,6 @@ using System; using System.Collections; using System.Collections.Generic; using AlicizaX.UI.Extension; -using AlicizaX.UI.Extension.Utility; using UnityEngine; using UnityEngine.Events; using UnityEngine.EventSystems; @@ -37,9 +36,7 @@ internal enum SelectionState [ExecuteInEditMode] [DisallowMultipleComponent] -public class UXButton : UIBehaviour, IButton, - IPointerDownHandler, IPointerUpHandler, IPointerEnterHandler, - IPointerExitHandler, IPointerClickHandler, ISubmitHandler +public class UXButton : UXSelectable, IButton, IPointerClickHandler, ISubmitHandler { #region Serialized Fields @@ -65,6 +62,13 @@ public class UXButton : UIBehaviour, IButton, private Coroutine _resetRoutine; private WaitForSeconds _waitFadeDuration; + // 静态锁(用于 normal 模式点击后的“保持 Selected”并能转移) + private static UXButton s_LockedButton = null; + + private bool m_IsFocusLocked = false; + + private bool m_IsNavFocused = false; + private static readonly Dictionary _animTriggerCache = new() { { "Normal", Animator.StringToHash("Normal") }, @@ -112,7 +116,23 @@ public class UXButton : UIBehaviour, IButton, if (_mTogSelected == value) return; _mTogSelected = value; m_OnValueChanged?.Invoke(value); - SetState(value ? SelectionState.Selected : SelectionState.Normal); + + // ------------- 关键修复 ------------- + // 如果当前控件处于聚焦(由导航/SetSelected 进入),那么视觉上应保持 Selected(无论逻辑是否为 selected)。 + // 聚焦判定使用 m_IsNavFocused(OnSelect/OnDeselect 管理)或 EventSystem.current.currentSelectedGameObject == gameObject。 + bool isEventSystemSelected = EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject; + bool isFocused = m_IsNavFocused || isEventSystemSelected; + + if (m_Mode == ButtonModeType.Toggle && isFocused) + { + // 即使逻辑值为 false,也保持 Selected 视觉,因为焦点还在该控件上 + SetState(SelectionState.Selected); + } + else + { + // 否则按逻辑恢复 Selected / Normal + SetState(value ? SelectionState.Selected : SelectionState.Normal); + } } } @@ -146,43 +166,105 @@ public class UXButton : UIBehaviour, IButton, base.OnDestroy(); } + protected override void OnSetProperty() + { + ApplyVisualState(m_SelectionState, true); + } + + public override bool IsInteractable() + { + return base.IsInteractable() && m_Interactable; + } + + #endregion + + #region Static lock helper + + private static void SetLockedButton(UXButton newLocked) + { + if (s_LockedButton == newLocked) + return; + + if (s_LockedButton != null) + { + var old = s_LockedButton; + s_LockedButton = null; + old.m_IsFocusLocked = false; + + if (old._mTogSelected && old.m_Mode == ButtonModeType.Toggle) + old.SetState(SelectionState.Selected); + else + old.SetState(SelectionState.Normal); + } + + if (newLocked != null) + { + s_LockedButton = newLocked; + s_LockedButton.m_IsFocusLocked = true; + s_LockedButton.SetState(SelectionState.Selected); + } + } + #endregion #region Pointer Handlers - public void OnPointerDown(PointerEventData eventData) + public override void OnPointerDown(PointerEventData eventData) { if (!CanProcess()) return; m_IsDown = true; + m_HasExitedWhileDown = false; SetState(SelectionState.Pressed); } - public void OnPointerUp(PointerEventData eventData) + public override void OnPointerUp(PointerEventData eventData) { if (!m_Interactable || eventData.button != PointerEventData.InputButton.Left) return; m_IsDown = false; - var newState = _mTogSelected - ? SelectionState.Selected - : m_HasExitedWhileDown - ? SelectionState.Normal - : SelectionState.Highlighted; + if (m_IsFocusLocked || (_mTogSelected && m_Mode == ButtonModeType.Toggle && navigation.mode != UXNavigation.Mode.None)) + { + SetState(SelectionState.Selected); + return; + } + + var newState = m_HasExitedWhileDown ? SelectionState.Normal : SelectionState.Highlighted; SetState(newState); } - public void OnPointerEnter(PointerEventData eventData) + public override void OnPointerEnter(PointerEventData eventData) { - if (!CanProcess()) return; + if (!CanProcessEnter()) return; m_HasExitedWhileDown = false; + + // 如果 toggle 模式并且聚焦(导航/或 EventSystem 选中),保持 Selected(不触发 Highlight) + bool isEventSystemSelected = EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject; + bool isFocused = m_IsNavFocused || isEventSystemSelected; + if (m_Mode == ButtonModeType.Toggle && isFocused) + { + SetState(SelectionState.Selected); + return; + } + + if (navigation.mode != UXNavigation.Mode.None) + { + if ((m_Mode == ButtonModeType.Normal && m_IsFocusLocked) || + (m_Mode == ButtonModeType.Toggle && _mTogSelected)) + { + SetState(SelectionState.Selected); + return; + } + } + if (m_IsDown) return; SetState(SelectionState.Highlighted); PlayAudio(hoverAudioClip); } - public void OnPointerExit(PointerEventData eventData) + public override void OnPointerExit(PointerEventData eventData) { if (!m_Interactable) return; if (m_IsDown) @@ -191,6 +273,25 @@ public class UXButton : UIBehaviour, IButton, return; } + // 聚焦时保持 Selected(不回退到 Normal) + bool isEventSystemSelected = EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject; + bool isFocused = m_IsNavFocused || isEventSystemSelected; + if (m_Mode == ButtonModeType.Toggle && isFocused) + { + SetState(SelectionState.Selected); + return; + } + + if (navigation.mode != UXNavigation.Mode.None) + { + if ((m_Mode == ButtonModeType.Normal && m_IsFocusLocked) || + (m_Mode == ButtonModeType.Toggle && _mTogSelected)) + { + SetState(SelectionState.Selected); + return; + } + } + SetState(_mTogSelected ? SelectionState.Selected : SelectionState.Normal); } @@ -200,17 +301,226 @@ public class UXButton : UIBehaviour, IButton, return; PlayAudio(clickAudioClip); - HandleClick(); + + if (m_Mode == ButtonModeType.Normal) + { + if (navigation.mode != UXNavigation.Mode.None) + { + if (EventSystem.current != null) + EventSystem.current.SetSelectedGameObject(gameObject, eventData); + + SetLockedButton(this); + + UISystemProfilerApi.AddMarker("Button.onClick", this); + m_OnClick?.Invoke(); + } + else + { + UISystemProfilerApi.AddMarker("Button.onClick", this); + m_OnClick?.Invoke(); + } + } + else + { + HandleClick(); + + if (navigation.mode != UXNavigation.Mode.None) + { + if (EventSystem.current != null) + EventSystem.current.SetSelectedGameObject(gameObject, eventData); + SetLockedButton(this); + } + } } + #endregion + + #region Submit handling (Keyboard Submit) + public void OnSubmit(BaseEventData eventData) { - SetState(SelectionState.Pressed); + if (_resetRoutine != null) + { + StopCoroutine(_resetRoutine); + _resetRoutine = null; + } + + if (Animator) + { + foreach (int id in _animTriggerCache.Values) + Animator.ResetTrigger(id); + } + + if (EventSystem.current != null) + EventSystem.current.SetSelectedGameObject(gameObject, eventData); + + m_IsDown = true; + m_HasExitedWhileDown = false; + ForceSetState(SelectionState.Pressed); + + + PlayAudio(clickAudioClip); + + + if (m_Mode == ButtonModeType.Toggle) + { + _resetRoutine = StartCoroutine(SubmitToggleDeferredRoutine()); + return; + } + HandleClick(); - if (_resetRoutine != null) - StopCoroutine(_resetRoutine); - _resetRoutine = StartCoroutine(ResetAfterSubmit()); + if (navigation.mode != UXNavigation.Mode.None) + SetLockedButton(this); + + if (navigation.mode != UXNavigation.Mode.None && m_Mode == ButtonModeType.Normal) + _resetRoutine = StartCoroutine(SubmitAndLockRoutine()); + else + _resetRoutine = StartCoroutine(ResetAfterSubmit()); + } + + #endregion + + #region Selection / Navigation handling + + public override void OnSelect(BaseEventData eventData) + { + base.OnSelect(eventData); + + m_IsNavFocused = true; + + if (s_LockedButton != null && s_LockedButton != this) + { + SetLockedButton(this); + return; + } + + // 聚焦时(无论逻辑是否选中),Toggle 显示 Selected + if (m_Mode == ButtonModeType.Toggle) + { + SetState(SelectionState.Selected); + return; + } + + if (m_IsDown) + { + SetState(SelectionState.Pressed); + } + else + { + if (m_IsFocusLocked) + SetState(SelectionState.Selected); + else + SetState(SelectionState.Highlighted); + } + } + + public override void OnDeselect(BaseEventData eventData) + { + base.OnDeselect(eventData); + + m_IsNavFocused = false; + + bool selectionIsNull = EventSystem.current == null || EventSystem.current.currentSelectedGameObject == null; + if (selectionIsNull) + { + if (s_LockedButton == this) + s_LockedButton = null; + m_IsFocusLocked = false; + } + + // Toggle 模式:如果逻辑上已选中则保持 Selected,否则恢复 Normal + if (m_Mode == ButtonModeType.Toggle) + { + if (_mTogSelected) + SetState(SelectionState.Selected); + else + SetState(SelectionState.Normal); + + return; + } + + if (m_Mode == ButtonModeType.Normal && _mTogSelected) + { + SetState(SelectionState.Selected); + return; + } + + SetState(SelectionState.Normal); + } + + #endregion + + #region Coroutines for submit/reset + + private IEnumerator SubmitAndLockRoutine() + { + yield return _waitFadeDuration; + + m_IsDown = false; + m_HasExitedWhileDown = false; + + if (Animator) + { + foreach (int id in _animTriggerCache.Values) + Animator.ResetTrigger(id); + } + + SetLockedButton(this); + ApplyVisualState(SelectionState.Selected, false); + + _resetRoutine = null; + } + + private IEnumerator SubmitToggleDeferredRoutine() + { + yield return null; + + + yield return _waitFadeDuration; + + + HandleClick(); + + + if (Animator) + { + foreach (int id in _animTriggerCache.Values) + Animator.ResetTrigger(id); + } + + m_IsDown = false; + m_HasExitedWhileDown = false; + + + bool stillFocused = EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject; + if (stillFocused || m_IsNavFocused) + SetState(SelectionState.Selected); + else + SetState(_mTogSelected ? SelectionState.Selected : SelectionState.Normal); + + + if (navigation.mode != UXNavigation.Mode.None) + SetLockedButton(this); + + _resetRoutine = null; + } + + private IEnumerator ResetAfterSubmit() + { + yield return _waitFadeDuration; + SetState(_mTogSelected ? SelectionState.Selected : SelectionState.Normal); + _resetRoutine = null; + } + + #endregion + + #region Utility + + private void ForceSetState(SelectionState state) + { + m_SelectionState = state; + ApplyVisualState(state, false); } #endregion @@ -219,8 +529,12 @@ public class UXButton : UIBehaviour, IButton, private bool CanProcess() { - return m_Interactable && - !(m_Mode == ButtonModeType.Toggle && Selected); + return m_Interactable; + } + + private bool CanProcessEnter() + { + return m_Interactable; } private void HandleClick() @@ -236,7 +550,7 @@ public class UXButton : UIBehaviour, IButton, } else { - InternalTogSelected = !Selected; + InternalTogSelected = !_mTogSelected; } } @@ -285,6 +599,7 @@ public class UXButton : UIBehaviour, IButton, private void TweenColor(TransitionData data, Color color, bool instant) { + if (data.targetGraphic == null) return; if (Application.isPlaying) { data.targetGraphic.CrossFadeColor( @@ -318,19 +633,30 @@ public class UXButton : UIBehaviour, IButton, Animator.SetTrigger(hash); } - private void PlayAudio(AudioClip clip) { if (clip && UXComponentExtensionsHelper.AudioHelper != null) UXComponentExtensionsHelper.AudioHelper.PlayAudio(clip); } - private IEnumerator ResetAfterSubmit() - { - yield return _waitFadeDuration; - SetState(_mTogSelected ? SelectionState.Selected : SelectionState.Normal); - _resetRoutine = null; - } - #endregion + + + public void Focus() + { + if (!IsInteractable()) + return; + + if (EventSystem.current != null) + { + EventSystem.current.SetSelectedGameObject(gameObject, new BaseEventData(EventSystem.current)); + } + + m_IsNavFocused = true; + + if ((s_LockedButton != null && s_LockedButton != this) || s_LockedButton == null) + { + SetLockedButton(this); + } + } } diff --git a/Runtime/UXComponent/Group/UXGroup.cs b/Runtime/UXComponent/Group/UXGroup.cs index 73183fb..8c25022 100644 --- a/Runtime/UXComponent/Group/UXGroup.cs +++ b/Runtime/UXComponent/Group/UXGroup.cs @@ -24,7 +24,7 @@ public class UXGroup : UIBehaviour } } - protected override void Awake() => ValidateGroup(); + protected override void Start() => ValidateGroup(); protected override void OnDestroy() { @@ -88,6 +88,7 @@ public class UXGroup : UIBehaviour if (select) _current = btn; } + if (_current) _current.Focus(); if (previous != _current) onSelectedChanged?.Invoke(_current); } diff --git a/Runtime/UXComponent/Selectable.meta b/Runtime/UXComponent/Selectable.meta new file mode 100644 index 0000000..115fb10 --- /dev/null +++ b/Runtime/UXComponent/Selectable.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 108cd2782bc4471ba364fb155525cec7 +timeCreated: 1764742229 \ No newline at end of file diff --git a/Runtime/Utility/SetPropertyUtility.cs b/Runtime/UXComponent/Selectable/SetPropertyUtility.cs similarity index 96% rename from Runtime/Utility/SetPropertyUtility.cs rename to Runtime/UXComponent/Selectable/SetPropertyUtility.cs index be40dcd..2f712ed 100644 --- a/Runtime/Utility/SetPropertyUtility.cs +++ b/Runtime/UXComponent/Selectable/SetPropertyUtility.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using UnityEngine; -namespace AlicizaX.UI.Extension.Utility +namespace AlicizaX.UI.Extension { internal static class SetPropertyUtility { diff --git a/Runtime/UXComponent/Selectable/SetPropertyUtility.cs.meta b/Runtime/UXComponent/Selectable/SetPropertyUtility.cs.meta new file mode 100644 index 0000000..bb0ed62 --- /dev/null +++ b/Runtime/UXComponent/Selectable/SetPropertyUtility.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d3d40fd183ef410e995334bdca8e4638 +timeCreated: 1764668263 \ No newline at end of file diff --git a/Runtime/UXComponent/Selectable/UXNavigation.cs b/Runtime/UXComponent/Selectable/UXNavigation.cs new file mode 100644 index 0000000..861f286 --- /dev/null +++ b/Runtime/UXComponent/Selectable/UXNavigation.cs @@ -0,0 +1,86 @@ +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace AlicizaX.UI.Extension +{ + [Serializable] + public struct UXNavigation : IEquatable + { + [Flags] + public enum Mode + { + None = 0, + Horizontal = 1, + Vertical = 2, + Automatic = 3, + Explicit = 4, + } + + [SerializeField] private Mode m_Mode; + + [Tooltip("Enables navigation to wrap around from last to first or first to last element. Does not work for automatic grid navigation")] [HideInInspector] [SerializeField] + private bool m_WrapAround; + + [HideInInspector] [SerializeField] private UXSelectable m_SelectOnUp; + [HideInInspector] [SerializeField] private UXSelectable m_SelectOnDown; + [HideInInspector] [SerializeField] private UXSelectable m_SelectOnLeft; + [HideInInspector] [SerializeField] private UXSelectable m_SelectOnRight; + + public Mode mode + { + get { return m_Mode; } + set { m_Mode = value; } + } + + public bool wrapAround + { + get { return m_WrapAround; } + set { m_WrapAround = value; } + } + + public UXSelectable selectOnUp + { + get { return m_SelectOnUp; } + set { m_SelectOnUp = value; } + } + + public UXSelectable selectOnDown + { + get { return m_SelectOnDown; } + set { m_SelectOnDown = value; } + } + + public UXSelectable selectOnLeft + { + get { return m_SelectOnLeft; } + set { m_SelectOnLeft = value; } + } + + public UXSelectable selectOnRight + { + get { return m_SelectOnRight; } + set { m_SelectOnRight = value; } + } + + static public UXNavigation defaultNavigation + { + get + { + var defaultNav = new UXNavigation(); + defaultNav.m_Mode = Mode.Automatic; + defaultNav.m_WrapAround = false; + return defaultNav; + } + } + + public bool Equals(UXNavigation other) + { + return mode == other.mode && + selectOnUp == other.selectOnUp && + selectOnDown == other.selectOnDown && + selectOnLeft == other.selectOnLeft && + selectOnRight == other.selectOnRight; + } + } +} diff --git a/Runtime/UXComponent/Selectable/UXNavigation.cs.meta b/Runtime/UXComponent/Selectable/UXNavigation.cs.meta new file mode 100644 index 0000000..d9c0cb2 --- /dev/null +++ b/Runtime/UXComponent/Selectable/UXNavigation.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 46a321e196774407b8f17943e7b55c63 +timeCreated: 1764667489 \ No newline at end of file diff --git a/Runtime/UXComponent/Selectable/UXSelectable.cs b/Runtime/UXComponent/Selectable/UXSelectable.cs new file mode 100644 index 0000000..25f58f9 --- /dev/null +++ b/Runtime/UXComponent/Selectable/UXSelectable.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.EventSystems; + +namespace AlicizaX.UI.Extension +{ + public class UXSelectable : + UIBehaviour, + IMoveHandler, + IPointerDownHandler, IPointerUpHandler, + IPointerEnterHandler, IPointerExitHandler, + ISelectHandler, IDeselectHandler + { + protected static UXSelectable[] s_Selectables = new UXSelectable[10]; + protected static int s_SelectableCount = 0; + protected int m_CurrentIndex = -1; + protected bool m_EnableCalled = false; + + protected bool isPointerInside { get; set; } + protected bool isPointerDown { get; set; } + protected bool hasSelection { get; set; } + + protected bool m_GroupsAllowInteraction = true; + private readonly List m_CanvasGroupCache = new List(); + + [SerializeField] private UXNavigation m_Navigation = UXNavigation.defaultNavigation; + public UXNavigation navigation + { + get { return m_Navigation; } + set { if (SetPropertyUtility.SetStruct(ref m_Navigation, value)) OnSetProperty(); } + } + public static UXSelectable[] allSelectablesArray + { + get + { + UXSelectable[] temp = new UXSelectable[s_SelectableCount]; + Array.Copy(s_Selectables, temp, s_SelectableCount); + return temp; + } + } + + + protected virtual void OnSetProperty() + { + // override if needed + } + + protected override void OnEnable() + { + if (m_EnableCalled) + return; + + base.OnEnable(); + + if (s_SelectableCount == s_Selectables.Length) + { + UXSelectable[] temp = new UXSelectable[s_Selectables.Length * 2]; + Array.Copy(s_Selectables, temp, s_Selectables.Length); + s_Selectables = temp; + } + + if (EventSystem.current && EventSystem.current.currentSelectedGameObject == gameObject) + { + hasSelection = true; + } + + m_CurrentIndex = s_SelectableCount; + s_Selectables[m_CurrentIndex] = this; + s_SelectableCount++; + isPointerDown = false; + m_GroupsAllowInteraction = ParentGroupAllowsInteraction(); + + m_EnableCalled = true; + } + + protected override void OnDisable() + { + if (!m_EnableCalled) + return; + + s_SelectableCount--; + + s_Selectables[s_SelectableCount].m_CurrentIndex = m_CurrentIndex; + s_Selectables[m_CurrentIndex] = s_Selectables[s_SelectableCount]; + s_Selectables[s_SelectableCount] = null; + + base.OnDisable(); + m_EnableCalled = false; + } + + protected void OnCanvasGroupChanged() + { + var parentGroupAllows = ParentGroupAllowsInteraction(); + if (parentGroupAllows != m_GroupsAllowInteraction) + { + m_GroupsAllowInteraction = parentGroupAllows; + OnSetProperty(); + } + } + + protected bool ParentGroupAllowsInteraction() + { + Transform t = transform; + while (t != null) + { + t.GetComponents(m_CanvasGroupCache); + for (var i = 0; i < m_CanvasGroupCache.Count; i++) + { + if (m_CanvasGroupCache[i].enabled && !m_CanvasGroupCache[i].interactable) + return false; + if (m_CanvasGroupCache[i].ignoreParentGroups) + return true; + } + + t = t.parent; + } + + return true; + } + + public virtual bool IsInteractable() + { + return m_GroupsAllowInteraction; + } + + public UXSelectable FindSelectable(Vector3 dir) + { + dir = dir.normalized; + Vector3 localDir = Quaternion.Inverse(transform.rotation) * dir; + Vector3 pos = transform.TransformPoint(GetPointOnRectEdge(transform as RectTransform, localDir)); + float maxScore = Mathf.NegativeInfinity; + float maxFurthestScore = Mathf.NegativeInfinity; + float score = 0; + + bool wantsWrapAround = navigation.wrapAround && (m_Navigation.mode == UXNavigation.Mode.Vertical || m_Navigation.mode == UXNavigation.Mode.Horizontal); + + UXSelectable bestPick = null; + UXSelectable bestFurthestPick = null; + + for (int i = 0; i < s_SelectableCount; ++i) + { + UXSelectable sel = s_Selectables[i]; + + if (sel == this) + continue; + + if (!sel.IsInteractable() || sel.navigation.mode == UXNavigation.Mode.None) + continue; + +#if UNITY_EDITOR + if (Camera.current != null && !UnityEditor.SceneManagement.StageUtility.IsGameObjectRenderedByCamera(sel.gameObject, Camera.current)) + continue; +#endif + + var selRect = sel.transform as RectTransform; + Vector3 selCenter = selRect != null ? (Vector3)selRect.rect.center : Vector3.zero; + Vector3 myVector = sel.transform.TransformPoint(selCenter) - pos; + + float dot = Vector3.Dot(dir, myVector); + + if (wantsWrapAround && dot < 0) + { + score = -dot * myVector.sqrMagnitude; + if (score > maxFurthestScore) + { + maxFurthestScore = score; + bestFurthestPick = sel; + } + continue; + } + + if (dot <= 0) + continue; + + score = dot / myVector.sqrMagnitude; + + if (score > maxScore) + { + maxScore = score; + bestPick = sel; + } + } + + if (wantsWrapAround && null == bestPick) return bestFurthestPick; + + return bestPick; + } + + private static Vector3 GetPointOnRectEdge(RectTransform rect, Vector2 dir) + { + if (rect == null) return Vector3.zero; + if (dir != Vector2.zero) dir /= Mathf.Max(Mathf.Abs(dir.x), Mathf.Abs(dir.y)); + dir = rect.rect.center + Vector2.Scale(rect.rect.size, dir * 0.5f); + return dir; + } + + void Navigate(AxisEventData eventData, UXSelectable sel) + { + if (sel != null && sel.IsActive()) + eventData.selectedObject = sel.gameObject; + } + + public virtual UXSelectable FindSelectableOnLeft() + { + if (m_Navigation.mode == UXNavigation.Mode.Explicit) + return m_Navigation.selectOnLeft; + if ((m_Navigation.mode & UXNavigation.Mode.Horizontal) != 0) + return FindSelectable(transform.rotation * Vector3.left); + return null; + } + + public virtual UXSelectable FindSelectableOnRight() + { + if (m_Navigation.mode == UXNavigation.Mode.Explicit) + return m_Navigation.selectOnRight; + if ((m_Navigation.mode & UXNavigation.Mode.Horizontal) != 0) + return FindSelectable(transform.rotation * Vector3.right); + return null; + } + + public virtual UXSelectable FindSelectableOnUp() + { + if (m_Navigation.mode == UXNavigation.Mode.Explicit) + return m_Navigation.selectOnUp; + if ((m_Navigation.mode & UXNavigation.Mode.Vertical) != 0) + return FindSelectable(transform.rotation * Vector3.up); + return null; + } + + public virtual UXSelectable FindSelectableOnDown() + { + if (m_Navigation.mode == UXNavigation.Mode.Explicit) + return m_Navigation.selectOnDown; + if ((m_Navigation.mode & UXNavigation.Mode.Vertical) != 0) + return FindSelectable(transform.rotation * Vector3.down); + return null; + } + + public virtual void OnMove(AxisEventData eventData) + { + switch (eventData.moveDir) + { + case MoveDirection.Right: + Navigate(eventData, FindSelectableOnRight()); + break; + case MoveDirection.Up: + Navigate(eventData, FindSelectableOnUp()); + break; + case MoveDirection.Left: + Navigate(eventData, FindSelectableOnLeft()); + break; + case MoveDirection.Down: + Navigate(eventData, FindSelectableOnDown()); + break; + } + } + + // default handlers to be overridden + public virtual void OnPointerDown(PointerEventData eventData) { } + public virtual void OnPointerUp(PointerEventData eventData) { } + public virtual void OnPointerEnter(PointerEventData eventData) { } + public virtual void OnPointerExit(PointerEventData eventData) { } + public virtual void OnSelect(BaseEventData eventData) { hasSelection = true; } + public virtual void OnDeselect(BaseEventData eventData) { hasSelection = false; } + } +} diff --git a/Runtime/UXComponent/Selectable/UXSelectable.cs.meta b/Runtime/UXComponent/Selectable/UXSelectable.cs.meta new file mode 100644 index 0000000..fbd51a2 --- /dev/null +++ b/Runtime/UXComponent/Selectable/UXSelectable.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0e3d476361ae4f1ea2e5143663661f3c +timeCreated: 1764667719 \ No newline at end of file diff --git a/Runtime/Utility.meta b/Runtime/Utility.meta deleted file mode 100644 index 639970d..0000000 --- a/Runtime/Utility.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 2997e0ea45404b2bad0969025eb613a5 -timeCreated: 1760407867 \ No newline at end of file diff --git a/Runtime/Utility/SetPropertyUtility.cs.meta b/Runtime/Utility/SetPropertyUtility.cs.meta deleted file mode 100644 index 0611d0c..0000000 --- a/Runtime/Utility/SetPropertyUtility.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: baab95df60ce49af96e3b0efcf82ed30 -timeCreated: 1760407873 \ No newline at end of file