using System; using System.Collections.Generic; using UnityEngine.EventSystems; namespace UnityEngine.UI { [Serializable] public class TransitionData { public Graphic targetGraphic; public Selectable.Transition transition = Selectable.Transition.ColorTint; public ColorBlock colors = ColorBlock.defaultColorBlock; public SpriteState spriteState; public AnimationTriggers animationTriggers = new AnimationTriggers(); } [ExecuteAlways] [SelectionBase] [DisallowMultipleComponent] public class UXSelectable : UIBehaviour, IMoveHandler, IPointerDownHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler, ISelectHandler, IDeselectHandler { [Serializable] public enum SelectionState { Normal, Highlighted, Pressed, Selected, Disabled, } #region Fields [SerializeField] protected UXNavigation m_Navigation = UXNavigation.defaultNavigation; [SerializeField] protected bool m_Interactable = true; [SerializeField] protected TransitionData m_MainTransition = new TransitionData(); // current visual / logical selection state (now in base) [SerializeField] protected SelectionState m_SelectionState = SelectionState.Normal; // runtime event flags (used by currentSelectionState) 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(); // registry for navigation find functions protected static UXSelectable[] s_Selectables = new UXSelectable[10]; protected static int s_SelectableCount = 0; protected int m_CurrentIndex = -1; protected bool m_EnableCalled = false; [SerializeField] private Animator _animator; public Animator Animator => _animator ? _animator : _animator = GetComponent(); public static readonly Dictionary _animTriggerCache = new() { { "Normal", Animator.StringToHash("Normal") }, { "Highlighted", Animator.StringToHash("Highlighted") }, { "Pressed", Animator.StringToHash("Pressed") }, { "Selected", Animator.StringToHash("Selected") }, { "Disabled", Animator.StringToHash("Disabled") }, }; public Graphic targetGraphic { get { return m_MainTransition.targetGraphic; } set { if (SetPropertyUtility.SetClass(ref m_MainTransition.targetGraphic, value)) OnSetProperty(); } } #endregion #region Properties public UXNavigation navigation { get { return m_Navigation; } set { if (SetPropertyUtility.SetStruct(ref m_Navigation, value)) OnSetProperty(); } } public bool Interactable { get => m_Interactable; set { if (m_Interactable == value) return; m_Interactable = value; OnSetProperty(); } } public static UXSelectable[] allSelectablesArray { get { UXSelectable[] temp = new UXSelectable[s_SelectableCount]; Array.Copy(s_Selectables, temp, s_SelectableCount); return temp; } } #endregion #region Unity Lifecycle protected override void Awake() { base.Awake(); // nothing specific here; subclasses may initialize visuals } 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; // Ensure visual state matches current settings immediately OnSetProperty(); } 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; InstantClearState(); base.OnDisable(); m_EnableCalled = false; } protected void OnCanvasGroupChanged() { var parentGroupAllows = ParentGroupAllowsInteraction(); if (parentGroupAllows != m_GroupsAllowInteraction) { m_GroupsAllowInteraction = parentGroupAllows; OnSetProperty(); } } #endregion #region Groups & Interactable 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 && m_Interactable; } #endregion #region Property change handling & SelectionState API /// /// Called when a property that affects visuals changes (navigation/interactable/animation/etc). /// protected virtual void OnSetProperty() { // If not interactable => Disabled state if (!IsInteractable()) { m_SelectionState = SelectionState.Disabled; DoStateTransition(SelectionState.Disabled, false); } else { // If previously disabled, restore to Normal if (m_SelectionState == SelectionState.Disabled) m_SelectionState = SelectionState.Normal; // Apply current selection state DoStateTransition(m_SelectionState, false); } } /// /// Computed selection state based on pointer / selection flags and interactability. /// Mirrors Unity's Selectable.currentSelectionState behavior. /// protected SelectionState currentSelectionState { get { if (!IsInteractable()) return SelectionState.Disabled; if (isPointerDown) return SelectionState.Pressed; if (hasSelection) return SelectionState.Selected; if (isPointerInside) return SelectionState.Highlighted; return SelectionState.Normal; } } /// /// Evaluate and transition to the computed selection state. Call after changing pointer/selection flags. /// protected void EvaluateAndTransitionToSelectionState() { if (!IsActive() || !IsInteractable()) return; DoStateTransition(currentSelectionState, false); } /// /// Force apply visual state immediately. /// protected void ForceSetState(SelectionState state) { m_SelectionState = state; DoStateTransition(state, true); } /// /// Clear internal state and restore visuals (used on disable / focus lost). /// protected virtual void InstantClearState() { // reset flags isPointerInside = false; isPointerDown = false; hasSelection = false; // restore visuals based on main transition's normal state if (m_MainTransition == null) return; switch (m_MainTransition.transition) { case Selectable.Transition.ColorTint: TweenColor(m_MainTransition, m_MainTransition.colors.normalColor * m_MainTransition.colors.colorMultiplier, true); break; case Selectable.Transition.SpriteSwap: SwapSprite(m_MainTransition, null); break; case Selectable.Transition.Animation: PlayAnimation(m_MainTransition.animationTriggers.normalTrigger); break; } m_SelectionState = SelectionState.Normal; } /// /// Core mapping from SelectionState -> TransitionData and execution. /// Subclasses can override PlayAnimation or ApplyVisualState for custom behavior. /// /// /// protected virtual void DoStateTransition(SelectionState state, bool instant) { if (!gameObject.activeInHierarchy) return; if (m_MainTransition == null) return; Color tintColor = Color.white; Sprite sprite = null; string trigger = null; switch (state) { case SelectionState.Normal: tintColor = m_MainTransition.colors.normalColor; sprite = m_MainTransition.spriteState.highlightedSprite; trigger = m_MainTransition.animationTriggers.normalTrigger; break; case SelectionState.Highlighted: tintColor = m_MainTransition.colors.highlightedColor; sprite = m_MainTransition.spriteState.highlightedSprite; trigger = m_MainTransition.animationTriggers.highlightedTrigger; break; case SelectionState.Pressed: tintColor = m_MainTransition.colors.pressedColor; sprite = m_MainTransition.spriteState.pressedSprite; trigger = m_MainTransition.animationTriggers.pressedTrigger; break; case SelectionState.Selected: tintColor = m_MainTransition.colors.selectedColor; sprite = m_MainTransition.spriteState.selectedSprite; trigger = m_MainTransition.animationTriggers.selectedTrigger; break; case SelectionState.Disabled: tintColor = m_MainTransition.colors.disabledColor; sprite = m_MainTransition.spriteState.disabledSprite; trigger = m_MainTransition.animationTriggers.disabledTrigger; break; } // Execute appropriate transition type switch (m_MainTransition.transition) { case Selectable.Transition.ColorTint: TweenColor(m_MainTransition, tintColor * m_MainTransition.colors.colorMultiplier, instant); break; case Selectable.Transition.SpriteSwap: SwapSprite(m_MainTransition, sprite); break; case Selectable.Transition.Animation: PlayAnimation(trigger); break; case Selectable.Transition.None: default: break; } // keep serialized state in sync m_SelectionState = state; } #endregion #region Navigation helpers 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; } public virtual UXSelectable FindSelectableOnLeft() { if (m_Navigation.mode == UXNavigation.Mode.Explicit) if (m_Navigation.selectOnLeft != null && m_Navigation.selectOnLeft.IsInteractable() && m_Navigation.mode != UXNavigation.Mode.None) 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) if (m_Navigation.selectOnRight != null && m_Navigation.selectOnRight.IsInteractable() && m_Navigation.mode != UXNavigation.Mode.None) 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) if (m_Navigation.selectOnUp != null && m_Navigation.selectOnUp.IsInteractable() && m_Navigation.mode != UXNavigation.Mode.None) 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) if (m_Navigation.selectOnDown != null && m_Navigation.selectOnDown.IsInteractable() && m_Navigation.mode != UXNavigation.Mode.None) 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; } } void Navigate(AxisEventData eventData, UXSelectable sel) { if (sel != null && sel.IsActive()) eventData.selectedObject = sel.gameObject; } #endregion #region Pointer / Select base implementations (update flags + evaluate) public virtual void OnPointerDown(PointerEventData eventData) { if (eventData.button != PointerEventData.InputButton.Left) return; if (IsInteractable() && navigation.mode != UXNavigation.Mode.None && EventSystem.current != null) EventSystem.current.SetSelectedGameObject(gameObject, eventData); isPointerDown = true; EvaluateAndTransitionToSelectionState(); } public virtual void OnPointerUp(PointerEventData eventData) { if (eventData.button != PointerEventData.InputButton.Left) return; isPointerDown = false; EvaluateAndTransitionToSelectionState(); } public virtual void OnPointerEnter(PointerEventData eventData) { isPointerInside = true; EvaluateAndTransitionToSelectionState(); } public virtual void OnPointerExit(PointerEventData eventData) { isPointerInside = false; EvaluateAndTransitionToSelectionState(); } public virtual void OnSelect(BaseEventData eventData) { hasSelection = true; EvaluateAndTransitionToSelectionState(); } public virtual void OnDeselect(BaseEventData eventData) { hasSelection = false; EvaluateAndTransitionToSelectionState(); } #endregion #region Visual transition helpers (main transition + low level ops) /// /// High-level API: apply main transition visuals (child classes can override, UXButton overrides to add child transitions). /// /// /// public virtual void ApplyVisualState(SelectionState state, bool instant) { if (m_MainTransition != null) ApplyTransition(m_MainTransition, state, instant); } /// /// Apply a single TransitionData (handles ColorTint / SpriteSwap / Animation). /// Animation triggering calls PlayAnimation (virtual). /// protected void ApplyTransition(TransitionData data, SelectionState state, bool instant) { if (data == null) return; if (data.targetGraphic == null && data.transition != Selectable.Transition.Animation) return; Color color = Color.white; Sprite sprite = null; string trigger = null; switch (state) { case SelectionState.Normal: color = data.colors.normalColor; sprite = data.spriteState.highlightedSprite; trigger = data.animationTriggers.normalTrigger; break; case SelectionState.Highlighted: color = data.colors.highlightedColor; sprite = data.spriteState.highlightedSprite; trigger = data.animationTriggers.highlightedTrigger; break; case SelectionState.Pressed: color = data.colors.pressedColor; sprite = data.spriteState.pressedSprite; trigger = data.animationTriggers.pressedTrigger; break; case SelectionState.Selected: color = data.colors.selectedColor; sprite = data.spriteState.selectedSprite; trigger = data.animationTriggers.selectedTrigger; break; case SelectionState.Disabled: color = data.colors.disabledColor; sprite = data.spriteState.disabledSprite; trigger = data.animationTriggers.disabledTrigger; break; } switch (data.transition) { case Selectable.Transition.ColorTint: TweenColor(data, color * data.colors.colorMultiplier, instant); break; case Selectable.Transition.SpriteSwap: SwapSprite(data, sprite); break; case Selectable.Transition.Animation: PlayAnimation(trigger); break; } } protected void TweenColor(TransitionData data, Color color, bool instant) { if (data == null || data.targetGraphic == null) return; data.targetGraphic.CrossFadeColor( color, instant ? 0f : data.colors.fadeDuration, true, true ); } protected static void SwapSprite(TransitionData data, Sprite sprite) { if (data == null) return; if (data.targetGraphic is Image img) img.overrideSprite = sprite; } /// /// Subclasses override this to trigger Animator triggers, etc. /// /// protected virtual void PlayAnimation(string trigger) { if (!Animator || !Animator.isActiveAndEnabled || string.IsNullOrEmpty(trigger) || !gameObject.activeInHierarchy) return; foreach (int id in _animTriggerCache.Values) Animator.ResetTrigger(id); if (_animTriggerCache.TryGetValue(trigger, out int hash)) Animator.SetTrigger(hash); } #endregion } }