Files

508 lines
25 KiB
C#

#nullable enable
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UVC.Extension;
using UVC.Locale;
using UVC.UI.Toolbar.Model;
using UVC.UI.Tooltip;
using UVC.UI.Util;
using UVC.Util;
namespace UVC.UI.Toolbar.View
{
/// <summary>
/// ToolbarModel에 정의된 데이터를 기반으로 실제 툴바 UI를 생성하고 관리하는 MonoBehaviour 클래스입니다.
/// 사용자의 UI 상호작용을 감지하여 모델의 상태를 변경하거나 커맨드를 실행하고,
/// 모델의 상태 변경에 따라 UI를 업데이트합니다.
/// </summary>
/// <remarks>
/// 주요 역할:
/// - UI 렌더링: ToolbarModel의 Items 리스트를 순회하며 각 항목에 맞는 UI GameObject(버튼, 구분선 등)를 프리팹으로부터 생성하여 화면에 표시합니다.
/// - 이벤트 바인딩: 생성된 UI 요소(예: Unity Button의 onClick)와 ToolbarButtonBase 모델의 ExecuteClick 메서드를 연결합니다.
/// 또한, 모델의 OnStateChanged, OnToggleStateChanged 등의 이벤트를 구독하여 모델 상태 변경 시 UI를 업데이트합니다.
/// - UI 업데이트: 모델의 상태(텍스트, 아이콘, 활성화 상태, 선택 상태 등)가 변경되면 해당 UI 요소의 시각적 표현을 업데이트합니다.
/// - 리소스 관리: 생성된 UI GameObject들과 이벤트 구독을 관리하며, 툴바가 파괴되거나 다시 렌더링될 때 정리합니다(ClearToolbar).
/// - 하위 로직 위임: 라디오 버튼 그룹 관리는 `ToggleGroupManager`에, 확장 버튼의 하위 메뉴 관리는 `SubMenuHandler`에 위임하여 클래스의 복잡도를 낮춥니다.
///
/// 이 클래스의 인스턴스는 Unity 씬 내의 GameObject에 컴포넌트로 추가되어야 하며,
/// Inspector를 통해 필요한 프리팹들(standardButtonPrefab, toggleButtonPrefab 등)과
/// UI 요소들이 배치될 부모 Transform(toolbarContainer), LayoutGroup 등을 할당받아야 합니다.
/// </remarks>
/// <example>
/// <code>
/// // 1. Unity 에디터에서 빈 GameObject를 만들고 ToolbarView 스크립트를 추가합니다.
/// // 2. Inspector에서 ToolbarView 컴포넌트의 다음 필드들을 설정합니다:
/// // - Standard Button Prefab: 표준 버튼 UI 프리팹
/// // - Toggle Button Prefab: 토글 버튼 UI 프리팹
/// // - Radio Button Prefab: 라디오 버튼 UI 프리팹
/// // - Expandable Button Prefab: 확장 버튼 UI 프리팹
/// // - Separator Prefab: 구분선 UI 프리팹
/// // - Sub Menu Panel Prefab: 확장 버튼의 하위 메뉴 패널 UI 프리팹
/// // - Toolbar Container: 생성된 툴바 항목 UI들이 자식으로 추가될 Transform
/// // - Layout Group: Toolbar Container에 연결된 LayoutGroup (예: VerticalLayoutGroup)
///
/// // 3. 툴바를 제어하는 다른 스크립트(예: UIManager, ToolbarController)에서 아래와 같이 사용합니다.
/// public class MyToolbarController : MonoBehaviour
/// {
/// public ToolbarView toolbarView; // Inspector에서 ToolbarView 할당
///
/// void Start()
/// {
/// // 3-1. 툴바에 표시할 데이터 모델(ToolbarModel)을 생성하고 설정합니다.
/// ToolbarModel myModel = new ToolbarModel();
///
/// // 표준 버튼 추가
/// myModel.AddStandardButton("새 파일", "icons/new_file", null, "새 파일을 생성합니다.");
///
/// // 구분선 추가
/// myModel.AddSeparator();
///
/// // 토글 버튼 추가
/// myModel.AddToggleButton("그리드 보기", false, "icons/grid_on", "icons/grid_off",
/// (IsSelected) => Debug.Log($"그리드 표시: {IsSelected}"));
///
/// // 라디오 버튼 그룹 추가
/// string viewModeGroup = "ViewMode";
/// myModel.AddRadioButton(viewModeGroup, "2D 보기", true, "icons/view_2d");
/// myModel.AddRadioButton(viewModeGroup, "3D 보기", false, "icons/view_3d");
///
/// // 3-2. 설정된 모델을 ToolbarView에 전달하여 UI를 생성하도록 합니다.
/// toolbarView.Initialize(myModel);
/// }
/// }
/// </code>
/// </example>
public class ToolbarView : MonoBehaviour
{
/// <summary>
/// 현재 툴바 UI의 기반이 되는 데이터 모델입니다.
/// Initialize 메서드를 통해 외부에서 주입받습니다.
/// </summary>
protected ToolbarModel ToolbarModel { get; private set; }
// --- Inspector에서 할당할 프리팹 및 UI 요소들 ---
[Header("UI Prefabs")]
[Tooltip("표준 버튼 UI에 사용될 프리팹입니다.")]
public GameObject standardButtonPrefab;
[Tooltip("토글 버튼 UI에 사용될 프리팹입니다. IsOn=false로 설정해 놔야 합니다.")]
public GameObject toggleButtonPrefab;
[Tooltip("라디오 버튼 UI에 사용될 프리팹입니다. IsOn=false로 설정해 놔야 합니다.")]
public GameObject radioButtonPrefab;
[Tooltip("확장 가능한 버튼 UI에 사용될 프리팹입니다.")]
public GameObject expandableButtonPrefab;
[Tooltip("구분선 UI에 사용될 프리팹입니다.")]
public GameObject separatorPrefab;
[Tooltip("확장 버튼의 하위 메뉴 패널 UI에 사용될 프리팹입니다.")]
public GameObject subMenuPanelPrefab;
[Header("UI Layout")]
[Tooltip("생성된 툴바 항목 UI들이 자식으로 추가될 부모 Transform입니다.")]
public Transform toolbarContainer;
[Tooltip("툴바 항목들의 자동 배치를 담당하는 LayoutGroup 컴포넌트입니다 (예: VerticalLayoutGroup).")]
public LayoutGroup layoutGroup;
/// <summary>
/// 툴바 버튼 모델(ToolbarButtonBase)과 해당 모델을 나타내는 실제 UI GameObject를 매핑하는 딕셔너리입니다.
/// 모델 상태 변경 시 해당 GameObject를 찾아 UI를 업데이트하거나, UI 정리 시 사용됩니다.
/// </summary>
protected Dictionary<ToolbarButtonBase, GameObject> _modelToGameObjectMap = new Dictionary<ToolbarButtonBase, GameObject>();
/// <summary>
/// 버튼 타입별 UI 처리기를 저장하는 딕셔너리입니다.
/// 각 버튼 타입(표준, 토글 등)에 맞는 IButtonViewProcessor 구현체를 등록하여 사용합니다.
/// </summary>
private Dictionary<System.Type, IButtonViewProcessor> _buttonViewProcessors = new Dictionary<System.Type, IButtonViewProcessor>();
// --- 헬퍼 클래스 ---
private ToggleGroupManager _toggleGroupManager;
private SubMenuHandler _subMenuHandler;
private Canvas _canvas;
/// <summary>
/// 렌더링 작업에 사용되는 정적 캔버스 인스턴스를 가져옵니다.
/// </summary>
public Canvas Canvas
{
get
{
if (_canvas == null) _canvas = CanvasUtil.GetOrCreate("StaticCanvas");
return _canvas;
}
}
/// <summary>
/// MonoBehaviour의 Awake 메서드입니다.
/// 필수 참조를 확인 및 초기화하고, 버튼 뷰 프로세서와 헬퍼 클래스들을 준비합니다.
/// </summary>
protected virtual void Awake()
{
// 필수 프리팹들이 Inspector에서 할당되었는지 확인합니다.
if (standardButtonPrefab == null || toggleButtonPrefab == null || radioButtonPrefab == null ||
expandableButtonPrefab == null || separatorPrefab == null || subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: 필수 프리팹이 할당되지 않았습니다. Inspector에서 모든 프리팹을 설정해주세요.", this);
}
// UI 컨테이너와 레이아웃 그룹을 자동으로 찾거나 설정합니다.
if (toolbarContainer == null) toolbarContainer = GetComponent<Transform>();
if (toolbarContainer == null) toolbarContainer = GetComponentInChildren<Transform>(true);
if (toolbarContainer == null) Debug.LogError("ToolbarView: toolbarContainer가 할당되지 않았습니다.", this);
if (layoutGroup == null && toolbarContainer != null)
{
layoutGroup = toolbarContainer.gameObject.GetComponent<LayoutGroup>();
if (layoutGroup == null)
{
layoutGroup = toolbarContainer.gameObject.AddComponent<VerticalLayoutGroup>();
Debug.LogWarning("ToolbarView: LayoutGroup이 toolbarContainer에 없어 새로 추가합니다. (기본 VerticalLayoutGroup)", this);
}
}
if (layoutGroup == null) Debug.LogError("ToolbarView: layoutGroup이 할당되지 않았습니다.", this);
// 각 버튼 타입에 대한 뷰 프로세서를 등록합니다.
_buttonViewProcessors[typeof(ToolbarStandardButton)] = new ToolbarStandardButtonViewProcessor();
_buttonViewProcessors[typeof(ToolbarToggleButton)] = new ToolbarToggleButtonViewProcessor();
_buttonViewProcessors[typeof(ToolbarRadioButton)] = new ToolbarRadioButtonViewProcessor();
_buttonViewProcessors[typeof(ToolbarExpandableButton)] = new ToolbarExpandableButtonViewProcessor();
// 헬퍼 클래스들을 초기화합니다.
_toggleGroupManager = new ToggleGroupManager(toolbarContainer);
_subMenuHandler = new SubMenuHandler(this);
}
/// <summary>
/// 지정된 ToolbarModel을 사용하여 툴바 UI를 초기화하고 렌더링합니다.
/// 기존 UI가 있다면 정리(ClearToolbar)한 후, 새 모델에 따라 UI를 다시 생성(RenderToolbar)합니다.
/// </summary>
/// <param name="toolbarModel">화면에 표시할 툴바의 데이터 모델입니다.</param>
public virtual void Initialize(ToolbarModel toolbarModel)
{
this.ToolbarModel = toolbarModel;
// 필수 조건들을 다시 한 번 확인합니다.
if (standardButtonPrefab == null || toggleButtonPrefab == null || radioButtonPrefab == null ||
expandableButtonPrefab == null || separatorPrefab == null || subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: 필수 프리팹이 할당되지 않았습니다.", this);
return;
}
if (toolbarContainer == null)
{
Debug.LogError("ToolbarView: Initialize 실패. toolbarContainer가 할당되지 않았습니다.", this);
return;
}
if (layoutGroup == null)
{
Debug.LogError("ToolbarView: Initialize 실패. layoutGroup이 할당되지 않았습니다.", this);
return;
}
if (this.ToolbarModel == null)
{
Debug.LogError("ToolbarView: Initialize 실패. 제공된 toolbarModel이 null입니다.", this);
ClearToolbar(); // 모델이 없으면 기존 UI라도 정리합니다.
return;
}
// 모든 조건이 충족되면 툴바 렌더링을 시작합니다.
RenderToolbar();
}
/// <summary>
/// 현재 툴바에 표시된 모든 UI 요소들을 제거하고, 관련된 이벤트 구독을 해제합니다.
/// 툴바를 새로 그리거나 뷰가 파괴될 때 호출됩니다.
/// </summary>
protected virtual void ClearToolbar()
{
if (_modelToGameObjectMap != null)
{
foreach (var pair in _modelToGameObjectMap)
{
if (pair.Key != null)
{
// 모델에 연결된 모든 이벤트 구독을 명시적으로 해제합니다.
pair.Key.ClearEventHandlers();
}
if (pair.Value != null)
{
// UI 컴포넌트의 이벤트 리스너를 명시적으로 해제합니다.
Toggle toggleComponent = pair.Value.GetComponent<Toggle>();
if (toggleComponent != null) toggleComponent.onValueChanged.RemoveAllListeners();
Button buttonComponent = pair.Value.GetComponent<Button>();
if (buttonComponent != null) buttonComponent.onClick.RemoveAllListeners();
TooltipHandler handler = pair.Value.GetComponent<TooltipHandler>();
if (handler != null)
{
handler.OnPointerEnterAction = null;
handler.OnPointerExitAction = null;
}
// UI GameObject를 파괴합니다.
Destroy(pair.Value);
}
}
_modelToGameObjectMap.Clear();
}
// 헬퍼 클래스들이 관리하던 리소스도 정리합니다.
_toggleGroupManager?.Clear();
_subMenuHandler?.CloseSubMenu();
// 툴팁이 화면에 남아있을 경우를 대비해 숨깁니다.
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
}
/// <summary>
/// 지정된 버튼 모델 타입에 맞는 IButtonViewProcessor를 반환합니다.
/// </summary>
/// <param name="buttonModelType">버튼 모델의 System.Type 객체입니다.</param>
/// <returns>해당 타입의 처리기. 없으면 null을 반환합니다.</returns>
internal IButtonViewProcessor GetButtonViewProcessor(System.Type buttonModelType)
{
if (_buttonViewProcessors.TryGetValue(buttonModelType, out var processor))
{
return processor;
}
return null;
}
/// <summary>
/// ToolbarModel에 정의된 항목들을 기반으로 실제 UI 요소들을 생성하여 툴바를 구성합니다.
/// </summary>
protected void RenderToolbar()
{
ClearToolbar(); // 기존 UI 및 이벤트 구독 정리
if (ToolbarModel == null || ToolbarModel.Items == null) return;
if (toolbarContainer == null)
{
Debug.LogError("ToolbarView: RenderToolbar 중단. toolbarContainer가 null입니다.", this);
return;
}
// 모델의 각 아이템을 순회하며 UI를 생성합니다.
foreach (var itemModel in ToolbarModel.Items)
{
GameObject? itemUIObject = null;
if (itemModel is ToolbarSeparator)
{
// 구분선 모델인 경우
if (separatorPrefab != null) itemUIObject = Instantiate(separatorPrefab, toolbarContainer);
else Debug.LogError("ToolbarView: separatorPrefab이 할당되지 않았습니다.", this);
}
else if (itemModel is ToolbarButtonBase buttonModel)
{
// 버튼 모델인 경우
IButtonViewProcessor processor = GetButtonViewProcessor(buttonModel.GetType());
if (processor != null)
{
// 해당 버튼 타입의 프로세서를 사용하여 UI를 생성하고 설정합니다.
itemUIObject = processor.CreateButtonUI(buttonModel, toolbarContainer, this);
if (itemUIObject != null)
{
_modelToGameObjectMap[buttonModel] = itemUIObject;
processor.SetupButtonInteractions(buttonModel, itemUIObject, this);
// 모델의 상태 변경 이벤트를 구독하여 UI를 자동으로 업데이트하도록 합니다.
buttonModel.OnStateChanged += () => processor.UpdateCommonButtonVisuals(buttonModel, itemUIObject, this);
if (buttonModel is ToolbarToggleButton toggleModel)
{
toggleModel.OnToggleStateChanged += (isSelected) => processor.UpdateToggleStateVisuals(toggleModel, itemUIObject, isSelected, this);
}
// 툴팁 설정
if (!string.IsNullOrEmpty(buttonModel.Tooltip))
{
TooltipHandler tooltipHandler = itemUIObject.GetComponent<TooltipHandler>() ?? itemUIObject.AddComponent<TooltipHandler>();
tooltipHandler.Tooltip = buttonModel.Tooltip;
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
tooltipHandler.OnPointerEnterAction = TooltipManager.Instance.HandlePointerEnter;
tooltipHandler.OnPointerExitAction = TooltipManager.Instance.HandlePointerExit;
}
}
}
else
{
Debug.LogError($"ToolbarView: {buttonModel.GetType().Name}의 UI를 생성하지 못했습니다 (Processor 반환 null).", this);
}
}
else
{
Debug.LogError($"ToolbarView: {buttonModel.GetType().Name}에 대한 프리팹이 할당되지 않았거나 알 수 없는 버튼 타입입니다.", this);
}
}
else
{
Debug.LogWarning($"ToolbarView: 알 수 없는 IToolbarItem 타입 ({itemModel.GetType().Name})입니다.", this);
}
if (itemUIObject != null)
{
itemUIObject.name = $"ToolbarItem_{itemModel.GetType().Name}_{(itemModel is ToolbarButtonBase b ? b.Text : "")}";
}
}
// 모든 UI 항목이 추가된 후 레이아웃을 강제로 업데이트합니다.
if (layoutGroup != null)
{
layoutGroup.FitToChildren(width: layoutGroup is VerticalLayoutGroup, height: true);
}
}
/// <summary>
/// 버튼 모델과 UI GameObject를 기반으로 공통적인 시각적 요소(텍스트, 아이콘, 활성화 상태)를 업데이트합니다.
/// 이 메서드는 각 IButtonViewProcessor 구현체에서 호출됩니다.
/// </summary>
/// <param name="model">업데이트할 버튼의 데이터 모델입니다.</param>
/// <param name="itemObj">업데이트할 UI GameObject입니다.</param>
internal void InternalUpdateCommonButtonVisuals(ToolbarButtonBase model, GameObject itemObj)
{
if (model == null || itemObj == null) return;
// 1. 텍스트 업데이트 (다국어 지원 포함)
TextMeshProUGUI buttonTextComponent = itemObj.GetComponentInChildren<TextMeshProUGUI>(true);
if (buttonTextComponent != null)
{
string displayText = model.Text;
if (LocalizationManager.Instance != null && !string.IsNullOrEmpty(model.Text))
{
displayText = LocalizationManager.Instance.GetString(model.Text, model.Text);
}
buttonTextComponent.text = displayText;
}
// 2. 아이콘 업데이트
Image iconImageComponent = itemObj.transform.Find("Icon")?.GetComponent<Image>() ?? itemObj.GetComponentInChildren<Image>(true);
if (iconImageComponent != null)
{
string iconPathToLoad = (model is ToolbarToggleButton toggleButton && !toggleButton.IsSelected)
? toggleButton.OffIconSpritePath
: model.IconSpritePath;
if (!string.IsNullOrEmpty(iconPathToLoad))
{
Sprite iconSprite = LoadSpriteFromResources(iconPathToLoad);
iconImageComponent.sprite = iconSprite;
iconImageComponent.enabled = (iconSprite != null);
}
else
{
iconImageComponent.sprite = null;
iconImageComponent.enabled = false;
}
}
// 3. 활성화 상태(interactable) 업데이트
Selectable selectableComponent = itemObj.GetComponent<Selectable>();
if (selectableComponent != null)
{
selectableComponent.interactable = model.IsEnabled;
}
}
/// <summary>
/// 라디오 버튼 그룹 이름에 해당하는 ToggleGroup을 가져오거나 생성합니다. (ToggleGroupManager에 위임)
/// </summary>
internal ToggleGroup GetOrCreateToggleGroup(string groupName)
{
return _toggleGroupManager.GetOrCreateToggleGroup(groupName);
}
/// <summary>
/// 확장 버튼의 하위 메뉴를 토글합니다. (SubMenuHandler에 위임)
/// </summary>
internal void ToggleSubMenu(ToolbarExpandableButton expandableButtonModel, GameObject expandableButtonObj)
{
_subMenuHandler.ToggleSubMenu(expandableButtonModel, expandableButtonObj);
}
/// <summary>
/// 지정된 경로에서 Sprite를 로드합니다. Sprite 파일은 Resources 폴더 또는 그 하위 폴더에 있어야 합니다.
/// </summary>
/// <param name="spritePath">Resources 폴더 기준의 Sprite 경로입니다 (확장자 제외).</param>
/// <returns>로드된 Sprite 객체. 실패 시 null을 반환합니다.</returns>
protected Sprite LoadSpriteFromResources(string spritePath)
{
if (string.IsNullOrEmpty(spritePath)) return null;
Sprite loadedSprite = Resources.Load<Sprite>(spritePath);
if (loadedSprite == null)
{
Debug.LogError($"ToolbarView: Resources 폴더에서 '{spritePath}' 경로의 Sprite를 로드할 수 없습니다.", this);
}
return loadedSprite;
}
/// <summary>
/// MonoBehaviour의 Update 메서드입니다. 매 프레임 호출됩니다.
/// SubMenuHandler의 Update를 호출하여 하위 메뉴 외부 클릭 감지 등을 처리합니다.
/// </summary>
protected virtual void Update()
{
_subMenuHandler?.Update();
}
/// <summary>
/// MonoBehaviour의 OnDestroy 메서드입니다. 이 컴포넌트 또는 GameObject가 파괴될 때 호출됩니다.
/// 툴바 UI를 정리(ClearToolbar)하여 메모리 누수를 방지합니다.
/// </summary>
protected virtual void OnDestroy()
{
ClearToolbar();
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
}
/// <summary>
/// 버튼 모델에 해당하는 UI GameObject의 Icon 자식에 있는 ImageColorChangeBehaviour의 선택 상태를 업데이트합니다.
/// </summary>
/// <param name="buttonModel">상태를 업데이트할 버튼 모델입니다.</param>
/// <param name="isSelected">설정할 선택 상태입니다.</param>
public void UpdateIconColorState(ToolbarButtonBase buttonModel, bool isSelected)
{
if (buttonModel == null) return;
if (_modelToGameObjectMap.TryGetValue(buttonModel, out GameObject buttonObj) && buttonObj != null)
{
ImageColorChangeBehaviour colorBehaviour = buttonObj.GetComponentInChildren<ImageColorChangeBehaviour>();
if (colorBehaviour != null)
{
colorBehaviour.SetSelected(isSelected);
}
}
}
/// <summary>
/// 라디오 버튼 그룹 내 모든 버튼의 Icon 색상 상태를 업데이트합니다.
/// </summary>
/// <param name="groupName">라디오 버튼 그룹 이름입니다.</param>
/// <param name="selectedButton">선택된 버튼입니다. null이면 모든 버튼이 비선택 상태가 됩니다.</param>
public void UpdateRadioGroupIconColors(string groupName, ToolbarRadioButton? selectedButton)
{
if (ToolbarModel == null) return;
var group = ToolbarModel.GetRadioButtonGroup(groupName);
if (group == null) return;
foreach (var button in group.GetButtons())
{
bool isSelected = (selectedButton != null && button == selectedButton);
UpdateIconColorState(button, isSelected);
}
}
}
}