using System; using System.Collections; using System.Collections.Generic; using AlicizaX.UI.Extension; using UnityEngine; using UnityEngine.Events; using UnityEngine.EventSystems; using UnityEngine.UI; [Serializable] public enum ButtonModeType { Normal, Toggle } [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(); } internal enum SelectionState { Normal, Highlighted, Pressed, Selected, Disabled, } [ExecuteInEditMode] [DisallowMultipleComponent] public class UXButton : UXSelectable, IButton, IPointerClickHandler, ISubmitHandler { #region Serialized Fields [SerializeField] private bool m_Interactable = true; [SerializeField] private ButtonModeType m_Mode; [SerializeField] private Button.ButtonClickedEvent m_OnClick = new(); [SerializeField] private TransitionData m_TransitionData = new(); [SerializeField] private List m_ChildTransitions = new(); [SerializeField] private UXGroup m_UXGroup; [SerializeField] private AudioClip hoverAudioClip; [SerializeField] private AudioClip clickAudioClip; [SerializeField] private UnityEvent m_OnValueChanged = new(); #endregion #region Private Fields [SerializeField] private SelectionState m_SelectionState = SelectionState.Normal; private bool m_IsDown; private bool m_HasExitedWhileDown; private bool _mTogSelected; private Animator _animator; 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") }, { "Highlighted", Animator.StringToHash("Highlighted") }, { "Pressed", Animator.StringToHash("Pressed") }, { "Selected", Animator.StringToHash("Selected") }, { "Disabled", Animator.StringToHash("Disabled") }, }; #endregion #region Properties private Animator Animator => _animator ? _animator : _animator = GetComponent(); public bool Interactable { get => m_Interactable; set { if (m_Interactable == value) return; m_Interactable = value; SetState(m_Interactable ? SelectionState.Normal : SelectionState.Disabled); } } public bool Selected { get => _mTogSelected; set { if ((m_Mode == ButtonModeType.Normal && value) || m_Mode == ButtonModeType.Toggle) { if (m_Mode == ButtonModeType.Toggle) _mTogSelected = !value; HandleClick(); } } } internal bool InternalTogSelected { get => _mTogSelected; set { if (_mTogSelected == value) return; _mTogSelected = value; m_OnValueChanged?.Invoke(value); // ------------- 关键修复 ------------- // 如果当前控件处于聚焦(由导航/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); } } } public Button.ButtonClickedEvent onClick { get => m_OnClick; set => m_OnClick = value; } public UnityEvent onValueChanged { get => m_OnValueChanged; set => m_OnValueChanged = value; } #endregion #region Unity Lifecycle protected override void Awake() { base.Awake(); _waitFadeDuration = new WaitForSeconds(Mathf.Max(0.01f, m_TransitionData.colors.fadeDuration)); ApplyVisualState(m_SelectionState, true); } protected override void OnDestroy() { if (_resetRoutine != null) StopCoroutine(_resetRoutine); 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 override void OnPointerDown(PointerEventData eventData) { if (!CanProcess()) return; m_IsDown = true; m_HasExitedWhileDown = false; SetState(SelectionState.Pressed); } public override void OnPointerUp(PointerEventData eventData) { if (!m_Interactable || eventData.button != PointerEventData.InputButton.Left) return; m_IsDown = false; 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 override void OnPointerEnter(PointerEventData eventData) { 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 override void OnPointerExit(PointerEventData eventData) { if (!m_Interactable) return; if (m_IsDown) { m_HasExitedWhileDown = true; 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); } public void OnPointerClick(PointerEventData eventData) { if (eventData.button != PointerEventData.InputButton.Left || !m_Interactable) return; PlayAudio(clickAudioClip); 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) { 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 (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 #region Logic private bool CanProcess() { return m_Interactable; } private bool CanProcessEnter() { return m_Interactable; } private void HandleClick() { if (m_Mode == ButtonModeType.Normal) { UISystemProfilerApi.AddMarker("Button.onClick", this); m_OnClick?.Invoke(); } else if (m_UXGroup) { m_UXGroup.NotifyButtonClicked(this); } else { InternalTogSelected = !_mTogSelected; } } private void SetState(SelectionState state) { if (m_SelectionState == state) return; m_SelectionState = state; ApplyVisualState(state, false); } private void ApplyVisualState(SelectionState state, bool instant) { ApplyTransition(m_TransitionData, state, instant); for (int i = 0; i < m_ChildTransitions.Count; i++) ApplyTransition(m_ChildTransitions[i], state, instant); } private void ApplyTransition(TransitionData data, SelectionState state, bool instant) { if (data.targetGraphic == null && data.transition != Selectable.Transition.Animation) return; (Color color, Sprite sprite, string trigger) = state switch { SelectionState.Normal => (data.colors.normalColor, data.spriteState.highlightedSprite, data.animationTriggers.normalTrigger), SelectionState.Highlighted => (data.colors.highlightedColor, data.spriteState.highlightedSprite, data.animationTriggers.highlightedTrigger), SelectionState.Pressed => (data.colors.pressedColor, data.spriteState.pressedSprite, data.animationTriggers.pressedTrigger), SelectionState.Selected => (data.colors.selectedColor, data.spriteState.selectedSprite, data.animationTriggers.selectedTrigger), SelectionState.Disabled => (data.colors.disabledColor, data.spriteState.disabledSprite, data.animationTriggers.disabledTrigger), _ => (Color.white, null, null) }; 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; } } private void TweenColor(TransitionData data, Color color, bool instant) { if (data.targetGraphic == null) return; if (Application.isPlaying) { data.targetGraphic.CrossFadeColor( color, instant ? 0f : data.colors.fadeDuration, true, true ); } else { data.targetGraphic.canvasRenderer.SetColor(color); } } private static void SwapSprite(TransitionData data, Sprite sprite) { if (data.targetGraphic is Image img) img.overrideSprite = sprite; } private void PlayAnimation(string trigger) { if (!Animator || !Animator.isActiveAndEnabled || string.IsNullOrEmpty(trigger)) return; foreach (int id in _animTriggerCache.Values) Animator.ResetTrigger(id); if (_animTriggerCache.TryGetValue(trigger, out int hash)) Animator.SetTrigger(hash); } private void PlayAudio(AudioClip clip) { if (clip && UXComponentExtensionsHelper.AudioHelper != null) UXComponentExtensionsHelper.AudioHelper.PlayAudio(clip); } #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); } } }