UI系统组件
This commit is contained in:
96
Assets/_Game/Scripts/UI/Controls/PooledListView.cs
Normal file
96
Assets/_Game/Scripts/UI/Controls/PooledListView.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 简单数据绑定列表项接口。视图实现后,<see cref="PooledListView{TData,TView}"/>
|
||||
/// 的默认 <c>BindItem</c> 可直接调用,无需子类重写绑定逻辑。
|
||||
/// </summary>
|
||||
public interface IPooledItemView<in TData>
|
||||
{
|
||||
void Bind(TData data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对象池数据绑定列表基类(统一 ItemInventory / CharmEquip / QuestLog 等处的手写重建模板)。
|
||||
///
|
||||
/// 用法:
|
||||
/// 1. 子类指定 TData(数据)与 TView(继承 <see cref="Component"/> 的列表项视图)。
|
||||
/// 2. 在 Inspector 设置 <see cref="_container"/>(布局父节点)与 <see cref="_template"/>(保持 inactive 的原型)。
|
||||
/// 3. 调用 <see cref="SetItems"/> 以数据集重建;视图通过 <see cref="BindItem"/> 绑定。
|
||||
///
|
||||
/// 默认绑定:TView 实现 <see cref="IPooledItemView{TData}"/> 时无需重写 <see cref="BindItem"/>;
|
||||
/// 需要传回调(如 onSelect)时重写 <see cref="BindItem"/>。
|
||||
///
|
||||
/// 非虚拟化:所有项同时存在(适合数十到上百项);超长列表后续可加虚拟化。
|
||||
/// </summary>
|
||||
public abstract class PooledListView<TData, TView> : MonoBehaviour
|
||||
where TView : Component
|
||||
{
|
||||
[Header("Pooled List")]
|
||||
[Tooltip("列表项的布局父节点(通常挂 Horizontal/VerticalLayoutGroup 或 GridLayoutGroup)。")]
|
||||
[SerializeField] protected Transform _container;
|
||||
|
||||
[Tooltip("列表项原型(保持 inactive,作为对象池模板)。")]
|
||||
[SerializeField] protected TView _template;
|
||||
|
||||
private readonly List<TView> _active = new();
|
||||
private readonly Queue<TView> _pool = new();
|
||||
|
||||
/// <summary>当前激活的列表项视图(只读)。</summary>
|
||||
public IReadOnlyList<TView> Active => _active;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
if (_template != null) _template.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>以数据集重建列表(回收旧项 → 逐项 Spawn + 绑定 → <see cref="OnRebuilt"/>)。</summary>
|
||||
public void SetItems(IEnumerable<TData> items)
|
||||
{
|
||||
RecycleAll();
|
||||
if (_container == null || _template == null || items == null) { OnRebuilt(); return; }
|
||||
|
||||
foreach (var data in items)
|
||||
{
|
||||
var view = Spawn();
|
||||
BindItem(view, data);
|
||||
_active.Add(view);
|
||||
}
|
||||
OnRebuilt();
|
||||
}
|
||||
|
||||
/// <summary>回收所有激活项到池中(隐藏)。</summary>
|
||||
public void RecycleAll()
|
||||
{
|
||||
foreach (var view in _active)
|
||||
{
|
||||
if (view == null) continue;
|
||||
view.gameObject.SetActive(false);
|
||||
_pool.Enqueue(view);
|
||||
}
|
||||
_active.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定单个视图。默认:TView 实现 <see cref="IPooledItemView{TData}"/> 时直接调用其 Bind。
|
||||
/// 需要额外参数(如选中回调)时子类重写。
|
||||
/// </summary>
|
||||
protected virtual void BindItem(TView view, TData data)
|
||||
{
|
||||
if (view is IPooledItemView<TData> bindable) bindable.Bind(data);
|
||||
}
|
||||
|
||||
/// <summary>重建完成回调(子类可重写:默认选中首项、空态提示等)。</summary>
|
||||
protected virtual void OnRebuilt() { }
|
||||
|
||||
private TView Spawn()
|
||||
{
|
||||
TView view = _pool.Count > 0 ? _pool.Dequeue() : Instantiate(_template, _container);
|
||||
view.transform.SetParent(_container, false);
|
||||
view.gameObject.SetActive(true);
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/PooledListView.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/PooledListView.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c1f8c866c54a9440ba21d3a146de17d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
142
Assets/_Game/Scripts/UI/Controls/UIButton.cs
Normal file
142
Assets/_Game/Scripts/UI/Controls/UIButton.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.UI.Theme;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>按钮视觉变体(驱动从主题派生的 <see cref="ColorBlock"/>)。</summary>
|
||||
public enum UIButtonVariant { Primary, Secondary, Accent, Danger, Ghost }
|
||||
|
||||
/// <summary>
|
||||
/// 主题化按钮变体 + 交互反馈。薄封装在 uGUI <see cref="Button"/> 之上:
|
||||
/// - 按变体从 <see cref="UIThemeSO"/> 取基色,派生 normal/highlighted/pressed/disabled 并写入 Button.colors。
|
||||
/// - 选中/悬停时缩放反馈(走 <see cref="UITween.Scale"/>)。
|
||||
/// - 可选点击/悬停音效(事件频道驱动,不直接耦合 Audio 程序集)。
|
||||
///
|
||||
/// 主题来源:优先 Inspector 直接赋的 <see cref="_theme"/>,否则就近向上找 <see cref="UIThemeApplier"/>。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Button))]
|
||||
[DisallowMultipleComponent]
|
||||
public class UIButton : MonoBehaviour,
|
||||
ISelectHandler, IDeselectHandler, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
[Header("变体")]
|
||||
[SerializeField] private UIButtonVariant _variant = UIButtonVariant.Primary;
|
||||
[Tooltip("主题资产;为空则就近向上查找 UIThemeApplier。")]
|
||||
[SerializeField] private UIThemeSO _theme;
|
||||
|
||||
[Header("反馈")]
|
||||
[Tooltip("选中/悬停时的缩放反馈。")]
|
||||
[SerializeField] private bool _scaleFeedback = true;
|
||||
[SerializeField] private float _selectedScale = 1.06f;
|
||||
[SerializeField] private float _scaleDuration = 0.08f;
|
||||
|
||||
[Header("音效(可选,事件频道驱动)")]
|
||||
[SerializeField] private VoidEventChannelSO _onClickSfx;
|
||||
[SerializeField] private VoidEventChannelSO _onHoverSfx;
|
||||
|
||||
private Button _button;
|
||||
private Coroutine _scaleRoutine;
|
||||
private Vector3 _baseScale = Vector3.one;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_button = GetComponent<Button>();
|
||||
_baseScale = transform.localScale;
|
||||
_button.onClick.AddListener(RaiseClickSfx);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
ApplyTheme();
|
||||
transform.localScale = _baseScale;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_scaleRoutine != null) { StopCoroutine(_scaleRoutine); _scaleRoutine = null; }
|
||||
transform.localScale = _baseScale;
|
||||
}
|
||||
|
||||
// ── 主题应用 ──────────────────────────────────────────────────────────
|
||||
/// <summary>按当前变体与主题刷新 Button 配色。</summary>
|
||||
public void ApplyTheme()
|
||||
{
|
||||
var theme = ResolveTheme();
|
||||
if (theme == null) return;
|
||||
if (_button == null) _button = GetComponent<Button>();
|
||||
if (_button == null) return;
|
||||
|
||||
Color baseColor = VariantBaseColor(theme, _variant);
|
||||
var cb = _button.colors;
|
||||
cb.colorMultiplier = 1f;
|
||||
cb.fadeDuration = 0.1f;
|
||||
cb.disabledColor = theme.ButtonDisabled;
|
||||
|
||||
if (_variant == UIButtonVariant.Ghost)
|
||||
{
|
||||
cb.normalColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0f);
|
||||
cb.highlightedColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.25f);
|
||||
cb.pressedColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.40f);
|
||||
cb.selectedColor = cb.highlightedColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
cb.normalColor = baseColor;
|
||||
cb.highlightedColor = Color.Lerp(baseColor, Color.white, 0.12f);
|
||||
cb.pressedColor = Color.Lerp(baseColor, Color.black, 0.15f);
|
||||
cb.selectedColor = cb.highlightedColor;
|
||||
}
|
||||
_button.colors = cb;
|
||||
}
|
||||
|
||||
private static Color VariantBaseColor(UIThemeSO t, UIButtonVariant v) => v switch
|
||||
{
|
||||
UIButtonVariant.Primary => t.Primary,
|
||||
UIButtonVariant.Secondary => t.Secondary,
|
||||
UIButtonVariant.Accent => t.Accent,
|
||||
UIButtonVariant.Danger => t.Danger,
|
||||
UIButtonVariant.Ghost => t.TextPrimary,
|
||||
_ => t.Primary,
|
||||
};
|
||||
|
||||
private UIThemeSO ResolveTheme()
|
||||
{
|
||||
if (_theme != null) return _theme;
|
||||
var applier = GetComponentInParent<UIThemeApplier>(includeInactive: true);
|
||||
return applier != null ? applier.Theme : null;
|
||||
}
|
||||
|
||||
// ── 交互反馈 ──────────────────────────────────────────────────────────
|
||||
public void OnSelect(BaseEventData e) { PlayScale(_selectedScale); RaiseHoverSfx(); }
|
||||
public void OnDeselect(BaseEventData e) { PlayScale(1f); }
|
||||
public void OnPointerEnter(PointerEventData e) { PlayScale(_selectedScale); RaiseHoverSfx(); }
|
||||
public void OnPointerExit(PointerEventData e)
|
||||
{
|
||||
// 仍为当前选中项则保持放大
|
||||
if (EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject) return;
|
||||
PlayScale(1f);
|
||||
}
|
||||
|
||||
private void PlayScale(float factor)
|
||||
{
|
||||
if (!_scaleFeedback || !isActiveAndEnabled) return;
|
||||
if (_scaleRoutine != null) StopCoroutine(_scaleRoutine);
|
||||
_scaleRoutine = StartCoroutine(UITween.Scale(transform, _baseScale * factor, _scaleDuration));
|
||||
}
|
||||
|
||||
private void RaiseClickSfx() => _onClickSfx?.Raise();
|
||||
private void RaiseHoverSfx() => _onHoverSfx?.Raise();
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate()
|
||||
{
|
||||
if (Application.isPlaying) return;
|
||||
_button = GetComponent<Button>();
|
||||
ApplyTheme();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UIButton.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UIButton.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 270b0a121b06dfc4587c060335f18432
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs
Normal file
44
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using TMPro;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 下拉控件封装:TMP_Dropdown + 一行式 <see cref="Bind"/>(填充选项 / 设当前项 / 接回调),
|
||||
/// 消除设置面板里重复的 ClearOptions/AddOptions/RefreshShownValue 样板。
|
||||
/// 选项文本可由调用方先经本地化(<c>LocalizationManager.Get</c>)解析后传入。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class UIDropdown : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Dropdown _dropdown;
|
||||
|
||||
public TMP_Dropdown Dropdown => _dropdown;
|
||||
public int Value => _dropdown != null ? _dropdown.value : 0;
|
||||
|
||||
/// <summary>填充选项、设当前选中项并接变更回调。</summary>
|
||||
public void Bind(IEnumerable<string> options, int index, UnityAction<int> onChanged)
|
||||
{
|
||||
if (_dropdown == null) return;
|
||||
_dropdown.onValueChanged.RemoveAllListeners();
|
||||
_dropdown.ClearOptions();
|
||||
|
||||
var list = options as List<string> ?? new List<string>(options);
|
||||
_dropdown.AddOptions(list);
|
||||
_dropdown.value = Mathf.Clamp(index, 0, Mathf.Max(0, list.Count - 1));
|
||||
_dropdown.RefreshShownValue();
|
||||
|
||||
_dropdown.onValueChanged.AddListener(i => onChanged?.Invoke(i));
|
||||
}
|
||||
|
||||
/// <summary>不触发回调地设置当前选中项(用于外部状态同步)。</summary>
|
||||
public void SetIndexSilent(int index)
|
||||
{
|
||||
if (_dropdown == null) return;
|
||||
_dropdown.SetValueWithoutNotify(Mathf.Clamp(index, 0, Mathf.Max(0, _dropdown.options.Count - 1)));
|
||||
_dropdown.RefreshShownValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6a9d49903ac46e4194b22cc1632a790
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
74
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs
Normal file
74
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用可选择行:图标 + 标签 + 选中高亮 + 按钮,适用于菜单项、设置行、存档槽、列表项。
|
||||
///
|
||||
/// 配合 <see cref="PooledListView{TData,TView}"/> 作 TView,或在面板里直接布局使用。
|
||||
/// 选中(手柄/键盘导航或鼠标悬停)时自动切换高亮节点。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class UISelectableRow : MonoBehaviour, ISelectHandler, IDeselectHandler, IPointerEnterHandler
|
||||
{
|
||||
[Header("引用")]
|
||||
[SerializeField] private Button _button;
|
||||
[SerializeField] private TMP_Text _label;
|
||||
[Tooltip("行图标(可空)。")]
|
||||
[SerializeField] private Image _icon;
|
||||
[Tooltip("选中高亮节点(可空,选中时 SetActive(true))。")]
|
||||
[SerializeField] private GameObject _selectedHighlight;
|
||||
|
||||
/// <summary>行被点击(或 Submit)时触发。</summary>
|
||||
public event Action Clicked;
|
||||
|
||||
public Button Button => _button;
|
||||
public Selectable Selectable => _button;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_selectedHighlight != null) _selectedHighlight.SetActive(false);
|
||||
if (_button != null)
|
||||
_button.onClick.AddListener(() => Clicked?.Invoke());
|
||||
}
|
||||
|
||||
// ── 数据填充 ──────────────────────────────────────────────────────────
|
||||
public void SetLabel(string text)
|
||||
{
|
||||
if (_label != null) _label.text = text;
|
||||
}
|
||||
|
||||
public void SetIcon(Sprite sprite)
|
||||
{
|
||||
if (_icon == null) return;
|
||||
_icon.sprite = sprite;
|
||||
_icon.enabled = sprite != null;
|
||||
}
|
||||
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
if (_selectedHighlight != null) _selectedHighlight.SetActive(selected);
|
||||
}
|
||||
|
||||
/// <summary>把 EventSystem 焦点设到本行。</summary>
|
||||
public void Focus()
|
||||
{
|
||||
if (_button != null && EventSystem.current != null)
|
||||
EventSystem.current.SetSelectedGameObject(_button.gameObject);
|
||||
}
|
||||
|
||||
// ── 选中反馈 ──────────────────────────────────────────────────────────
|
||||
public void OnSelect(BaseEventData e) => SetSelected(true);
|
||||
public void OnDeselect(BaseEventData e) => SetSelected(false);
|
||||
|
||||
public void OnPointerEnter(PointerEventData e)
|
||||
{
|
||||
if (_button != null && _button.interactable && EventSystem.current != null)
|
||||
EventSystem.current.SetSelectedGameObject(_button.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64037ec2ca4c93d40908480eb137ccf3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Assets/_Game/Scripts/UI/Controls/UISlider.cs
Normal file
52
Assets/_Game/Scripts/UI/Controls/UISlider.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.Events;
|
||||
using TMPro;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 滑条控件封装:Slider + 实时数值标签 + 一行式 <see cref="Bind"/>,
|
||||
/// 消除 <see cref="SettingsPanelController"/> 里"移除监听→设值→加监听→更新标签"的重复样板。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class UISlider : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Slider _slider;
|
||||
[Tooltip("实时数值标签(可空)。")]
|
||||
[SerializeField] private TMP_Text _valueLabel;
|
||||
[Tooltip("数值标签格式(当未传 fmt 委托时使用)。例:\"{0:0}\"、\"{0:0}%\"。")]
|
||||
[SerializeField] private string _format = "{0:0}";
|
||||
|
||||
private Func<float, string> _fmt;
|
||||
|
||||
public Slider Slider => _slider;
|
||||
public float Value => _slider != null ? _slider.value : 0f;
|
||||
|
||||
/// <summary>
|
||||
/// 绑定取值范围、初值与变更回调。<paramref name="fmt"/> 可自定义数值标签文本(优先于 <see cref="_format"/>)。
|
||||
/// </summary>
|
||||
public void Bind(float min, float max, float value, UnityAction<float> onChanged, Func<float, string> fmt = null)
|
||||
{
|
||||
if (_slider == null) return;
|
||||
_slider.onValueChanged.RemoveAllListeners();
|
||||
_slider.minValue = min;
|
||||
_slider.maxValue = max;
|
||||
_slider.value = Mathf.Clamp(value, min, max);
|
||||
_fmt = fmt;
|
||||
UpdateLabel(_slider.value);
|
||||
_slider.onValueChanged.AddListener(v =>
|
||||
{
|
||||
onChanged?.Invoke(v);
|
||||
UpdateLabel(v);
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateLabel(float v)
|
||||
{
|
||||
if (_valueLabel == null) return;
|
||||
_valueLabel.text = _fmt != null ? _fmt(v) : string.Format(_format, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UISlider.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UISlider.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1743e750e4f79c43b64e07a1c1a7270
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
157
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs
Normal file
157
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 可复用标签组(从 <see cref="BaseGames.UI.Inventory.InventoryHubPanel"/> 的 Tab 逻辑提取)。
|
||||
///
|
||||
/// 职责:管理一组 Tab(内容根 + 头部按钮 + 高亮),处理 Next/Prev/Select 切换、
|
||||
/// 头部高亮、焦点委托(内容若实现 <see cref="IFocusable"/> 则接管,否则聚焦头部按钮),
|
||||
/// 可选跨重建记忆当前 Tab,并通过 C# 事件 + 可选 SO 频道广播索引。
|
||||
///
|
||||
/// 解耦:不引用任何具体 Tab 类型,只持有 Tab 根 <see cref="GameObject"/>。
|
||||
/// 由宿主面板在 OnEnable 中调用 <see cref="Activate"/>,并把肩键频道接到 <see cref="Next"/>/<see cref="Prev"/>。
|
||||
/// </summary>
|
||||
public class UITabGroup : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
public struct Tab
|
||||
{
|
||||
[Tooltip("Tab 内容根节点(激活时 SetActive(true))。")]
|
||||
public GameObject content;
|
||||
[Tooltip("Tab 头部按钮(点击跳转;高亮显示当前选中)。可空。")]
|
||||
public Button headerButton;
|
||||
[Tooltip("Tab 头部高亮节点(选中时 SetActive(true))。可空。")]
|
||||
public GameObject headerHighlight;
|
||||
}
|
||||
|
||||
[Header("Tabs(按显示顺序)")]
|
||||
[SerializeField] private Tab[] _tabs;
|
||||
|
||||
[Tooltip("默认打开的 Tab 索引。")]
|
||||
[SerializeField] private int _defaultIndex = 0;
|
||||
|
||||
[Tooltip("是否记住上次停留的 Tab(跨面板重开)。")]
|
||||
[SerializeField] private bool _rememberLast = true;
|
||||
|
||||
[Tooltip("跨重建记忆用的键;为空则只在本实例存活期间记忆。")]
|
||||
[SerializeField] private string _persistKey = "";
|
||||
|
||||
[Header("Event Channels(可选)")]
|
||||
[Tooltip("当前 Tab 变化时广播索引。可空。")]
|
||||
[SerializeField] private IntEventChannelSO _onTabChanged;
|
||||
|
||||
/// <summary>当前 Tab 变化时触发(参数为新索引)。</summary>
|
||||
public event Action<int> TabChanged;
|
||||
|
||||
public int CurrentIndex { get; private set; } = -1;
|
||||
public int TabCount => _tabs?.Length ?? 0;
|
||||
|
||||
// 跨重建记忆(静态:面板/组件被销毁重建后仍保留)
|
||||
private static readonly Dictionary<string, int> s_persist = new();
|
||||
|
||||
private bool _wired;
|
||||
|
||||
private void Awake() => EnsureWired();
|
||||
|
||||
private void EnsureWired()
|
||||
{
|
||||
if (_wired || _tabs == null) return;
|
||||
for (int i = 0; i < _tabs.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
if (_tabs[i].headerButton != null)
|
||||
{
|
||||
_tabs[i].headerButton.onClick.RemoveAllListeners();
|
||||
_tabs[i].headerButton.onClick.AddListener(() => Select(captured));
|
||||
}
|
||||
if (_tabs[i].content != null) _tabs[i].content.SetActive(false);
|
||||
if (_tabs[i].headerHighlight != null) _tabs[i].headerHighlight.SetActive(false);
|
||||
}
|
||||
_wired = true;
|
||||
}
|
||||
|
||||
/// <summary>激活标签组并打开起始 Tab(默认或记忆)。宿主面板在 OnEnable 中调用。</summary>
|
||||
public void Activate()
|
||||
{
|
||||
EnsureWired();
|
||||
if (TabCount == 0) return;
|
||||
int start = _rememberLast && TryGetPersisted(out var p) ? p : _defaultIndex;
|
||||
CurrentIndex = -1; // 强制 Select 执行切换
|
||||
Select(Mathf.Clamp(start, 0, TabCount - 1));
|
||||
}
|
||||
|
||||
public void Next() => Step(+1);
|
||||
public void Prev() => Step(-1);
|
||||
|
||||
private void Step(int dir)
|
||||
{
|
||||
if (TabCount == 0) return;
|
||||
int next = CurrentIndex;
|
||||
for (int i = 0; i < _tabs.Length; i++) // 跳过未配置 content 的空槽
|
||||
{
|
||||
next = (next + dir + _tabs.Length) % _tabs.Length;
|
||||
if (_tabs[next].content != null) break;
|
||||
}
|
||||
Select(next);
|
||||
}
|
||||
|
||||
/// <summary>切换到指定 Tab;无效或与当前相同则忽略。</summary>
|
||||
public void Select(int index)
|
||||
{
|
||||
EnsureWired();
|
||||
if (TabCount == 0) return;
|
||||
index = Mathf.Clamp(index, 0, _tabs.Length - 1);
|
||||
if (index == CurrentIndex) return;
|
||||
|
||||
// 关闭旧 Tab
|
||||
if (CurrentIndex >= 0 && CurrentIndex < _tabs.Length)
|
||||
{
|
||||
if (_tabs[CurrentIndex].content != null) _tabs[CurrentIndex].content.SetActive(false);
|
||||
if (_tabs[CurrentIndex].headerHighlight != null) _tabs[CurrentIndex].headerHighlight.SetActive(false);
|
||||
}
|
||||
|
||||
CurrentIndex = index;
|
||||
SetPersisted(index);
|
||||
|
||||
// 打开新 Tab
|
||||
var tab = _tabs[index];
|
||||
if (tab.content != null) tab.content.SetActive(true);
|
||||
if (tab.headerHighlight != null) tab.headerHighlight.SetActive(true);
|
||||
|
||||
FocusCurrent();
|
||||
_onTabChanged?.Raise(index);
|
||||
TabChanged?.Invoke(index);
|
||||
}
|
||||
|
||||
/// <summary>把焦点交给当前 Tab(内容 IFocusable 优先,否则其头部按钮)。供宿主 OnFocusRestored 调用。</summary>
|
||||
public void FocusCurrent()
|
||||
{
|
||||
if (CurrentIndex < 0 || CurrentIndex >= TabCount) return;
|
||||
var tab = _tabs[CurrentIndex];
|
||||
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
|
||||
if (focusable != null) focusable.OnFocusRestored();
|
||||
else if (tab.headerButton != null && EventSystem.current != null)
|
||||
EventSystem.current.SetSelectedGameObject(tab.headerButton.gameObject);
|
||||
}
|
||||
|
||||
// ── 记忆 ──────────────────────────────────────────────────────────────
|
||||
private bool TryGetPersisted(out int index)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_persistKey) && s_persist.TryGetValue(_persistKey, out index)) return true;
|
||||
if (CurrentIndex >= 0) { index = CurrentIndex; return true; }
|
||||
index = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetPersisted(int index)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_persistKey)) s_persist[_persistKey] = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8cc0a2d3090a75f4c82697a82dd9ec85
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user