TopMenu 개발 완료

This commit is contained in:
logonkhi
2025-06-18 20:09:16 +09:00
parent f0ae7b7696
commit 21020f590f
29 changed files with 8992 additions and 1505 deletions

View File

@@ -2,7 +2,7 @@
using UVC.UI.Toolbar.Model;
using UVC.UI.Toolbar.View;
namespace UVC.UI.ToolBar.View
namespace UVC.UI.Toolbar.View
{
/// <summary>
/// 툴바 버튼의 UI 생성, 설정 및 업데이트 로직을 처리하는 인터페이스입니다.

View File

@@ -0,0 +1,330 @@
using UnityEngine;
using UnityEngine.UI;
using UVC.Extension;
using UVC.UI.Toolbar.Model;
using UVC.UI.Tooltip;
namespace UVC.UI.Toolbar.View
{
/// <summary>
/// 확장 가능한 툴바 버튼(ToolbarExpandableButton)의 하위 메뉴 UI를 생성, 관리 및 상호작용을 처리하는 헬퍼 클래스입니다.
/// ToolbarView로부터 하위 메뉴 관련 로직을 위임받아 처리하여 ToolbarView의 복잡도를 낮춥니다.
/// </summary>
/// <remarks>
/// 주요 역할:
/// - 하위 메뉴 토글: 특정 확장 버튼에 대한 하위 메뉴를 열거나 닫습니다.
/// - 하위 메뉴 UI 생성: `subMenuPanelPrefab`을 사용하여 하위 메뉴의 패널과 그 안의 버튼들을 동적으로 생성합니다.
/// - 위치 계산: 하위 메뉴가 나타날 위치를 주 확장 버튼의 위치를 기준으로 계산하여 설정합니다.
/// - 이벤트 처리: 생성된 하위 메뉴의 버튼들에 대한 클릭 이벤트를 설정하고, 클릭 시 메뉴를 닫는 등의 동작을 처리합니다.
/// - 외부 클릭 감지: 하위 메뉴가 열려 있을 때 메뉴 영역 바깥을 클릭하면 메뉴가 닫히도록 처리합니다.
/// - 리소스 정리: 하위 메뉴가 닫힐 때 생성되었던 모든 UI 요소와 이벤트 리스너를 깨끗하게 제거합니다.
/// </remarks>
/// <example>
/// <code>
/// // 이 클래스는 ToolbarView 내부에서 다음과 같이 사용됩니다.
///
/// // 1. ToolbarView의 Awake 메서드에서 SubMenuHandler 인스턴스 생성
/// // public class ToolbarView : MonoBehaviour
/// // {
/// // private SubMenuHandler _subMenuHandler;
/// //
/// // void Awake()
/// // {
/// // _subMenuHandler = new SubMenuHandler(this);
/// // }
/// //
/// // // ...
/// // }
///
/// // 2. 확장 버튼(ExpandableButton)의 UI 프로세서에서 `ToggleSubMenu` 호출
/// // public class ToolbarExpandableButtonViewProcessor : IButtonViewProcessor
/// // {
/// // public void SetupButtonInteractions(ToolbarButtonBase buttonModel, GameObject buttonUIObject, ToolbarView viewContext)
/// // {
/// // ToolbarExpandableButton expandableModel = buttonModel as ToolbarExpandableButton;
/// // Button uiButton = buttonUIObject.GetComponent<Button>();
/// // if (uiButton != null)
/// // {
/// // uiButton.onClick.AddListener(() =>
/// // {
/// // // viewContext의 ToggleSubMenu를 호출하면 내부적으로 SubMenuHandler의 ToggleSubMenu가 실행됩니다.
/// // viewContext.ToggleSubMenu(expandableModel, buttonUIObject);
/// // });
/// // }
/// // }
/// // }
///
/// // 3. ToolbarView의 Update 메서드에서 매 프레임 외부 클릭 감지 로직 실행
/// // public class ToolbarView : MonoBehaviour
/// // {
/// // void Update()
/// // {
/// // _subMenuHandler.Update(); // 매 프레임 호출하여 외부 클릭 감지
/// // }
/// // }
/// </code>
/// </example>
public class SubMenuHandler
{
private readonly ToolbarView _view;
private GameObject _currentSubMenu;
private ToolbarExpandableButton _ownerOfCurrentSubMenu;
/// <summary>
/// SubMenuHandler의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="view">이 핸들러를 소유하고 관리하는 ToolbarView의 인스턴스입니다. 프리팹, 캔버스 등의 참조에 사용됩니다.</param>
public SubMenuHandler(ToolbarView view)
{
_view = view;
}
/// <summary>
/// 확장 버튼에 대한 하위 메뉴 UI를 토글합니다.
/// 이미 해당 버튼의 하위 메뉴가 열려있으면 닫고, 닫혀있으면 엽니다.
/// 다른 버튼의 하위 메뉴가 열려있었다면, 그 메뉴는 닫고 새로운 메뉴를 엽니다.
/// </summary>
/// <param name="expandableButtonModel">하위 버튼 목록을 가진 확장 버튼의 데이터 모델입니다.</param>
/// <param name="expandableButtonObj">하위 메뉴의 위치를 결정하는 데 사용될 주 확장 버튼의 UI GameObject입니다.</param>
public void ToggleSubMenu(ToolbarExpandableButton expandableButtonModel, GameObject expandableButtonObj)
{
bool closeOnly = false;
// 이미 하위 메뉴가 열려있는 경우
if (_currentSubMenu != null)
{
// 현재 열린 메뉴가 지금 클릭한 버튼에 의해 열린 것인지 확인
if (_ownerOfCurrentSubMenu == expandableButtonModel)
{
// 같은 버튼을 다시 클릭했으므로, 메뉴를 닫기만 하고 다시 열지 않습니다.
closeOnly = true;
}
// 기존에 열려있던 하위 메뉴를 정리합니다.
DestroyCurrentSubMenuAndClearListeners();
}
// 닫기만 해야 하는 경우, 여기서 함수를 종료합니다.
if (closeOnly)
{
return;
}
// --- 새 하위 메뉴를 여는 로직 ---
// 필요한 프리팹이 할당되었는지 확인합니다.
if (_view.subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: subMenuPanelPrefab이 할당되지 않아 하위 메뉴를 열 수 없습니다.", _view);
return;
}
// 하위 버튼이 하나라도 있는지 확인합니다.
if (expandableButtonModel.SubButtons == null || expandableButtonModel.SubButtons.Count == 0)
{
return; // 하위 버튼이 없으면 메뉴를 열지 않습니다.
}
// 하위 메뉴 패널 UI를 생성합니다. 생성 위치는 ToolbarView의 자식으로 합니다.
_currentSubMenu = Object.Instantiate(_view.subMenuPanelPrefab, _view.transform);
_currentSubMenu.name = $"SubMenu_{expandableButtonModel.Text}";
_ownerOfCurrentSubMenu = expandableButtonModel; // 이 메뉴를 연 버튼 모델을 기록합니다.
RectTransform panelRect = _currentSubMenu.GetComponent<RectTransform>();
if (panelRect == null)
{
Debug.LogError("ToolbarView: subMenuPanelPrefab에 RectTransform이 없습니다.", _currentSubMenu);
Object.Destroy(_currentSubMenu);
_currentSubMenu = null;
return;
}
// 하위 메뉴 패널의 위치와 피벗을 설정합니다.
RectTransform expandableButtonRect = expandableButtonObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0, 1);
panelRect.anchorMax = new Vector2(0, 1);
panelRect.pivot = new Vector2(0, 1); // 좌상단 기준
_currentSubMenu.SetActive(true);
// UI 계층에서 가장 마지막 자식으로 만들어 다른 UI 요소들 위에 표시되도록 합니다.
if (panelRect.parent != null) panelRect.SetAsLastSibling();
// 하위 메뉴 패널에 LayoutGroup이 있는지 확인하고, 없다면 기본값으로 추가합니다.
LayoutGroup subMenuLayoutGroup = _currentSubMenu.GetComponent<LayoutGroup>();
if (subMenuLayoutGroup == null)
{
subMenuLayoutGroup = _currentSubMenu.AddComponent<VerticalLayoutGroup>();
}
// 툴바의 레이아웃 방향(수직/수평)에 따라 하위 메뉴의 위치를 조정합니다.
Vector2 offset = new Vector2(expandableButtonRect.anchoredPosition.x, expandableButtonRect.anchoredPosition.y);
if (_view.layoutGroup is VerticalLayoutGroup)
{
// 툴바가 수직이면, 버튼 오른쪽에 메뉴 표시
offset.x += expandableButtonRect.rect.width;
}
else
{
// 툴바가 수평이면, 버튼 아래쪽에 메뉴 표시
if (subMenuLayoutGroup != null) offset.x -= subMenuLayoutGroup.padding.left;
offset.y -= expandableButtonRect.rect.height;
}
panelRect.anchoredPosition = offset;
// 확장 버튼 모델에 포함된 모든 하위 버튼들을 UI로 생성합니다.
foreach (var subItemBase in expandableButtonModel.SubButtons)
{
if (subItemBase is ToolbarButtonBase subItemModel)
{
// 하위 버튼 타입에 맞는 UI 처리기를 가져옵니다.
IButtonViewProcessor subProcessor = _view.GetButtonViewProcessor(subItemModel.GetType());
if (subProcessor == null)
{
Debug.LogWarning($"하위 버튼 '{subItemModel.Text}'에 대한 ViewProcessor를 찾을 수 없습니다.", _view);
continue;
}
// 하위 버튼 UI를 생성합니다.
GameObject subButtonObj = subProcessor.CreateButtonUI(subItemModel, panelRect, _view);
if (subButtonObj == null)
{
Debug.LogError($"하위 버튼 '{subItemModel.Text}'의 UI를 생성하지 못했습니다.", _view);
continue;
}
subButtonObj.name = $"SubItem_{subItemModel.Text}";
// 하위 버튼의 상호작용을 설정합니다. (클릭 이벤트 등)
subProcessor.SetupButtonInteractions(subItemModel, subButtonObj, _view);
// 하위 버튼의 공통 시각적 요소(텍스트, 아이콘 등)를 업데이트합니다.
_view.InternalUpdateCommonButtonVisuals(subItemModel, subButtonObj);
// 하위 버튼의 클릭 이벤트에 추가적인 로직을 연결합니다.
// (하위 메뉴를 닫고, 주 확장 버튼의 상태를 업데이트하는 등)
var buttonComponent = subButtonObj.GetComponent<Button>();
if (buttonComponent != null)
{
// 기존 리스너를 모두 지우고 새로 설정하여 중복을 방지합니다.
buttonComponent.onClick.RemoveAllListeners();
buttonComponent.onClick.AddListener(() =>
{
// 1. 주 확장 버튼 모델에 하위 버튼이 선택되었음을 알립니다.
// 이를 통해 주 버튼의 아이콘이나 텍스트가 선택된 하위 버튼의 것으로 변경될 수 있습니다.
expandableButtonModel.SelectSubButton(subItemModel);
// 2. 하위 버튼 자체의 커맨드를 실행합니다.
subItemModel.ExecuteClick();
// 3. 하위 메뉴를 닫습니다.
CloseSubMenu();
// 4. 툴팁이 남아있을 경우를 대비해 숨깁니다.
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
});
}
var toggleComponent = subButtonObj.GetComponent<Toggle>();
if (toggleComponent != null)
{
toggleComponent.onValueChanged.RemoveAllListeners();
toggleComponent.onValueChanged.AddListener((isSelected) =>
{
if (isSelected) // 토글은 선택될 때만 동작하도록 처리
{
expandableButtonModel.SelectSubButton(subItemModel);
subItemModel.ExecuteClick();
CloseSubMenu();
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
}
});
}
}
}
// 모든 하위 버튼이 추가된 후, 레이아웃 그룹의 크기를 자식 요소에 맞게 조절합니다.
if (subMenuLayoutGroup != null)
{
subMenuLayoutGroup.FitToChildren();
}
}
/// <summary>
/// 현재 열려있는 하위 메뉴를 닫습니다.
/// </summary>
public void CloseSubMenu()
{
DestroyCurrentSubMenuAndClearListeners();
}
/// <summary>
/// 현재 열려있는 하위 메뉴의 모든 UI 요소와 이벤트 리스너를 정리하고 GameObject를 파괴합니다.
/// </summary>
private void DestroyCurrentSubMenuAndClearListeners()
{
if (_currentSubMenu == null) return;
// 하위 메뉴에 있는 모든 버튼의 클릭 리스너를 제거합니다.
Button[] subButtons = _currentSubMenu.GetComponentsInChildren<Button>(true);
foreach (Button btn in subButtons)
{
btn.onClick.RemoveAllListeners();
}
// 하위 메뉴에 있는 모든 토글의 값 변경 리스너를 제거합니다.
Toggle[] subToggles = _currentSubMenu.GetComponentsInChildren<Toggle>(true);
foreach (Toggle tgl in subToggles)
{
tgl.onValueChanged.RemoveAllListeners();
}
// 하위 메뉴에 있는 모든 툴팁 핸들러의 액션을 정리합니다.
TooltipHandler[] subTooltipHandlers = _currentSubMenu.GetComponentsInChildren<TooltipHandler>(true);
foreach (TooltipHandler handler in subTooltipHandlers)
{
handler.OnPointerEnterAction = null;
handler.OnPointerExitAction = null;
}
// 하위 메뉴 GameObject를 파괴합니다.
Object.Destroy(_currentSubMenu);
_currentSubMenu = null;
_ownerOfCurrentSubMenu = null; // 메뉴 소유자 참조도 초기화합니다.
}
/// <summary>
/// 매 프레임 호출되어야 하는 로직을 처리합니다.
/// 현재는 하위 메뉴 외부를 클릭했을 때 메뉴를 닫는 기능을 수행합니다.
/// </summary>
public void Update()
{
// 마우스 왼쪽 버튼이 클릭되었고, 하위 메뉴가 열려있는 상태일 때
if (Input.GetMouseButtonDown(0) && _currentSubMenu != null && _currentSubMenu.activeSelf && _view.rootCanvas != null)
{
RectTransform subMenuRect = _currentSubMenu.GetComponent<RectTransform>();
if (subMenuRect == null) return;
// 캔버스의 렌더 모드에 따라 이벤트 카메라를 가져옵니다.
Camera eventCamera = null;
if (_view.rootCanvas.renderMode == RenderMode.ScreenSpaceCamera || _view.rootCanvas.renderMode == RenderMode.WorldSpace)
{
eventCamera = _view.rootCanvas.worldCamera;
}
// 마우스 포인터가 하위 메뉴 영역 바깥에 있는지 확인합니다.
if (!RectTransformUtility.RectangleContainsScreenPoint(subMenuRect, Input.mousePosition, eventCamera))
{
// 바깥을 클릭했다면 하위 메뉴를 닫습니다.
CloseSubMenu();
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d3d02215318a5a34f942ce2fd67dd75e

View File

@@ -0,0 +1,136 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace UVC.UI.Toolbar.View
{
/// <summary>
/// 툴바 내의 라디오 버튼 그룹(UnityEngine.UI.ToggleGroup)을 생성하고 관리하는 헬퍼 클래스입니다.
/// ToolbarView로부터 ToggleGroup 관련 로직을 위임받아 처리함으로써 ToolbarView의 복잡도를 낮추고 역할을 분리합니다.
/// </summary>
/// <remarks>
/// 주요 역할:
/// - ToggleGroup 동적 생성: 라디오 버튼 모델의 GroupName을 기반으로 해당하는 ToggleGroup을 찾거나 새로 생성합니다.
/// - 리소스 관리: 생성된 ToggleGroup GameObject들을 내부적으로 관리하며, 툴바가 정리될 때 함께 파괴하여 메모리 누수를 방지합니다.
/// - 레이아웃 독립성: 생성된 ToggleGroup GameObject가 툴바의 UI 레이아웃에 영향을 주지 않도록 LayoutElement 컴포넌트를 추가하고 `ignoreLayout`을 true로 설정합니다.
/// </remarks>
/// <example>
/// <code>
/// // 이 클래스는 ToolbarView 내부에서 다음과 같이 사용됩니다.
///
/// // 1. ToolbarView의 Awake 메서드에서 ToggleGroupManager 인스턴스 생성
/// // public class ToolbarView : MonoBehaviour
/// // {
/// // public Transform toolbarContainer;
/// // private ToggleGroupManager _toggleGroupManager;
/// //
/// // void Awake()
/// // {
/// // _toggleGroupManager = new ToggleGroupManager(toolbarContainer);
/// // }
/// // // ...
/// // }
///
/// // 2. 라디오 버튼(RadioButton)의 UI 프로세서에서 `GetOrCreateToggleGroup` 호출
/// // public class ToolbarRadioButtonViewProcessor : IButtonViewProcessor
/// // {
/// // public void SetupButtonInteractions(ToolbarButtonBase buttonModel, GameObject buttonUIObject, ToolbarView viewContext)
/// // {
/// // ToolbarRadioButton radioModel = buttonModel as ToolbarRadioButton;
/// // Toggle uiToggle = buttonUIObject.GetComponent<Toggle>();
/// // if (uiToggle != null)
/// // {
/// // // viewContext의 GetOrCreateToggleGroup을 호출하면 내부적으로 ToggleGroupManager의 메서드가 실행됩니다.
/// // uiToggle.group = viewContext.GetOrCreateToggleGroup(radioModel.GroupName);
/// // }
/// // }
/// // }
///
/// // 3. 툴바를 정리(Clear)할 때 Manager의 Clear 메서드 호출
/// // public class ToolbarView : MonoBehaviour
/// // {
/// // protected virtual void ClearToolbar()
/// // {
/// // // ... 다른 UI 요소 정리 ...
/// // _toggleGroupManager.Clear(); // 생성된 모든 토글 그룹 GameObject들을 파괴
/// // }
/// // }
/// </code>
/// </example>
public class ToggleGroupManager
{
private readonly Transform _container;
private readonly Dictionary<string, ToggleGroup> _toggleGroups = new Dictionary<string, ToggleGroup>();
/// <summary>
/// ToggleGroupManager의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="container">생성될 ToggleGroup GameObject들이 자식으로 추가될 부모 Transform입니다. 보통 툴바의 컨테이너를 지정합니다.</param>
public ToggleGroupManager(Transform container)
{
_container = container;
}
/// <summary>
/// 지정된 그룹 이름에 해당하는 UnityEngine.UI.ToggleGroup을 가져오거나, 없으면 새로 생성합니다.
/// </summary>
/// <param name="groupName">찾거나 생성할 토글 그룹의 이름입니다.</param>
/// <returns>요청된 이름에 해당하는 ToggleGroup 컴포넌트입니다.</returns>
public ToggleGroup GetOrCreateToggleGroup(string groupName)
{
if (string.IsNullOrEmpty(groupName))
{
Debug.LogError("ToggleGroupManager: GetOrCreateToggleGroup - groupName이 null이거나 비어있습니다.");
// 그룹 이름이 없는 비정상적인 경우를 위해 임시 그룹을 생성하여 반환합니다.
GameObject tempGroupObj = new GameObject($"ToggleGroup_UnnamedDynamic");
tempGroupObj.transform.SetParent(_container, false);
return tempGroupObj.AddComponent<ToggleGroup>();
}
// 캐시된 딕셔너리에서 그룹 이름으로 ToggleGroup을 찾아봅니다.
if (!_toggleGroups.TryGetValue(groupName, out ToggleGroup group))
{
// 해당하는 ToggleGroup이 없으면 새로 생성합니다.
GameObject groupObj = new GameObject($"ToggleGroup_{groupName}");
groupObj.transform.SetParent(_container);
// RectTransform을 추가하고 크기를 0으로 설정하여 레이아웃에 영향을 주지 않도록 합니다.
RectTransform groupRect = groupObj.AddComponent<RectTransform>();
groupRect.sizeDelta = Vector2.zero;
groupRect.anchoredPosition = Vector2.zero;
groupRect.anchorMin = Vector2.zero;
groupRect.anchorMax = Vector2.one;
// ToggleGroup 컴포넌트를 추가합니다.
group = groupObj.AddComponent<ToggleGroup>();
group.allowSwitchOff = false; // 라디오 버튼 그룹은 일반적으로 항상 하나가 선택된 상태를 유지해야 하므로, 선택 해제를 허용하지 않습니다.
// LayoutElement를 추가하고 ignoreLayout을 true로 설정하여 부모의 LayoutGroup 계산에서 이 GameObject를 무시하도록 합니다.
LayoutElement element = groupObj.AddComponent<LayoutElement>();
element.ignoreLayout = true;
// 새로 생성된 그룹을 딕셔너리에 추가하여 다음 요청 시 재사용할 수 있도록 합니다.
_toggleGroups.Add(groupName, group);
}
return group;
}
/// <summary>
/// 이 매니저를 통해 생성된 모든 ToggleGroup GameObject들을 파괴하고 내부 캐시를 비웁니다.
/// 툴바가 다시 렌더링되거나 파괴될 때 호출하여 메모리 누수를 방지합니다.
/// </summary>
public void Clear()
{
if (_toggleGroups == null) return;
foreach (var groupObj in _toggleGroups.Values)
{
if (groupObj != null && groupObj.gameObject != null)
{
// 생성했던 GameObject를 파괴합니다.
Object.Destroy(groupObj.gameObject);
}
}
_toggleGroups.Clear();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9b063a5e328b734fa10fbf1d06bde5c

View File

@@ -1,7 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Toolbar.Model;
using UVC.UI.ToolBar.View;
namespace UVC.UI.Toolbar.View
{

View File

@@ -1,7 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Toolbar.Model;
using UVC.UI.ToolBar.View;
namespace UVC.UI.Toolbar.View
{

View File

@@ -1,7 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Toolbar.Model;
using UVC.UI.ToolBar.View;
namespace UVC.UI.Toolbar.View
{

View File

@@ -1,7 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Toolbar.Model;
using UVC.UI.ToolBar.View;
namespace UVC.UI.Toolbar.View
{

View File

@@ -5,8 +5,6 @@ using UnityEngine.UI;
using UVC.Extension;
using UVC.Locale;
using UVC.UI.Toolbar.Model;
using UVC.UI.ToolBar;
using UVC.UI.ToolBar.View;
using UVC.UI.Tooltip;
namespace UVC.UI.Toolbar.View
@@ -23,8 +21,7 @@ namespace UVC.UI.Toolbar.View
/// 또한, 모델의 OnStateChanged, OnToggleStateChanged 등의 이벤트를 구독하여 모델 상태 변경 시 UI를 업데이트합니다.
/// - UI 업데이트: 모델의 상태(텍스트, 아이콘, 활성화 상태, 선택 상태 등)가 변경되면 해당 UI 요소의 시각적 표현을 업데이트합니다.
/// - 리소스 관리: 생성된 UI GameObject들과 이벤트 구독을 관리하며, 툴바가 파괴되거나 다시 렌더링될 때 정리합니다(ClearToolbar).
/// - 하위 메뉴 관리: ToolbarExpandableButton 클릭 시 하위 메뉴 UI를 생성하고 토글합니다.
/// - 툴팁 관리: 각 버튼에 TooltipHandler를 추가하여 툴팁 기능을 제공합니다.
/// - 하위 로직 위임: 라디오 버튼 그룹 관리는 `ToggleGroupManager`에, 확장 버튼의 하위 메뉴 관리는 `SubMenuHandler`에 위임하여 클래스의 복잡도를 낮춥니다.
///
/// 이 클래스의 인스턴스는 Unity 씬 내의 GameObject에 컴포넌트로 추가되어야 하며,
/// Inspector를 통해 필요한 프리팹들(standardButtonPrefab, toggleButtonPrefab 등)과
@@ -40,67 +37,66 @@ namespace UVC.UI.Toolbar.View
/// // - Expandable Button Prefab: 확장 버튼 UI 프리팹
/// // - Separator Prefab: 구분선 UI 프리팹
/// // - Sub Menu Panel Prefab: 확장 버튼의 하위 메뉴 패널 UI 프리팹
/// // - Toolbar Container: 생성된 툴바 항목 UI들이 자식으로 추가될 Transform (보통 이 GameObject 자체 또는 자식 Panel)
/// // - Layout Group: Toolbar Container에 연결된 LayoutGroup (예: VerticalLayoutGroup, HorizontalLayoutGroup)
/// // - Root Canvas: (선택 사항, TooltipManager 초기화에 필요하며 보통 자동으로 찾음)
/// //
/// // 3. Toolbar (또는 유사 클래스)에서 ToolbarModel을 설정한 후,
/// // ToolbarView의 Initialize 메서드를 호출하여 툴바 UI를 생성합니다.
/// // Toolbar controller = GetComponent<Toolbar>(); // 또는 다른 방식으로 참조
/// // ToolbarModel model = controller.GetConfiguredModel(); // 가정: 컨트롤러가 모델을 반환
/// // Initialize(model);
/// // - 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 메서드를 통해 외부(주로 Toolbar)에서 주입받습니다.
/// Initialize 메서드를 통해 외부에서 주입받습니다.
/// </summary>
protected ToolbarModel ToolbarModel { get; private set; }
// --- Inspector에서 할당할 프리팹 및 UI 요소들 ---
[Header("UI Prefabs")]
/// <summary>
/// 표준 버튼 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("표준 버튼 UI에 사용될 프리팹입니다.")]
public GameObject standardButtonPrefab;
/// <summary>
/// 토글 버튼 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("토글 버튼 UI에 사용될 프리팹입니다.")]
public GameObject toggleButtonPrefab;
/// <summary>
/// 라디오 버튼 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("라디오 버튼 UI에 사용될 프리팹입니다.")]
public GameObject radioButtonPrefab;
/// <summary>
/// 확장 가능한 버튼 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("확장 가능한 버튼 UI에 사용될 프리팹입니다.")]
public GameObject expandableButtonPrefab;
/// <summary>
/// 구분선 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("구분선 UI에 사용될 프리팹입니다.")]
public GameObject separatorPrefab;
/// <summary>
/// 확장 버튼의 하위 메뉴 패널 UI에 사용될 프리팹입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("확장 버튼의 하위 메뉴 패널 UI에 사용될 프리팹입니다.")]
public GameObject subMenuPanelPrefab;
[Header("UI Layout")]
/// <summary>
/// 생성된 툴바 항목 UI들이 자식으로 추가될 부모 Transform입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("생성된 툴바 항목 UI들이 자식으로 추가될 부모 Transform입니다.")]
public Transform toolbarContainer;
/// <summary>
/// 툴바 항목들의 자동 배치를 담당하는 LayoutGroup 컴포넌트입니다. Inspector를 통해 할당해야 합니다.
/// </summary>
[Tooltip("툴바 항목들의 자동 배치를 담당하는 LayoutGroup 컴포넌트입니다 (예: VerticalLayoutGroup).")]
public LayoutGroup layoutGroup;
@@ -112,73 +108,77 @@ namespace UVC.UI.Toolbar.View
/// <summary>
/// 버튼 타입별 UI 처리기를 저장하는 딕셔너리입니다.
/// 각 버튼 타입(표준, 토글 등)에 맞는 IButtonViewProcessor 구현체를 등록하여 사용합니다.
/// </summary>
private Dictionary<System.Type, IButtonViewProcessor> _buttonViewProcessors = new Dictionary<System.Type, IButtonViewProcessor>();
/// <summary>
/// 툴팁 표시에 사용될 루트 Canvas입니다.
/// Inspector에서 할당하거나, Awake에서 자동으로 찾으려고 시도합니다.
/// </summary>
protected Canvas rootCanvas;
internal Canvas rootCanvas;
// --- 헬퍼 클래스 ---
private ToggleGroupManager _toggleGroupManager;
private SubMenuHandler _subMenuHandler;
/// <summary>
/// MonoBehaviour의 Awake 메서드입니다.
/// toolbarContainer, layoutGroup, rootCanvas 등의 필수 참조를 초기화하고,
/// TooltipManager를 초기화합니다.
/// 필수 참조를 확인 및 초기화하고, 버튼 뷰 프로세서와 헬퍼 클래스들을 준비합니다.
/// </summary>
protected virtual void Awake()
{
// 필수 프리팹들이 Inspector에서 할당되었는지 확인합니다.
if (standardButtonPrefab == null || toggleButtonPrefab == null || radioButtonPrefab == null ||
expandableButtonPrefab == null || separatorPrefab == null || subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: 필수 프리팹이 할당되지 않았습니다. Inspector에서 모든 프리팹을 설정해주세요.", this);
}
// toolbarContainer 자동 할당 (Inspector에서 할당되지 않은 경우)
// UI 컨테이너와 레이아웃 그룹을 자동으로 찾거나 설정합니다.
if (toolbarContainer == null) toolbarContainer = GetComponent<Transform>();
if (toolbarContainer == null) toolbarContainer = GetComponentInChildren<Transform>(true); // 비활성 자식도 포함
if (toolbarContainer == null) Debug.LogError("ToolbarView: toolbarContainer가 할당되지 않았습니다. Inspector에서 설정하거나 현재 GameObject에 Transform이 있어야 합니다.", this);
if (toolbarContainer == null) toolbarContainer = GetComponentInChildren<Transform>(true);
if (toolbarContainer == null) Debug.LogError("ToolbarView: toolbarContainer가 할당되지 않았습니다.", this);
// layoutGroup 자동 할당 및 추가 (Inspector에서 할당되지 않은 경우)
if (layoutGroup == null && toolbarContainer != null)
{
layoutGroup = toolbarContainer.gameObject.GetComponent<LayoutGroup>();
if (layoutGroup == null)
{
// 기본으로 VerticalLayoutGroup 추가. 필요시 프로젝트에 맞게 수정.
layoutGroup = toolbarContainer.gameObject.AddComponent<VerticalLayoutGroup>();
Debug.LogWarning("ToolbarView: LayoutGroup이 toolbarContainer에 없어 새로 추가합니다. (기본 VerticalLayoutGroup)", this);
}
}
if (layoutGroup == null) Debug.LogError("ToolbarView: layoutGroup이 할당되지 않았습니다. toolbarContainer에 LayoutGroup 컴포넌트를 추가해주세요.", this);
if (layoutGroup == null) Debug.LogError("ToolbarView: layoutGroup이 할당되지 않았습니다.", this);
// rootCanvas 자동 찾기 (TooltipManager 초기화에 필요)
// 툴팁 표시에 필요한 루트 캔버스를 찾습니다.
if (rootCanvas == null) rootCanvas = GetComponentInParent<Canvas>();
if (rootCanvas == null)
{
Canvas[] canvases = FindObjectsByType<Canvas>(FindObjectsSortMode.None); // Unity 2023부터 FindObjectsByType 사용 권장
Canvas[] canvases = FindObjectsByType<Canvas>(FindObjectsSortMode.None);
foreach (Canvas c in canvases) { if (c.isRootCanvas) { rootCanvas = c; break; } }
if (rootCanvas == null && canvases.Length > 0) rootCanvas = canvases[0]; // 최후의 수단
if (rootCanvas == null && canvases.Length > 0) rootCanvas = canvases[0];
}
// TooltipManager 초기화
// 툴팁 매니저를 초기화합니다.
if (rootCanvas != null)
{
if (!TooltipManager.Instance.IsInitialized) TooltipManager.Instance.Initialize(rootCanvas.transform, rootCanvas);
}
else
{
Debug.LogError("ToolbarView: rootCanvas를 찾을 수 없어 TooltipVisualManager를 초기화할 수 없습니다.");
Debug.LogError("ToolbarView: rootCanvas를 찾을 수 없어 TooltipManager를 초기화할 수 없습니다.");
}
// 버튼 뷰 처리기 등록
// 버튼 타입에 대한 뷰 프로세서를 등록합니다.
_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>
@@ -190,19 +190,19 @@ namespace UVC.UI.Toolbar.View
{
this.ToolbarModel = toolbarModel;
// 필수 조건들을 다시 한 번 확인합니다.
if (standardButtonPrefab == null || toggleButtonPrefab == null || radioButtonPrefab == null ||
expandableButtonPrefab == null || separatorPrefab == null || subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: 필수 프리팹이 할당되지 않았습니다. Inspector에서 모든 프리팹을 설정해주세요.", this);
Debug.LogError("ToolbarView: 필수 프리팹이 할당되지 않았습니다.", this);
return;
}
if (toolbarContainer == null)
{
Debug.LogError("ToolbarView: Initialize 실패. toolbarContainer가 할당되지 않았습니다.", this);
return;
}
if (layoutGroup == null) // layoutGroup도 필수적이므로 확인
if (layoutGroup == null)
{
Debug.LogError("ToolbarView: Initialize 실패. layoutGroup이 할당되지 않았습니다.", this);
return;
@@ -210,20 +210,17 @@ namespace UVC.UI.Toolbar.View
if (this.ToolbarModel == null)
{
Debug.LogError("ToolbarView: Initialize 실패. 제공된 toolbarModel이 null입니다.", this);
ClearToolbar(); // 모델이 없으면 기존 UI라도 정리
ClearToolbar(); // 모델이 없으면 기존 UI라도 정리합니다.
return;
}
// 모든 조건이 충족되면 툴바 렌더링을 시작합니다.
RenderToolbar();
}
// 현재 열린 하위 메뉴의 원인 제공자(확장 버튼 모델) 추적용
protected ToolbarExpandableButton _ownerOfCurrentSubMenu;
/// <summary>
/// 현재 툴바에 표시된 모든 UI 요소들을 제거하고, 관련된 이벤트 구독을 해제합니다.
/// _modelToGameObjectMap, _toggleGroups를 비우고, 열려있는 하위 메뉴(currentSubMenu)도 파괴합니다.
/// 툴팁도 숨깁니다.
/// 툴바를 새로 그리거나 뷰가 파괴될 때 호출됩니다.
/// </summary>
protected virtual void ClearToolbar()
{
@@ -231,57 +228,39 @@ namespace UVC.UI.Toolbar.View
{
foreach (var pair in _modelToGameObjectMap)
{
if (pair.Key != null) // 모델(버튼)에 연결된 이벤트 구독 해제
if (pair.Key != null)
{
// ToolbarButtonBase 및 파생 클래스에 정의된 ClearEventHandlers를 호출하여
// 모델에 연결된 모든 이벤트 구독을 명시적으로 해제합니다.
pair.Key.ClearEventHandlers();
}
if (pair.Value != null) // UI GameObject 파괴
{
// UI 컴포넌트의 이벤트 리스너 명시적 해제
// ToggleButton 또는 RadioButton의 경우 Toggle 컴포넌트 리스너 해제
if (pair.Value != null)
{
// UI 컴포넌트의 이벤트 리스너 명시적으로 해제합니다.
Toggle toggleComponent = pair.Value.GetComponent<Toggle>();
if (toggleComponent != null)
{
toggleComponent.onValueChanged.RemoveAllListeners();
}
if (toggleComponent != null) toggleComponent.onValueChanged.RemoveAllListeners();
// StandardButton 또는 ExpandableButton의 경우 Button 컴포넌트 리스너 해제
// (필요에 따라 다른 버튼 타입의 리스너도 유사하게 처리)
Button buttonComponent = pair.Value.GetComponent<Button>();
if (buttonComponent != null)
{
buttonComponent.onClick.RemoveAllListeners();
}
if (buttonComponent != null) buttonComponent.onClick.RemoveAllListeners();
TooltipHandler handler = pair.Value.GetComponent<TooltipHandler>();
if (handler != null) // 툴팁 핸들러 이벤트도 정리 (필요시)
if (handler != null)
{
handler.OnPointerEnterAction = null;
handler.OnPointerExitAction = null;
}
// UI GameObject를 파괴합니다.
Destroy(pair.Value);
}
}
_modelToGameObjectMap.Clear();
}
if (_toggleGroups != null) // 토글 그룹 GameObject들도 정리
{
foreach (var groupObj in _toggleGroups.Values)
{
if (groupObj != null && groupObj.gameObject != null) Destroy(groupObj.gameObject);
}
_toggleGroups.Clear();
}
// 헬퍼 클래스들이 관리하던 리소스도 정리합니다.
_toggleGroupManager?.Clear();
_subMenuHandler?.CloseSubMenu();
if (currentSubMenu != null) // 열려있는 하위 메뉴 파괴
{
DestroyCurrentSubMenuAndClearListeners(); // 헬퍼 메서드 사용
}
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized) // 툴팁 숨기기
// 툴팁이 화면에 남아있을 경우를 대비해 숨깁니다.
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
TooltipManager.Instance.HideTooltip();
}
@@ -292,24 +271,17 @@ namespace UVC.UI.Toolbar.View
/// </summary>
/// <param name="buttonModelType">버튼 모델의 System.Type 객체입니다.</param>
/// <returns>해당 타입의 처리기. 없으면 null을 반환합니다.</returns>
protected IButtonViewProcessor GetButtonViewProcessor(System.Type buttonModelType)
internal IButtonViewProcessor GetButtonViewProcessor(System.Type buttonModelType)
{
if (_buttonViewProcessors.TryGetValue(buttonModelType, out var processor))
{
return processor;
}
// 상속 관계를 고려하여 부모 타입의 처리기를 찾을 수도 있습니다.
// 예를 들어, MyCustomButton : ToolbarStandardButton 인 경우, ToolbarStandardButton 처리기를 반환할 수 있습니다.
// 이 로직은 필요에 따라 확장할 수 있습니다.
// 현재는 정확한 타입 매칭만 지원합니다.
return null;
}
/// <summary>
/// ToolbarModel에 정의된 항목들을 기반으로 실제 UI 요소들을 생성하여 툴바를 구성합니다.
/// 먼저 ClearToolbar를 호출하여 기존 UI를 정리한 후, 모델의 각 항목에 대해 적절한 프리팹을 사용하여
/// UI GameObject를 생성하고, 필요한 설정(이벤트 바인딩, 툴팁 등)을 수행합니다.
/// 버튼 항목의 경우 IButtonViewProcessor 구현체에 UI 생성을 위임합니다.
/// </summary>
protected void RenderToolbar()
{
@@ -322,41 +294,42 @@ namespace UVC.UI.Toolbar.View
return;
}
// 모델의 각 아이템을 순회하며 UI를 생성합니다.
foreach (var itemModel in ToolbarModel.Items)
{
GameObject itemUIObject = null;// 생성될 UI GameObject
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) // ToolbarRadioButton도 ToolbarToggleButton을 상속
if (buttonModel is ToolbarToggleButton toggleModel)
{
toggleModel.OnToggleStateChanged += (isSelected) => processor.UpdateToggleStateVisuals(toggleModel, itemUIObject, isSelected, this);
}
// 툴팁 핸들러 추가 및 설정
// 툴팁 설정
if (!string.IsNullOrEmpty(buttonModel.Tooltip))
{
TooltipHandler tooltipHandler = itemUIObject.GetComponent<TooltipHandler>();
if (tooltipHandler == null) tooltipHandler = itemUIObject.AddComponent<TooltipHandler>();
TooltipHandler tooltipHandler = itemUIObject.GetComponent<TooltipHandler>() ?? itemUIObject.AddComponent<TooltipHandler>();
tooltipHandler.Tooltip = buttonModel.Tooltip;
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
@@ -368,18 +341,16 @@ namespace UVC.UI.Toolbar.View
else
{
Debug.LogError($"ToolbarView: {buttonModel.GetType().Name}의 UI를 생성하지 못했습니다 (Processor 반환 null).", this);
continue;
}
}
else
{
Debug.LogError($"ToolbarView: {buttonModel.GetType().Name}에 대한 프리팹이 할당되지 않았거나 알 수 없는 버튼 타입입니다.", this);
continue;
}
}
else
{
Debug.LogWarning($"ToolbarView: 알 수 없는 IToolbarItem 타입 ({itemModel.GetType().Name})이거나, UI 생성이 지원되지 않습니다.", this);
Debug.LogWarning($"ToolbarView: 알 수 없는 IToolbarItem 타입 ({itemModel.GetType().Name})니다.", this);
}
if (itemUIObject != null)
@@ -388,11 +359,9 @@ namespace UVC.UI.Toolbar.View
}
}
// 모든 UI 항목이 추가된 후 레이아웃 그룹 업데이트
// 모든 UI 항목이 추가된 후 레이아웃을 강제로 업데이트합니다.
if (layoutGroup != null)
{
// LayoutRebuilder.ForceRebuildLayoutImmediate(layoutGroup.GetComponent<RectTransform>()); // 즉시 업데이트
// 또는 FitToChildren 확장 메서드 사용 (UVC.Extension에 정의되어 있다고 가정)
layoutGroup.FitToChildren(width: layoutGroup is VerticalLayoutGroup, height: true);
}
}
@@ -407,7 +376,7 @@ namespace UVC.UI.Toolbar.View
{
if (model == null || itemObj == null) return;
// 1. 텍스트 업데이트
// 1. 텍스트 업데이트 (다국어 지원 포함)
TextMeshProUGUI buttonTextComponent = itemObj.GetComponentInChildren<TextMeshProUGUI>(true);
if (buttonTextComponent != null)
{
@@ -420,18 +389,12 @@ namespace UVC.UI.Toolbar.View
}
// 2. 아이콘 업데이트
Image iconImageComponent = null;
Transform iconTransform = itemObj.transform.Find("Icon");
if (iconTransform != null) iconImageComponent = iconTransform.GetComponent<Image>();
if (iconImageComponent == null) iconImageComponent = itemObj.GetComponentInChildren<Image>(true);
Image iconImageComponent = itemObj.transform.Find("Icon")?.GetComponent<Image>() ?? itemObj.GetComponentInChildren<Image>(true);
if (iconImageComponent != null)
{
string iconPathToLoad = model.IconSpritePath;
if (model is ToolbarToggleButton toggleButton)
{
iconPathToLoad = toggleButton.IsSelected ? toggleButton.IconSpritePath : toggleButton.OffIconSpritePath;
}
string iconPathToLoad = (model is ToolbarToggleButton toggleButton && !toggleButton.IsSelected)
? toggleButton.OffIconSpritePath
: model.IconSpritePath;
if (!string.IsNullOrEmpty(iconPathToLoad))
{
@@ -454,279 +417,20 @@ namespace UVC.UI.Toolbar.View
}
}
protected Dictionary<string, ToggleGroup> _toggleGroups = new Dictionary<string, ToggleGroup>();
/// <summary>
/// 라디오 버튼 그룹 이름에 해당하는 UnityEngine.UI.ToggleGroup을 가져오거나, 없으면 새로 생성합니다.
/// 생성된 ToggleGroup GameObject는 toolbarContainer의 자식으로 추가되며, 레이아웃에 영향을 주지 않도록 설정됩니다.
/// 라디오 버튼 그룹 이름에 해당하는 ToggleGroup을 가져오거나 생성합니다. (ToggleGroupManager에 위임)
/// </summary>
/// <param name="groupName">찾거나 생성할 토글 그룹의 이름입니다.</param>
/// <returns>해당 이름의 ToggleGroup 컴포넌트입니다.</returns>
internal ToggleGroup GetOrCreateToggleGroup(string groupName)
{
if (string.IsNullOrEmpty(groupName))
{
Debug.LogError("ToolbarView: GetOrCreateToggleGroup - groupName이 null이거나 비어있습니다.", this);
// 임시 그룹 또는 null 반환 등의 예외 처리 필요
GameObject tempGroupObj = new GameObject($"ToggleGroup_UnnamedDynamic");
tempGroupObj.transform.SetParent(toolbarContainer, false);
return tempGroupObj.AddComponent<ToggleGroup>();
}
if (!_toggleGroups.TryGetValue(groupName, out ToggleGroup group))
{
// 새 ToggleGroup GameObject 생성
GameObject groupObj = new GameObject($"ToggleGroup_{groupName}");
groupObj.transform.SetParent(toolbarContainer);
// RectTransform 설정 (크기가 0이 되도록 하여 레이아웃에 영향 최소화)
RectTransform groupRect = groupObj.AddComponent<RectTransform>();
groupRect.sizeDelta = new Vector2(0, 0); // 크기는 필요에 따라 조정
groupRect.anchoredPosition = Vector2.zero;
groupRect.sizeDelta = Vector2.zero;
groupRect.anchorMin = Vector2.zero;
groupRect.anchorMax = Vector2.one;
group = groupObj.AddComponent<ToggleGroup>();
group.allowSwitchOff = false; // 라디오 버튼 그룹은 일반적으로 하나는 선택되어 있도록 함
LayoutElement element = groupObj.AddComponent<LayoutElement>();
element.ignoreLayout = true; // 레이아웃 그룹에서 무시하도록 설정
_toggleGroups.Add(groupName, group);
}
return group;
return _toggleGroupManager.GetOrCreateToggleGroup(groupName);
}
/// <summary>
/// 현재 열려있는 하위 메뉴(currentSubMenu)의 자식 UI 요소들의 이벤트 리스너를 제거하고,
/// 하위 메뉴 GameObject를 파괴한 후 currentSubMenu 참조를 null로 설정합니다.
/// 확장 버튼의 하위 메뉴를 토글합니다. (SubMenuHandler에 위임)
/// </summary>
protected void DestroyCurrentSubMenuAndClearListeners()
{
if (currentSubMenu == null) return;
// currentSubMenu의 자식들을 순회하며 Button 리스너 제거
Button[] subButtons = currentSubMenu.GetComponentsInChildren<Button>(true);
foreach (Button btn in subButtons)
{
btn.onClick.RemoveAllListeners();
}
// currentSubMenu의 자식들을 순회하며 Toggle 리스너 제거
Toggle[] subToggles = currentSubMenu.GetComponentsInChildren<Toggle>(true);
foreach (Toggle tgl in subToggles)
{
tgl.onValueChanged.RemoveAllListeners();
}
// currentSubMenu의 자식들에 있는 TooltipHandler 정리 (필요시)
TooltipHandler[] subTooltipHandlers = currentSubMenu.GetComponentsInChildren<TooltipHandler>(true);
foreach (TooltipHandler handler in subTooltipHandlers)
{
handler.OnPointerEnterAction = null;
handler.OnPointerExitAction = null;
}
Destroy(currentSubMenu);
currentSubMenu = null;
_ownerOfCurrentSubMenu = null; // 하위 메뉴 소유자 참조도 초기화
}
/// <summary>
/// 현재 열려있는 하위 메뉴 UI GameObject입니다. 없으면 null입니다.
/// </summary>
protected GameObject currentSubMenu = null;
/// <summary>
/// 확장 가능한 버튼(ToolbarExpandableButton)에 대한 하위 메뉴 UI를 토글합니다.
/// 이미 열려있으면 닫고, 닫혀있으면 엽니다.
/// 하위 메뉴는 subMenuPanelPrefab을 사용하여 생성되며, expandableButtonObj의 위치를 기준으로 표시됩니다.
/// </summary>
/// <param name="expandableButtonModel">하위 버튼 목록을 가진 확장 버튼 모델입니다.</param>
/// <param name="expandableButtonObj">주 확장 버튼의 UI GameObject로, 하위 메뉴 위치 결정에 사용됩니다.</param>
internal void ToggleSubMenu(ToolbarExpandableButton expandableButtonModel, GameObject expandableButtonObj)
{
bool closeOnly = false;
// 이미 다른 하위 메뉴가 열려있거나, 현재 클릭한 버튼의 하위 메뉴가 열려있다면 닫습니다.
if (currentSubMenu != null)
{
// 현재 열린 하위 메뉴가 지금 클릭한 확장 버튼에 의해 열린 것인지 확인
if (_ownerOfCurrentSubMenu == expandableButtonModel)
{
closeOnly = true; // 같은 버튼을 다시 클릭했으므로 닫기만 함
}
DestroyCurrentSubMenuAndClearListeners(); // 기존 하위 메뉴 정리 (리스너 포함)
// 만약 현재 클릭한 버튼의 하위 메뉴가 이미 열려있어서 닫는 경우라면, 여기서 함수 종료
// (다시 열리지 않도록). 이 로직은 currentSubMenu가 어떤 버튼에 의해 열렸는지 알아야 함.
// 간단하게는, 어떤 확장 버튼이든 클릭하면 기존 서브메뉴는 닫고, 필요하면 새로 연다.
// 지금은 그냥 닫고, 아래 로직에서 새로 열 조건이 되면 열도록 함.
// 만약 클릭한 버튼이 이미 열린 메뉴의 주인이면, 그냥 닫기만 하고 리턴하는게 자연스러울 수 있음.
// 이 부분은 UI/UX 정책에 따라 상세 조정 필요. 현재는 "토글"이므로, 다시 누르면 닫히고, 없으면 연다.
// 즉, 아래 로직에서 currentSubMenu가 null이 되었으므로, 다시 열릴 수 있음.
// 만약 "닫기만" 하고 싶다면, 여기서 return 해야 함.
// 현재 로직: 이미 열려있으면 무조건 닫고, 아래에서 다시 열지 결정.
// 수정 제안: 만약 currentSubMenu가 이 expandableButtonModel에 의해 열린 것이라면, 닫고 return.
// (이를 위해서는 currentSubMenu가 어떤 모델에 연결되었는지 정보가 필요)
// 현재는 간단히 토글로 동작: 열려있으면 닫고, 닫혀있으면 연다.
// 즉, if (currentSubMenu != null) { Destroy; currentSubMenu = null; return; } 이면 닫기만 하고 끝.
// 지금은 닫고, 아래에서 다시 열 수 있게 함.
}
if (closeOnly)
{
return; // 닫기만 하고 새로운 메뉴를 열지 않음
}
// 새 하위 메뉴를 열 조건 확인 (닫혀 있었고, 프리팹과 하위 버튼이 있는 경우)
if (subMenuPanelPrefab == null)
{
Debug.LogError("ToolbarView: subMenuPanelPrefab이 할당되지 않아 하위 메뉴를 열 수 없습니다.", this);
return;
}
if (expandableButtonModel.SubButtons == null || expandableButtonModel.SubButtons.Count == 0)
{
// Debug.LogWarning($"ToolbarView: 확장 버튼 '{expandableButtonModel.Text}'에 하위 버튼이 없어 메뉴를 열지 않습니다.", this);
return; // 하위 버튼 없으면 메뉴 열지 않음
}
// 하위 메뉴 패널 UI 생성
// 생성 위치는 일단 ToolbarView의 자식으로 하고, 이후 위치 조정
currentSubMenu = Instantiate(subMenuPanelPrefab, transform);
currentSubMenu.name = $"SubMenu_{expandableButtonModel.Text}";
_ownerOfCurrentSubMenu = expandableButtonModel; // 새 하위 메뉴의 소유자 설정
RectTransform panelRect = currentSubMenu.GetComponent<RectTransform>();
if (panelRect == null)
{
Debug.LogError("ToolbarView: subMenuPanelPrefab에 RectTransform이 없습니다.", currentSubMenu);
Destroy(currentSubMenu); currentSubMenu = null;
return;
}
// 하위 메뉴 패널 위치 설정 (확장 버튼 UI 오른쪽 또는 아래 등)
// 이 로직은 툴바의 방향(수평/수직)과 UI 디자인에 따라 매우 달라질 수 있습니다.
// 여기서는 간단한 예시로, 확장 버튼의 오른쪽에 표시한다고 가정합니다.
RectTransform expandableButtonRect = expandableButtonObj.GetComponent<RectTransform>();
// panelRect의 앵커 및 피벗 설정 (예: 좌상단 기준)
panelRect.anchorMin = new Vector2(0, 1);
panelRect.anchorMax = new Vector2(0, 1);
panelRect.pivot = new Vector2(0, 1); // 좌상단 피벗
currentSubMenu.SetActive(true);
// 부모 내에서 가장 마지막 자식으로 만들어 최상단에 표시되도록 합니다.
if (panelRect.parent != null) panelRect.SetAsLastSibling();
LayoutGroup subMenuLayoutGroup = currentSubMenu.GetComponent<LayoutGroup>();
if (subMenuLayoutGroup == null)
{
// 하위 메뉴 패널에 LayoutGroup이 없다면 추가합니다.
subMenuLayoutGroup = currentSubMenu.AddComponent<VerticalLayoutGroup>();
}
// 패널 위치를 확장 버튼의 위치에 맞춥니다.
Vector2 offset = new Vector2(expandableButtonRect.anchoredPosition.x, expandableButtonRect.anchoredPosition.y);
if (layoutGroup is VerticalLayoutGroup)
{
offset.x += expandableButtonRect.rect.width; // 수직 레이아웃인 경우 버튼 오른쪽에 표시 되도록
}
else
{
if (subMenuLayoutGroup != null) offset.x -= subMenuLayoutGroup.padding.left; // 수평 레이아웃인 경우 패딩을 고려
offset.y -= expandableButtonRect.rect.height; // 수평 레이아웃인 경우 버튼 아래에 표시 되도록
}
panelRect.anchoredPosition = offset; // 위치 조정
// 하위 버튼들 생성. 하위 메뉴 패널에 LayoutGroup이 있다면 자식 버튼들이 자동으로 정렬됩니다.
foreach (var subItemBase in expandableButtonModel.SubButtons)
{
if (subItemBase is ToolbarButtonBase subItemModel) // 모든 하위 아이템은 ToolbarButtonBase라고 가정
{
IButtonViewProcessor subProcessor = GetButtonViewProcessor(subItemModel.GetType());
// 하위 버튼도 적절한 프리팹을 사용해야 합니다.
// 여기서는 모든 하위 버튼이 standardButtonPrefab을 사용한다고 가정합니다.
// 실제로는 subItemModel의 타입에 따라 다른 프리팹을 선택할 수 있습니다.
GameObject subButtonPrefabToUse = standardButtonPrefab; // 기본값
if (subProcessor != null) // 하위 버튼도 Processor를 통해 생성 시도
{
if (subItemModel is ToolbarToggleButton) subButtonPrefabToUse = toggleButtonPrefab;
else subButtonPrefabToUse = standardButtonPrefab; // 기본은 표준 버튼
// ... 다른 타입에 대한 처리 ...
if (subButtonPrefabToUse == null)
{
Debug.LogError($"ToolbarView: 하위 버튼 '{subItemModel.Text}'에 대한 프리팹을 결정할 수 없습니다 (standardButtonPrefab 사용 시도).", this);
continue;
}
GameObject subButtonObj = Instantiate(subButtonPrefabToUse, panelRect); // 하위 메뉴 패널의 자식으로 생성
subButtonObj.name = $"SubItem_{subItemModel.Text}";
// 하위 버튼의 시각적 요소 설정 및 상호작용 연결
InternalUpdateCommonButtonVisuals(subItemModel, subButtonObj); // 공통 UI 업데이트
// 하위 버튼 클릭 이벤트 연결
Button subUiButton = subButtonObj.GetComponent<Button>(); // 또는 Toggle 등 타입에 맞는 컴포넌트
Toggle subUiToggle = subButtonObj.GetComponent<Toggle>();
if (subUiButton != null)
{
subUiButton.interactable = subItemModel.IsEnabled; // 상호작용 상태 설정
subUiButton.onClick.AddListener(() =>
{
// 1. 주 확장 버튼 모델에 하위 버튼 선택 알림 (주 버튼 모양 업데이트 등)
// SelectSubButton의 두 번째 파라미터(GameObject)는 제거되었으므로, 모델만 전달
expandableButtonModel.SelectSubButton(subItemModel); // 모델 업데이트 및 주 버튼 외형 변경 요청
// 2. 선택된 하위 버튼 자체의 커맨드 실행
subItemModel.ExecuteClick(subItemModel.Text); // 하위 버튼의 Command 실행
// 3. 하위 메뉴 닫기
Destroy(currentSubMenu);
currentSubMenu = null;
// 4. 툴팁 숨기기 (하위 메뉴가 닫힐 때 관련 툴팁도 숨김)
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized) TooltipManager.Instance.HandlePointerExit();
});
}
else if (subUiToggle != null && subItemModel is ToolbarToggleButton subToggleModel) // 하위 아이템이 토글 버튼인 경우
{
subUiToggle.interactable = subToggleModel.IsEnabled;
subUiToggle.SetIsOnWithoutNotify(subToggleModel.IsSelected);
subUiToggle.onValueChanged.AddListener((isSelected) =>
{
// 하위 토글 버튼은 보통 클릭 시 바로 액션 실행 및 메뉴 닫힘
expandableButtonModel.SelectSubButton(subToggleModel); // 주 버튼 모양 업데이트
subToggleModel.ExecuteClick(); // 하위 버튼 커맨드 실행 (내부에서 IsSelected 변경)
Destroy(currentSubMenu);
currentSubMenu = null;
if (TooltipManager.Instance != null) TooltipManager.Instance.HideTooltip();
});
}
// 하위 버튼 툴팁 처리
if (!string.IsNullOrEmpty(subItemModel.Tooltip))
{
TooltipHandler tooltipHandler = subButtonObj.GetComponent<TooltipHandler>();
if (tooltipHandler == null) tooltipHandler = subButtonObj.AddComponent<TooltipHandler>();
tooltipHandler.Tooltip = subItemModel.Tooltip;
tooltipHandler.OnPointerEnterAction = TooltipManager.Instance.HandlePointerEnter;
tooltipHandler.OnPointerExitAction = TooltipManager.Instance.HandlePointerExit;
}
// 하위 버튼 모델의 상태 변경도 구독하여 UI 업데이트 (선택적 확장)
// subItemModel.OnStateChanged += () => UpdateCommonButtonVisuals(subItemModel, subButtonObj);
// 이 경우, _modelToGameObjectMap에 하위 버튼도 추가하고 ClearToolbar에서 정리해야 함.
}
}
}
// 하위 메뉴 레이아웃 그룹 크기 맞춤
if (subMenuLayoutGroup != null)
{
subMenuLayoutGroup.FitToChildren(); // UVC.Extension 필요 가정
}
_subMenuHandler.ToggleSubMenu(expandableButtonModel, expandableButtonObj);
}
/// <summary>
@@ -736,68 +440,36 @@ namespace UVC.UI.Toolbar.View
/// <returns>로드된 Sprite 객체. 실패 시 null을 반환합니다.</returns>
protected Sprite LoadSpriteFromResources(string spritePath)
{
if (string.IsNullOrEmpty(spritePath))
{
Debug.LogWarning("LoadSpriteFromResources: spritePath가 null이거나 비어있습니다.");
return null;
}
if (string.IsNullOrEmpty(spritePath)) return null;
Sprite loadedSprite = Resources.Load<Sprite>(spritePath);
if (loadedSprite == null)
{
// 경로에 파일이 없을 경우 오류 로깅. 게임 빌드 시 Resources 폴더 내용이 포함되는지 확인 필요.
Debug.LogError($"ToolbarView: Resources 폴더에서 '{spritePath}' 경로의 Sprite를 로드할 수 없습니다. 경로 및 파일 존재 여부를 확인하세요.", this);
Debug.LogError($"ToolbarView: Resources 폴더에서 '{spritePath}' 경로의 Sprite를 로드할 수 없습니다.", this);
}
return loadedSprite;
}
/// <summary>
/// MonoBehaviour의 Update 메서드입니다. 매 프레임 호출됩니다.
/// 여기서는 열려있는 하위 메뉴(currentSubMenu) 외부를 클릭했을 때 메뉴를 닫는 로직을 처리합니다.
/// SubMenuHandler의 Update를 호출하여 하위 메뉴 외부 클릭 감지 등을 처리합니다.
/// </summary>
protected virtual void Update()
{
// 마우스 왼쪽 버튼이 클릭되었고, 현재 열려있는 하위 메뉴가 있으며, 루트 캔버스가 존재하는 경우
if (Input.GetMouseButtonDown(0) && currentSubMenu != null && currentSubMenu.activeSelf && rootCanvas != null)
{
RectTransform subMenuRect = currentSubMenu.GetComponent<RectTransform>();
if (subMenuRect == null) return; // 하위 메뉴에 RectTransform이 없는 경우 (비정상)
// 마우스 포인터가 하위 메뉴 영역(RectTransform) 내에 있는지 확인
// 이를 위해 RectTransformUtility.RectangleContainsScreenPoint 사용
Camera eventCamera = null; // 스크린 스페이스 오버레이 캔버스인 경우 null
if (rootCanvas.renderMode == RenderMode.ScreenSpaceCamera || rootCanvas.renderMode == RenderMode.WorldSpace)
{
eventCamera = rootCanvas.worldCamera; // 캔버스에 할당된 카메라 사용
}
// 마우스 포인터가 하위 메뉴 영역 밖에 있는지 확인
if (!RectTransformUtility.RectangleContainsScreenPoint(subMenuRect, Input.mousePosition, eventCamera))
{
// 하위 메뉴 영역 바깥을 클릭한 경우, 하위 메뉴를 닫습니다.
DestroyCurrentSubMenuAndClearListeners();
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized) TooltipManager.Instance.HideTooltip();
}
}
_subMenuHandler?.Update();
}
/// <summary>
/// MonoBehaviour의 OnDestroy 메서드입니다. 이 컴포넌트 또는 GameObject가 파괴될 때 호출됩니다.
/// 툴바 UI를 정리(ClearToolbar)하고, TooltipManager 등의 리소스를 해제(Dispose)합니다.
/// 툴바 UI를 정리(ClearToolbar)하여 메모리 누수를 방지합니다.
/// </summary>
protected virtual void OnDestroy()
{
ClearToolbar(); // 모든 UI 요소 및 이벤트 구독 해제
{
ClearToolbar();
// TooltipManager가 싱글톤이고 다른 곳에서도 사용된다면, 여기서 Dispose 호출은 신중해야 합니다.
// 만약 이 ToolbarView가 TooltipManager의 유일한 사용자이거나,
// 씬 전환 등으로 모든 관련 UI가 사라지는 상황이라면 적절할 수 있습니다.
// 현재 TooltipManager.Instance.Dispose()는 TooltipVisualManager의 내용을 정리하는 것으로 보입니다.
if (TooltipManager.Instance != null && TooltipManager.Instance.IsInitialized)
{
// TooltipManager.Instance.Dispose(); // 주석 처리된 기존 코드. 필요시 활성화.
// Dispose의 구체적인 내용에 따라 호출 여부 결정.
// 만약 TooltipManager가 전역적이고 계속 사용되어야 한다면, 여기서 Dispose하면 안됨.
// HideTooltip 정도가 적당할 수 있음.
TooltipManager.Instance.HideTooltip();
}
}
}