158 lines
6.4 KiB
C#
158 lines
6.4 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|