ui 구조 변경 임시저장

This commit is contained in:
NTG 2025-08-20 18:08:41 +09:00
parent 0cce2efe62
commit 6f4417cd98
23 changed files with 519 additions and 1824 deletions

View File

@ -6311,7 +6311,10 @@ PrefabInstance:
m_AddedComponents:
- targetCorrespondingSourceObject: {fileID: 6952779389930089995, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3}
insertIndex: -1
addedObject: {fileID: 7197937761328488698}
addedObject: {fileID: 3190533777077691364}
- targetCorrespondingSourceObject: {fileID: 6952779389930089995, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3}
insertIndex: -1
addedObject: {fileID: 245423958264921561}
m_SourcePrefab: {fileID: 100100000, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3}
--- !u!224 &4720669467062659157 stripped
RectTransform:
@ -6323,7 +6326,7 @@ GameObject:
m_CorrespondingSourceObject: {fileID: 6952779389930089995, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3}
m_PrefabInstance: {fileID: 4463400116329503023}
m_PrefabAsset: {fileID: 0}
--- !u!114 &7197937761328488698
--- !u!114 &3190533777077691364
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
@ -6332,10 +6335,11 @@ MonoBehaviour:
m_GameObject: {fileID: 6740783381500491556}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8a2e0954aa144633aad86e53dc80a46a, type: 3}
m_Script: {fileID: 11500000, guid: 46c8396c996c804449b383960b44e812, type: 3}
m_Name:
m_EditorClassIdentifier:
_enableBlockImage: 0
InputActionMaps: 3
_uiActionsInputBinding: {fileID: 11400000, guid: 8073fcaf56fc7c34e996d0d47044f146, type: 2}
_checklistView: {fileID: 7075966153492927588}
_inventoryView: {fileID: 3570087040626823091}
@ -6346,6 +6350,18 @@ MonoBehaviour:
_menuCategoryTabs: {fileID: 6805049896193344908}
_cookwareCategoryTabs: {fileID: 6628923975427483430}
_completeBatchFilledImage: {fileID: 2965326806322860544}
--- !u!114 &245423958264921561
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6740783381500491556}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 80dee5e1862248aab26236036049e5fc, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1001 &4530765275021007961
PrefabInstance:
m_ObjectHideFlags: 0

Binary file not shown.

View File

@ -1,28 +1,39 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;
using DDD.MVVM;
namespace DDD
{
public abstract class BaseUi : MonoBehaviour
{
[SerializeField] protected bool _enableBlockImage;
protected CanvasGroup _canvasGroup;
protected GameObject _blockImage;
protected GameObject _panel;
protected BindingContext _bindingContext;
public virtual bool IsBlockingTime => false;
public virtual bool IsOpen => _panel.activeSelf;
[SerializeField] protected bool _enableBlockImage;
public virtual bool IsOpen => _panel != null && _panel.activeSelf;
protected virtual void Awake()
{
_canvasGroup = GetComponent<CanvasGroup>();
_panel = transform.Find(CommonConstants.Panel).gameObject;
_blockImage = transform.Find(CommonConstants.BlockImage).gameObject;
_panel = transform.Find(CommonConstants.Panel)?.gameObject;
_blockImage = transform.Find(CommonConstants.BlockImage)?.gameObject;
_bindingContext = new BindingContext();
SetupAutoBindings();
SetupBindings();
}
protected virtual void OnEnable()
{
}
protected virtual void Start()
@ -33,22 +44,24 @@ protected virtual void Start()
protected virtual void Update()
{
}
protected virtual void OnDisable()
{
}
protected virtual void OnDestroy()
{
TryUnregister();
_bindingContext?.Dispose();
}
protected virtual void TryRegister() { }
protected virtual void TryUnregister() { }
// BaseUi 메서드들을 직접 구현
public virtual void OpenPanel()
{
if (_enableBlockImage)
@ -71,10 +84,127 @@ public virtual void ClosePanel()
public virtual void SetUiInteractable(bool active)
{
_canvasGroup.interactable = active;
_canvasGroup.blocksRaycasts = active;
if (_canvasGroup != null)
{
_canvasGroup.interactable = active;
_canvasGroup.blocksRaycasts = active;
}
}
public bool IsOpenPanel() => _panel.activeInHierarchy;
public bool IsOpenPanel() => _panel && _panel.activeInHierarchy;
/// <summary>
/// Attribute 기반 자동 바인딩 설정
/// </summary>
private void SetupAutoBindings()
{
var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindToAttribute>() != null);
foreach (var field in fields)
{
var bindAttribute = field.GetCustomAttribute<BindToAttribute>();
SetupBinding(field, bindAttribute);
}
// 컬렉션 바인딩 설정
var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindCollectionAttribute>() != null);
foreach (var field in collectionFields)
{
var bindAttribute = field.GetCustomAttribute<BindCollectionAttribute>();
SetupCollectionBinding(field, bindAttribute);
}
}
/// <summary>
/// 개별 필드의 바인딩 설정
/// </summary>
private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute)
{
var target = field.GetValue(this);
IValueConverter converter = null;
if (bindAttribute.ConverterType != null)
{
converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter;
}
// UI 컴포넌트 타입별 바인딩 타겟 생성
IBindingTarget bindingTarget = target switch
{
Text text => new TextBindingTarget(text, bindAttribute.PropertyPath),
Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath),
GameObject go => new ActiveBindingTarget(go, bindAttribute.PropertyPath),
Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath),
_ => null
};
if (bindingTarget != null)
{
_bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter);
}
}
/// <summary>
/// 컬렉션 바인딩 설정
/// </summary>
private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute)
{
var target = field.GetValue(this);
if (target is Transform parent)
{
// 컬렉션 바인딩 로직 (필요시 확장)
Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}");
}
}
/// <summary>
/// 추가 바인딩 설정 - 하위 클래스에서 구현
/// </summary>
protected virtual void SetupBindings() { }
/// <summary>
/// ViewModel 속성 변경 이벤트 핸들러
/// </summary>
protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
HandleCustomPropertyChanged(e.PropertyName);
}
/// <summary>
/// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드)
/// </summary>
protected virtual void HandleCustomPropertyChanged(string propertyName)
{
// 하위 클래스에서 구현
}
// 수동 바인딩 헬퍼 메서드들
protected void BindText(Text text, string propertyPath, IValueConverter converter = null)
{
var target = new TextBindingTarget(text, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindImage(Image image, string propertyPath, IValueConverter converter = null)
{
var target = new ImageBindingTarget(image, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindActive(GameObject gameObject, string propertyPath, IValueConverter converter = null)
{
var target = new ActiveBindingTarget(gameObject, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindSlider(Slider slider, string propertyPath, IValueConverter converter = null)
{
var target = new SliderBindingTarget(slider, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
}
}

View File

@ -0,0 +1,74 @@
namespace DDD
{
public class BaseViewModelUi<TViewModel> : BaseUi where TViewModel : SimpleViewModel
{
protected TViewModel _viewModel;
protected override void Awake()
{
base.Awake();
_viewModel = GetComponent<TViewModel>();
}
protected override void OnEnable()
{
base.OnEnable();
if (_viewModel && _bindingContext != null)
{
_bindingContext.SetDataContext(_viewModel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
}
protected override void OnDisable()
{
base.OnDisable();
if (_viewModel != null)
{
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
}
public override void OpenPanel()
{
base.OpenPanel();
_viewModel?.Initialize();
}
public override void ClosePanel()
{
base.ClosePanel();
_viewModel?.Cleanup();
}
/// <summary>
/// ViewModel 메서드 호출 헬퍼
/// </summary>
protected void InvokeViewModelMethod(string methodName, params object[] parameters)
{
if (_viewModel == null) return;
var method = _viewModel.GetType().GetMethod(methodName);
method?.Invoke(_viewModel, parameters);
}
/// <summary>
/// ViewModel 속성 설정 헬퍼
/// </summary>
protected void SetViewModelProperty(string propertyName, object value)
{
if (_viewModel == null) return;
var property = _viewModel.GetType().GetProperty(propertyName);
if (property != null && property.CanWrite)
{
property.SetValue(_viewModel, value);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: df6384ea09a44f188d636ca7ee47db13
timeCreated: 1755678434

View File

@ -8,7 +8,7 @@
namespace DDD
{
public class ConfirmUi : PopupUi<RestaurantUiActions>
public class ConfirmUi : PopupUi<RestaurantUiActions, ConfirmViewModel>
{
[SerializeField] private TextMeshProUGUI _messageLabel;
[SerializeField] private LocalizeStringEvent _messageLabelLocalizeStringEvent;

View File

@ -0,0 +1,7 @@
namespace DDD
{
public class ConfirmViewModel : SimpleViewModel
{
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7552fc9cc76345e09148a145ed7799a5
timeCreated: 1755679705

View File

@ -1,321 +0,0 @@
# 기존 UI 시스템을 MVVM으로 마이그레이션 가이드
Unity 프로젝트에서 기존 UI 시스템(BaseUi, BasePopupUi, PopupUi)을 MVVM 패턴으로 점진적으로 전환하는 방법을 설명합니다.
## 호환성 보장
### 1. 기존 UI 시스템은 그대로 유지됩니다
- `BaseUi`, `BasePopupUi`, `PopupUi<T>` 클래스들은 변경되지 않음
- 기존 UI들은 계속해서 정상 동작
- `UiManager``PopupUiState`도 기존 방식 그대로 지원
### 2. 새로운 Integrated 클래스들 사용
- `IntegratedBaseUi<TViewModel>` : BaseUi + MVVM 기능 통합
- `IntegratedBasePopupUi<TViewModel>` : BasePopupUi + MVVM 기능 통합
- `IntegratedPopupUi<TInputEnum, TViewModel>` : PopupUi<T> + MVVM 기능 통합
## 마이그레이션 전략
### 단계 1: 점진적 전환 계획 수립
1. **우선순위 결정**
```
높음: 복잡한 상태 관리가 필요한 UI (InventoryView, ShopView 등)
중간: 중간 규모의 팝업 UI
낮음: 간단한 확인/알림 다이얼로그
```
2. **전환 범위 설정**
- 한 번에 하나의 UI씩 전환
- 관련된 ViewModel과 Service 함께 구현
- 철저한 테스트 후 다음 UI 진행
### 단계 2: ViewModel 및 Service 구현
#### 기존 View 분석
```csharp
// 기존 InventoryView
public class InventoryView : MonoBehaviour
{
private InventoryCategoryType _currentCategory;
private List<ItemViewModel> _items;
public void UpdateCategoryView(InventoryCategoryType category)
{
_currentCategory = category;
// 복잡한 UI 업데이트 로직
}
}
```
#### ViewModel로 분리
```csharp
// 새로운 InventoryViewModel
public class InventoryViewModel : SimpleViewModel
{
private InventoryCategoryType _currentCategory;
private List<ItemViewModel> _visibleItems;
public InventoryCategoryType CurrentCategory
{
get => _currentCategory;
set => SetField(ref _currentCategory, value);
}
public void SetCategory(InventoryCategoryType category)
{
CurrentCategory = category;
UpdateVisibleItems(); // 비즈니스 로직
}
}
```
#### Service 계층 분리
```csharp
// 비즈니스 로직을 Service로 분리
public class InventoryService : IUiService
{
public IEnumerable<ItemViewModel> FilterItems(InventoryCategoryType category)
{
// 복잡한 필터링 로직
}
}
```
### 단계 3: View를 MVVM으로 전환
#### 기존 View 코드
```csharp
public class InventoryView : MonoBehaviour, IEventHandler<InventoryChangedEvent>
{
[SerializeField] private Transform _slotParent;
[SerializeField] private Text _categoryLabel;
// 많은 상태 변수들과 복잡한 로직들...
}
```
#### MVVM View로 전환
```csharp
public class MvvmInventoryView : MvvmBasePopupUi<InventoryViewModel>
{
[SerializeField] private Transform _slotParent;
[SerializeField] private Text _categoryLabel;
protected override void SetupBindings()
{
BindText(_categoryLabel, nameof(InventoryViewModel.CategoryDisplayText));
// 간단한 바인딩 설정만
}
public void OnCategoryButtonClicked(int categoryIndex)
{
ViewModel.SetCategory((InventoryCategoryType)categoryIndex);
}
}
```
### 단계 4: 마이그레이션 체크리스트
#### ViewModel 체크리스트
- [ ] SimpleViewModel 상속
- [ ] 모든 상태를 속성으로 변환
- [ ] SetField 사용한 PropertyChanged 알림
- [ ] 계산된 속성 구현
- [ ] 비즈니스 로직을 Service로 위임
#### View 체크리스트
- [ ] 적절한 MVVM Base 클래스 상속
- [ ] SetupBindings() 구현
- [ ] UI 이벤트를 ViewModel 메서드 호출로 변경
- [ ] 직접적인 UI 업데이트 코드 제거
- [ ] HandleCustomPropertyChanged에서 복잡한 UI 처리
#### Service 체크리스트
- [ ] IService 인터페이스 구현
- [ ] 복잡한 비즈니스 로직 포함
- [ ] 데이터 접근 로직 캡슐화
- [ ] 테스트 가능한 구조
## 구체적인 마이그레이션 예시
### 예시 1: 간단한 팝업 UI
#### Before (기존)
```csharp
public class SimpleDialogUi : BasePopupUi
{
[SerializeField] private Text _messageText;
[SerializeField] private Button _confirmButton;
public void ShowMessage(string message)
{
_messageText.text = message;
_confirmButton.onClick.AddListener(Close);
}
}
```
#### After (MVVM)
```csharp
// ViewModel
public class DialogViewModel : SimpleViewModel
{
private string _message;
public string Message
{
get => _message;
set => SetField(ref _message, value);
}
}
// View
public class MvvmSimpleDialogUi : MvvmBasePopupUi<DialogViewModel>
{
[SerializeField] private Text _messageText;
[SerializeField] private Button _confirmButton;
protected override void SetupBindings()
{
BindText(_messageText, nameof(DialogViewModel.Message));
}
protected override void Awake()
{
base.Awake();
_confirmButton.onClick.AddListener(() => Close());
}
}
```
### 예시 2: 복잡한 목록 UI
#### Before (기존 InventoryView)
```csharp
public class InventoryView : MonoBehaviour
{
private InventoryCategoryType _currentCategory;
private Dictionary<string, ItemSlotUi> _slotLookup = new();
public void UpdateCategoryView(InventoryCategoryType category)
{
_currentCategory = category;
// 복잡한 필터링 로직
foreach (var slot in _slotLookup.Values)
{
bool shouldShow = MatchesCategory(slot.Model, category);
slot.SetActive(shouldShow);
}
// 정렬 로직
// UI 업데이트 로직
}
private bool MatchesCategory(ItemViewModel model, InventoryCategoryType category)
{
// 복잡한 카테고리 매칭 로직
}
}
```
#### After (MVVM)
```csharp
// ViewModel (상태 관리)
public class InventoryViewModel : SimpleViewModel
{
private InventoryCategoryType _currentCategory;
private List<ItemViewModel> _visibleItems;
public InventoryCategoryType CurrentCategory
{
get => _currentCategory;
set => SetField(ref _currentCategory, value);
}
public List<ItemViewModel> VisibleItems
{
get => _visibleItems;
private set => SetField(ref _visibleItems, value);
}
public string CategoryDisplayText => GetCategoryDisplayText();
public void SetCategory(InventoryCategoryType category)
{
CurrentCategory = category;
UpdateVisibleItems();
OnPropertyChanged(nameof(CategoryDisplayText));
}
private void UpdateVisibleItems()
{
var filtered = _inventoryService.FilterItems(CurrentCategory, CurrentSortType);
VisibleItems = filtered.ToList();
}
}
// Service (비즈니스 로직)
public class InventoryService : IUiService
{
public IEnumerable<ItemViewModel> FilterItems(InventoryCategoryType category, InventorySortType sortType)
{
// 복잡한 필터링과 정렬 로직을 Service로 이동
}
}
// View (UI 바인딩만)
public class MvvmInventoryView : MvvmBasePopupUi<InventoryViewModel>
{
[SerializeField] private Text _categoryLabel;
[SerializeField] private Transform _slotParent;
protected override void SetupBindings()
{
BindText(_categoryLabel, nameof(InventoryViewModel.CategoryDisplayText));
}
protected override void HandleCustomPropertyChanged(string propertyName)
{
if (propertyName == nameof(InventoryViewModel.VisibleItems))
{
UpdateItemSlots(); // 복잡한 UI 업데이트는 여전히 필요
}
}
}
```
## 마이그레이션 주의사항
### 1. 기존 코드와의 의존성
- 기존 UI를 참조하는 다른 코드들 확인
- 이벤트 시스템과의 연동 유지
- ScriptableObject 설정 파일들과의 호환성
### 2. 성능 고려사항
- PropertyChanged 이벤트의 과도한 발생 방지
- UI 업데이트 배칭 활용
- 메모리 누수 방지 (이벤트 핸들러 정리)
### 3. 테스트 전략
- ViewModel 단위 테스트 작성
- 기존 UI와 새 UI 동시 테스트
- 사용자 시나리오 기반 통합 테스트
## 마이그레이션 우선순위 추천
### 1차 마이그레이션 (높은 우선순위)
- **InventoryView**: 복잡한 상태 관리, 필터링, 정렬
- **ShopView**: 상품 목록, 카테고리, 구매 로직
- **MenuView**: 메뉴 관리, 레시피 선택
### 2차 마이그레이션 (중간 우선순위)
- **SettingsView**: 다양한 설정 옵션들
- **StatisticsView**: 데이터 표시와 계산
### 3차 마이그레이션 (낮은 우선순위)
- **SimpleDialogUi**: 간단한 확인 다이얼로그들
- **NotificationUi**: 알림 팝업들
이 가이드를 따라 점진적으로 UI를 MVVM으로 전환하면, 기존 시스템의 안정성을 유지하면서도 새로운 아키텍처의 이점을 얻을 수 있습니다.

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 4d3c9e8511cb71c42b53aa3543216518
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,275 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using DDD.MVVM;
namespace DDD
{
/// <summary>
/// MVVM 패턴을 적용한 새로운 레스토랑 관리 UI
/// 기존 RestaurantManagementUi의 기능을 ViewModel과 분리하여 구현
/// </summary>
public class NewRestaurantManagementUi : IntegratedBasePopupUi<RestaurantUiActions, RestaurantManagementViewModel>
{
[Header("Sub Views")]
[SerializeField] private ChecklistView _checklistView;
[SerializeField] private InventoryView _inventoryView;
[SerializeField] private ItemDetailView _itemDetailView;
[SerializeField] private TodayMenuView _todayMenuView;
[SerializeField] private TodayRestaurantStateView _todayRestaurantStateView;
[Header("Tab Groups")]
[SerializeField] private TabGroupUi _sectionTabs;
[SerializeField] private TabGroupUi _menuCategoryTabs;
[SerializeField] private TabGroupUi _cookwareCategoryTabs;
[Header("Hold Progress UI")]
[SerializeField, BindTo(nameof(RestaurantManagementViewModel.NormalizedHoldProgress))]
private Image _completeBatchFilledImage;
protected override void Awake()
{
base.Awake();
SetupViewModelEvents();
}
protected override void Update()
{
base.Update();
if (_viewModel != null && _viewModel.IsHolding)
{
_viewModel.UpdateHoldProgress();
}
}
public override void Open(OpenPopupUiEvent evt)
{
base.Open(evt);
InitializeViews();
SetupTabs();
}
protected override GameObject GetInitialSelected()
{
// ViewModel의 현재 상태에 따라 초기 선택 UI 결정
var inventoryInitialSelected = _inventoryView.GetInitialSelected();
if (inventoryInitialSelected) return inventoryInitialSelected;
var menuCategoryButton = _menuCategoryTabs.GetFirstInteractableButton();
if (menuCategoryButton != null && menuCategoryButton.activeInHierarchy)
return menuCategoryButton;
var cookwareCategoryButton = _cookwareCategoryTabs.GetFirstInteractableButton();
if (cookwareCategoryButton != null && cookwareCategoryButton.activeInHierarchy)
return cookwareCategoryButton;
return null;
}
protected override void SetupBindings()
{
// Attribute 기반 자동 바인딩이 처리됨
// 추가적인 수동 바인딩이 필요한 경우 여기에 구현
}
protected override void HandleCustomPropertyChanged(string propertyName)
{
switch (propertyName)
{
case nameof(RestaurantManagementViewModel.CurrentSection):
UpdateSectionTabs();
break;
case nameof(RestaurantManagementViewModel.CurrentCategory):
UpdateCategoryTabs();
break;
}
}
private void SetupViewModelEvents()
{
if (!_viewModel) return;
_viewModel.OnBatchCompleted = HandleBatchCompleted;
_viewModel.OnChecklistFailed = HandleChecklistFailed;
_viewModel.OnMenuSectionSelected = HandleMenuSectionSelected;
_viewModel.OnCookwareSectionSelected = HandleCookwareSectionSelected;
_viewModel.OnCategoryChanged = HandleCategoryChanged;
_viewModel.OnTabMoved = HandleTabMoved;
_viewModel.OnInteractRequested = HandleInteractRequested;
_viewModel.OnCloseRequested = HandleCloseRequested;
_viewModel.OnMenuCategorySelected = HandleMenuCategorySelected;
}
private void InitializeViews()
{
_checklistView.Initalize();
_inventoryView.Initialize();
_itemDetailView.Initialize();
_todayMenuView.Initialize();
_todayRestaurantStateView.Initialize();
}
private void SetupTabs()
{
SetupCategoryTabs();
InitializeTabGroups();
SelectInitialTabs();
}
private void SetupCategoryTabs()
{
_menuCategoryTabs.UseDefaultAllowedValues();
_cookwareCategoryTabs.UseDefaultAllowedValues();
}
private void InitializeTabGroups()
{
_sectionTabs.Initialize(OnSectionTabSelected);
_menuCategoryTabs.Initialize(OnCategoryTabSelected);
_cookwareCategoryTabs.Initialize(OnCategoryTabSelected);
}
private void SelectInitialTabs()
{
_sectionTabs.SelectFirstTab();
_menuCategoryTabs.SelectFirstTab();
}
private void UpdateSectionTabs()
{
if (_viewModel == null) return;
_sectionTabs.SelectTab((int)_viewModel.CurrentSection);
}
private void UpdateCategoryTabs()
{
if (_viewModel == null) return;
switch (_viewModel.CurrentSection)
{
case SectionButtonType.Menu:
_menuCategoryTabs.SelectTab((int)_viewModel.CurrentCategory);
break;
case SectionButtonType.Cookware:
_cookwareCategoryTabs.SelectTab((int)_viewModel.CurrentCategory);
break;
}
}
// ViewModel 이벤트 핸들러들
private void HandleBatchCompleted()
{
Close();
}
private void HandleChecklistFailed()
{
var evt = GameEvents.OpenPopupUiEvent;
evt.UiType = typeof(ConfirmUi);
evt.IsCancelButtonVisible = true;
evt.NewMessageKey = "checklist_failed_message";
evt.OnConfirm = ClosePanel;
EventBus.Broadcast(evt);
}
private void HandleMenuSectionSelected()
{
_menuCategoryTabs.SelectFirstTab();
}
private void HandleCookwareSectionSelected()
{
_cookwareCategoryTabs.SelectFirstTab();
}
private void HandleCategoryChanged(InventoryCategoryType category)
{
_inventoryView.UpdateCategoryView(category);
_itemDetailView.UpdateCategory(category);
}
private void HandleTabMoved(int direction)
{
_sectionTabs.Move(direction);
}
private void HandleInteractRequested()
{
var selected = EventSystem.current.currentSelectedGameObject;
var interactable = selected?.GetComponent<IInteractableUi>();
interactable?.OnInteract();
}
private void HandleCloseRequested()
{
Close();
}
private void HandleMenuCategorySelected(InventoryCategoryType category)
{
_menuCategoryTabs.SelectTab((int)category);
}
// UI 이벤트 핸들러들 (TabGroupUi 콜백)
private void OnSectionTabSelected(int sectionValue)
{
_viewModel?.SetSection((SectionButtonType)sectionValue);
}
private void OnCategoryTabSelected(int categoryValue)
{
_viewModel?.SetCategory((InventoryCategoryType)categoryValue);
}
// 입력 처리 - ViewModel로 위임
protected override bool OnInputPerformed(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
var isHandled = base.OnInputPerformed(actionEnum, context);
if (isHandled && _viewModel != null)
{
switch (actionEnum)
{
case RestaurantUiActions.Cancel:
_viewModel.CloseUi();
break;
case RestaurantUiActions.PreviousTab:
_viewModel.MoveTab(-1);
break;
case RestaurantUiActions.NextTab:
_viewModel.MoveTab(1);
break;
case RestaurantUiActions.Interact1:
_viewModel.InteractWithSelected();
break;
case RestaurantUiActions.Interact2:
_viewModel.StartHold();
break;
}
}
return isHandled;
}
protected override bool OnInputCanceled(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
var isHandled = base.OnInputCanceled(actionEnum, context);
if (isHandled && _viewModel != null)
{
switch (actionEnum)
{
case RestaurantUiActions.Interact2:
_viewModel.CancelHold();
break;
}
}
return isHandled;
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 8a2e0954aa144633aad86e53dc80a46a
timeCreated: 1755673386

View File

@ -1,278 +0,0 @@
# Unity MVVM 시스템 가이드
Unity에서 MVVM(Model-View-ViewModel) 패턴을 구현한 시스템입니다. Attribute 기반 데이터 바인딩과 `nameof`를 활용한 타입 안전한 구조를 제공합니다.
## 폴더 구조
```
GameUi/New/
├── ViewModels/ # ViewModel 클래스들
│ ├── Base/ # 기본 ViewModel 클래스
│ │ └── SimpleViewModel.cs
│ └── InventoryViewModel.cs # 예시 ViewModel
├── Views/ # View 클래스들
│ └── Base/
│ └── AutoBindView.cs # 자동 바인딩 View 기본 클래스
├── Services/ # 서비스 계층 클래스들
│ ├── IService.cs # 서비스 인터페이스
│ └── InventoryService.cs # 예시 서비스
├── Utils/ # 유틸리티 클래스들
│ ├── BindToAttribute.cs # 바인딩 Attribute
│ └── BindingContext.cs # 바인딩 컨텍스트
└── Converters/ # 값 변환기들
├── IValueConverter.cs # 컨버터 인터페이스
└── CommonConverters.cs # 공통 컨버터들
```
## 핵심 클래스
### 1. SimpleViewModel
- ViewModel의 기본 클래스
- `INotifyPropertyChanged` 구현
- 속성 변경 알림 자동 처리
- 배치 업데이트 지원
### 2. IntegratedBaseUi<TViewModel>
- UI의 기본 클래스 (BaseUi + MVVM 기능 통합)
- Attribute 기반 자동 바인딩
- ViewModel과 UI 요소 자동 연결
### 3. BindToAttribute
- UI 요소를 ViewModel 속성에 바인딩
- `nameof()` 사용으로 타입 안전성 보장
- 컨버터 지원
## 사용법
### 1. ViewModel 생성
```csharp
namespace DDD.MVVM
{
public class MyViewModel : SimpleViewModel
{
private string _title = "기본 제목";
private int _count = 0;
private bool _isVisible = true;
public string Title
{
get => _title;
set => SetField(ref _title, value);
}
public int Count
{
get => _count;
set => SetField(ref _count, value);
}
public bool IsVisible
{
get => _isVisible;
set => SetField(ref _isVisible, value);
}
public string CountText => $"개수: {Count}";
public void IncrementCount()
{
Count++;
OnPropertyChanged(nameof(CountText));
}
}
}
```
### 2. View 생성
```csharp
namespace DDD
{
public class MyView : IntegratedBaseUi<MyViewModel>
{
[SerializeField, BindTo(nameof(MyViewModel.Title))]
private Text _titleText;
[SerializeField, BindTo(nameof(MyViewModel.CountText))]
private Text _countText;
[SerializeField, BindTo(nameof(MyViewModel.IsVisible))]
private GameObject _panel;
// UI 이벤트 핸들러
public void OnIncrementButtonClicked()
{
ViewModel.IncrementCount();
}
public void OnToggleVisibilityClicked()
{
ViewModel.IsVisible = !ViewModel.IsVisible;
}
}
}
```
### 3. 컨버터 사용
```csharp
[SerializeField, BindTo(nameof(MyViewModel.IsVisible), typeof(InvertBoolConverter))]
private GameObject _hiddenPanel;
[SerializeField, BindTo(nameof(MyViewModel.Count), typeof(ItemCountConverter))]
private Text _formattedCountText;
```
## 기존 InventoryView 변환 예시
### 기존 코드 (InventoryView)
```csharp
public class InventoryView : MonoBehaviour
{
private InventoryCategoryType _currentCategory = InventoryCategoryType.Food;
public void UpdateCategoryView(InventoryCategoryType category)
{
_currentCategory = category;
// 복잡한 UI 업데이트 로직...
}
}
```
### MVVM 변환 후
**InventoryViewModel.cs**
```csharp
namespace DDD.MVVM
{
public class InventoryViewModel : SimpleViewModel
{
private InventoryCategoryType _currentCategory = InventoryCategoryType.Food;
public InventoryCategoryType CurrentCategory
{
get => _currentCategory;
set => SetField(ref _currentCategory, value);
}
public string CategoryDisplayText => CurrentCategory switch
{
InventoryCategoryType.Food => "음식",
InventoryCategoryType.Drink => "음료",
_ => "전체"
};
public void SetCategory(InventoryCategoryType category)
{
CurrentCategory = category;
OnPropertyChanged(nameof(CategoryDisplayText));
}
}
}
```
**InventoryView.cs**
```csharp
namespace DDD.MVVM
{
public class InventoryView : AutoBindView<InventoryViewModel>
{
[SerializeField, BindTo(nameof(InventoryViewModel.CategoryDisplayText))]
private Text _categoryLabel;
public void OnCategoryButtonClicked(int categoryIndex)
{
ViewModel.SetCategory((InventoryCategoryType)categoryIndex);
}
}
}
```
## 장점
### 1. 타입 안전성
- `nameof()` 사용으로 컴파일 타임 검증
- 속성명 변경 시 자동 업데이트
- IDE IntelliSense 지원
### 2. Unity 통합성
- Inspector에서 바인딩 확인
- MonoBehaviour 패턴 유지
- 기존 Unity 워크플로우와 호환
### 3. 유지보수성
- View와 비즈니스 로직 분리
- 테스트 가능한 ViewModel
- 재사용 가능한 컴포넌트
### 4. 개발 생산성
- 자동 바인딩으로 보일러플레이트 코드 감소
- 데이터 변경 시 UI 자동 업데이트
- 일관된 개발 패턴
## 컨버터 목록
### 기본 컨버터들
- `InvertBoolConverter`: 불린 값 반전
- `ItemCountConverter`: 숫자를 "아이템 수: N" 형식으로 변환
- `InventoryCategoryConverter`: 카테고리 열거형을 한국어로 변환
- `CurrencyConverter`: 숫자를 통화 형식으로 변환
- `PercentageConverter`: 소수를 백분율로 변환
### 커스텀 컨버터 생성
```csharp
public class CustomConverter : IValueConverter
{
public object Convert(object value)
{
// 변환 로직 구현
return convertedValue;
}
public object ConvertBack(object value)
{
// 역변환 로직 구현 (선택사항)
return originalValue;
}
}
```
## 베스트 프랙티스
### 1. ViewModel 설계
- 단일 책임 원칙 준수
- UI 관련 Unity API 직접 사용 금지
- 계산된 속성 적극 활용
- 이벤트를 통한 느슨한 결합
### 2. 바인딩 설정
- `nameof()` 사용 필수
- 적절한 컨버터 활용
- 복잡한 로직은 ViewModel에서 처리
### 3. 성능 고려사항
- 배치 업데이트 활용
- 불필요한 PropertyChanged 이벤트 방지
- 컬렉션 변경 시 효율적인 업데이트
## 마이그레이션 가이드
### 단계별 적용 방법
#### 1단계: 기존 View에서 ViewModel 분리
1. View의 상태 변수들을 ViewModel로 이동
2. 비즈니스 로직을 Service로 분리
3. UI 업데이트 로직을 바인딩으로 대체
#### 2단계: 자동 바인딩 적용
1. `AutoBindView<TViewModel>` 상속
2. UI 요소에 `BindTo` Attribute 추가
3. 수동 UI 업데이트 코드 제거
#### 3단계: 최적화 및 리팩토링
1. 컨버터 활용으로 로직 단순화
2. 계산된 속성으로 중복 제거
3. 이벤트 시스템과 통합
이 MVVM 시스템을 통해 Unity UI 개발의 생산성과 유지보수성을 크게 향상시킬 수 있습니다.

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 5d5b08d15b2e43f4698c0f24977c2582
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,252 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using Sirenix.OdinInspector;
using UnityEngine.InputSystem;
using DDD.MVVM;
namespace DDD
{
public abstract class IntegratedBasePopupUi<TInputEnum, TViewModel> : BasePopupUi
where TInputEnum : Enum
where TViewModel : SimpleViewModel
{
[SerializeField, Required] protected BaseUiActionsInputBinding<TInputEnum> _uiActionsInputBinding;
protected readonly List<(InputAction action, Action<InputAction.CallbackContext> handler)> _registeredHandlers =
new();
// MVVM 기능들
protected BindingContext _bindingContext;
protected TViewModel _viewModel;
public override InputActionMaps InputActionMaps => _uiActionsInputBinding.InputActionMaps;
public bool IsTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this);
protected override void Awake()
{
base.Awake();
_enableBlockImage = true;
_viewModel = GetComponent<TViewModel>();
_bindingContext = new BindingContext();
SetupAutoBindings();
SetupBindings();
}
protected override void OnEnable()
{
base.OnEnable();
}
protected override void Update()
{
base.Update();
if (IsOpenPanel() == false) return;
var currentSelectedGameObject = EventSystem.current.currentSelectedGameObject;
if (currentSelectedGameObject == null || currentSelectedGameObject.activeInHierarchy == false)
{
var initialSelected = GetInitialSelected();
if (initialSelected != null)
{
EventSystem.current.SetSelectedGameObject(initialSelected);
}
}
}
public override void Open(OpenPopupUiEvent evt)
{
base.Open(evt);
var initialSelected = GetInitialSelected();
if (initialSelected != null)
{
EventSystem.current.SetSelectedGameObject(initialSelected);
}
transform.SetAsLastSibling();
if (IsTopPopup)
{
InputManager.Instance.SwitchCurrentActionMap(_uiActionsInputBinding.InputActionMaps);
}
}
protected override abstract GameObject GetInitialSelected();
protected override void TryRegister()
{
base.TryRegister();
// PopupUi의 입력 바인딩 등록
foreach (var actionEnum in _uiActionsInputBinding.BindingActions.GetFlags())
{
if (actionEnum.Equals(default(TInputEnum))) continue;
var inputAction =
InputManager.Instance.GetAction(_uiActionsInputBinding.InputActionMaps, actionEnum.ToString());
if (inputAction == null) continue;
var startedHandler = new Action<InputAction.CallbackContext>(context =>
{
OnInputStarted(actionEnum, context);
});
inputAction.started += startedHandler;
var performedHandler = new Action<InputAction.CallbackContext>(context =>
{
OnInputPerformed(actionEnum, context);
});
inputAction.performed += performedHandler;
var canceledHandler = new Action<InputAction.CallbackContext>(context =>
{
OnInputCanceled(actionEnum, context);
});
inputAction.canceled += canceledHandler;
_registeredHandlers.Add((inputAction, startedHandler));
_registeredHandlers.Add((inputAction, performedHandler));
_registeredHandlers.Add((inputAction, canceledHandler));
}
}
protected override void TryUnregister()
{
base.TryUnregister();
// 입력 핸들러 해제
foreach (var (action, handler) in _registeredHandlers)
{
if (action != null)
{
action.started -= handler;
action.performed -= handler;
action.canceled -= handler;
}
}
_registeredHandlers.Clear();
}
// 입력 처리 메서드들
protected virtual bool OnInputStarted(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
protected virtual bool OnInputPerformed(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
protected virtual bool OnInputCanceled(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
/// <summary>
/// Attribute 기반 자동 바인딩 설정
/// </summary>
private void SetupAutoBindings()
{
var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindToAttribute>() != null);
foreach (var field in fields)
{
var bindAttribute = field.GetCustomAttribute<BindToAttribute>();
SetupBinding(field, bindAttribute);
}
// 컬렉션 바인딩 설정
var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindCollectionAttribute>() != null);
foreach (var field in collectionFields)
{
var bindAttribute = field.GetCustomAttribute<BindCollectionAttribute>();
SetupCollectionBinding(field, bindAttribute);
}
}
/// <summary>
/// 개별 필드의 바인딩 설정
/// </summary>
private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute)
{
var target = field.GetValue(this);
IValueConverter converter = null;
if (bindAttribute.ConverterType != null)
{
converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter;
}
// UI 컴포넌트 타입별 바인딩 타겟 생성
IBindingTarget bindingTarget = target switch
{
Text text => new TextBindingTarget(text, bindAttribute.PropertyPath),
Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath),
GameObject gameObject => new ActiveBindingTarget(gameObject, bindAttribute.PropertyPath),
Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath),
_ => null
};
if (bindingTarget != null)
{
_bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter);
}
}
/// <summary>
/// 컬렉션 바인딩 설정
/// </summary>
private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute)
{
var target = field.GetValue(this);
if (target is Transform parent)
{
// 컬렉션 바인딩 로직 (필요시 확장)
Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}");
}
}
/// <summary>
/// 추가 바인딩 설정 - 하위 클래스에서 구현
/// </summary>
protected virtual void SetupBindings() { }
/// <summary>
/// ViewModel 속성 변경 이벤트 핸들러
/// </summary>
protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
HandleCustomPropertyChanged(e.PropertyName);
}
/// <summary>
/// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드)
/// </summary>
protected virtual void HandleCustomPropertyChanged(string propertyName)
{
// 하위 클래스에서 구현
}
/// <summary>
/// UI 패널 열기 오버라이드 (ViewModel 초기화 추가)
/// </summary>
public override void OpenPanel()
{
base.OpenPanel();
_viewModel?.Initialize();
}
/// <summary>
/// UI 패널 닫기 오버라이드 (ViewModel 정리 추가)
/// </summary>
public override void ClosePanel()
{
_viewModel?.Cleanup();
base.ClosePanel();
}
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3ef87d5c2f3d82e488302056ac09a287

View File

@ -1,248 +0,0 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;
using DDD.MVVM;
namespace DDD
{
public abstract class IntegratedBaseUi<TViewModel> : MonoBehaviour where TViewModel : SimpleViewModel
{
[SerializeField] protected bool _enableBlockImage;
protected CanvasGroup _canvasGroup;
protected GameObject _blockImage;
protected GameObject _panel;
protected BindingContext _bindingContext;
protected TViewModel _viewModel;
public virtual bool IsBlockingTime => false;
public virtual bool IsOpen => _panel != null && _panel.activeSelf;
protected virtual void Awake()
{
_canvasGroup = GetComponent<CanvasGroup>();
_panel = transform.Find(CommonConstants.Panel)?.gameObject;
_blockImage = transform.Find(CommonConstants.BlockImage)?.gameObject;
if (_viewModel == null)
_viewModel = GetComponent<TViewModel>();
_bindingContext = new BindingContext();
SetupAutoBindings();
SetupBindings();
}
protected virtual void OnEnable()
{
if (_viewModel && _bindingContext != null)
{
_bindingContext.SetDataContext(_viewModel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
}
protected virtual void Start()
{
TryRegister();
ClosePanel();
}
protected virtual void Update()
{
}
protected virtual void OnDisable()
{
if (_viewModel != null)
{
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
}
protected virtual void OnDestroy()
{
TryUnregister();
_bindingContext?.Dispose();
}
protected virtual void TryRegister() { }
protected virtual void TryUnregister() { }
// BaseUi 메서드들을 직접 구현
public virtual void OpenPanel()
{
if (_enableBlockImage)
{
_blockImage.SetActive(true);
}
_panel.SetActive(true);
_viewModel?.Initialize();
}
public virtual void ClosePanel()
{
if (_enableBlockImage)
{
_blockImage.SetActive(false);
}
_panel.SetActive(false);
_viewModel?.Cleanup();
}
public virtual void SetUiInteractable(bool active)
{
if (_canvasGroup != null)
{
_canvasGroup.interactable = active;
_canvasGroup.blocksRaycasts = active;
}
}
public bool IsOpenPanel() => _panel && _panel.activeInHierarchy;
/// <summary>
/// Attribute 기반 자동 바인딩 설정
/// </summary>
private void SetupAutoBindings()
{
var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindToAttribute>() != null);
foreach (var field in fields)
{
var bindAttribute = field.GetCustomAttribute<BindToAttribute>();
SetupBinding(field, bindAttribute);
}
// 컬렉션 바인딩 설정
var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)
.Where(f => f.GetCustomAttribute<BindCollectionAttribute>() != null);
foreach (var field in collectionFields)
{
var bindAttribute = field.GetCustomAttribute<BindCollectionAttribute>();
SetupCollectionBinding(field, bindAttribute);
}
}
/// <summary>
/// 개별 필드의 바인딩 설정
/// </summary>
private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute)
{
var target = field.GetValue(this);
IValueConverter converter = null;
if (bindAttribute.ConverterType != null)
{
converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter;
}
// UI 컴포넌트 타입별 바인딩 타겟 생성
IBindingTarget bindingTarget = target switch
{
Text text => new TextBindingTarget(text, bindAttribute.PropertyPath),
Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath),
GameObject go => new ActiveBindingTarget(go, bindAttribute.PropertyPath),
Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath),
_ => null
};
if (bindingTarget != null)
{
_bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter);
}
}
/// <summary>
/// 컬렉션 바인딩 설정
/// </summary>
private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute)
{
var target = field.GetValue(this);
if (target is Transform parent)
{
// 컬렉션 바인딩 로직 (필요시 확장)
Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}");
}
}
/// <summary>
/// 추가 바인딩 설정 - 하위 클래스에서 구현
/// </summary>
protected virtual void SetupBindings() { }
/// <summary>
/// ViewModel 속성 변경 이벤트 핸들러
/// </summary>
protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
HandleCustomPropertyChanged(e.PropertyName);
}
/// <summary>
/// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드)
/// </summary>
protected virtual void HandleCustomPropertyChanged(string propertyName)
{
// 하위 클래스에서 구현
}
// 수동 바인딩 헬퍼 메서드들
protected void BindText(Text text, string propertyPath, IValueConverter converter = null)
{
var target = new TextBindingTarget(text, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindImage(Image image, string propertyPath, IValueConverter converter = null)
{
var target = new ImageBindingTarget(image, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindActive(GameObject gameObject, string propertyPath, IValueConverter converter = null)
{
var target = new ActiveBindingTarget(gameObject, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
protected void BindSlider(Slider slider, string propertyPath, IValueConverter converter = null)
{
var target = new SliderBindingTarget(slider, propertyPath);
_bindingContext?.Bind(propertyPath, target, converter);
}
/// <summary>
/// ViewModel 메서드 호출 헬퍼
/// </summary>
protected void InvokeViewModelMethod(string methodName, params object[] parameters)
{
if (_viewModel == null) return;
var method = _viewModel.GetType().GetMethod(methodName);
method?.Invoke(_viewModel, parameters);
}
/// <summary>
/// ViewModel 속성 설정 헬퍼
/// </summary>
protected void SetViewModelProperty(string propertyName, object value)
{
if (_viewModel == null) return;
var property = _viewModel.GetType().GetProperty(propertyName);
if (property != null && property.CanWrite)
{
property.SetValue(_viewModel, value);
}
}
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 868322e05b33bdd4cbbe3e1495fe359b

View File

@ -1,221 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using DDD.MVVM;
namespace DDD
{
/// <summary>
/// IntegratedPopupUi를 사용한 인벤토리 뷰 예시
/// 상속 대신 컴포지션 기반으로 모든 기능을 통합하여 구현
/// Attribute 기반 자동 바인딩을 적극 활용
/// </summary>
public class IntegratedInventoryView : IntegratedBasePopupUi<RestaurantUiActions, InventoryViewModel>
{
[Header("UI References")]
// Attribute를 통한 자동 바인딩 설정
[SerializeField, BindTo(nameof(InventoryViewModel.CategoryDisplayText))]
private Text _categoryLabel;
[SerializeField, BindTo(nameof(InventoryViewModel.ItemCountText))]
private Text _itemCountLabel;
[SerializeField, BindTo(nameof(InventoryViewModel.ShowEmptyMessage))]
private GameObject _emptyMessage;
// 수동 바인딩이 필요한 복잡한 UI 요소들
[SerializeField] private Transform _slotParent;
[SerializeField] private Button[] _categoryButtons;
[SerializeField] private Button[] _sortButtons;
[Header("Prefab References")]
[SerializeField] private GameObject _itemSlotPrefab;
protected override GameObject GetInitialSelected()
{
// ViewModel의 FirstValidItem을 활용하여 초기 선택 UI 결정
var firstItem = _viewModel?.FirstValidItem;
if (firstItem != null)
{
return FindSlotGameObject(firstItem);
}
return _categoryButtons?.Length > 0 ? _categoryButtons[0].gameObject : null;
}
/// <summary>
/// 수동 바인딩이 필요한 복잡한 UI 요소들 설정
/// Attribute로 처리하기 어려운 컬렉션이나 복잡한 로직이 필요한 경우
/// </summary>
protected override void SetupBindings()
{
// 복잡한 컬렉션 바인딩은 수동으로 처리
// BindCollection은 아직 완전 구현되지 않았으므로 HandleCustomPropertyChanged에서 처리
}
protected override void HandleCustomPropertyChanged(string propertyName)
{
switch (propertyName)
{
case nameof(InventoryViewModel.VisibleItems):
UpdateItemSlots();
break;
case nameof(InventoryViewModel.CurrentCategory):
UpdateCategoryButtons();
break;
case nameof(InventoryViewModel.CurrentSortType):
UpdateSortButtons();
break;
case nameof(InventoryViewModel.FirstValidItem):
UpdateInitialSelection();
break;
}
}
/// <summary>
/// 아이템 슬롯 UI 업데이트
/// </summary>
private void UpdateItemSlots()
{
if (_viewModel?.VisibleItems == null) return;
// 기존 슬롯들 정리
ClearSlots();
int siblingIndex = 0;
foreach (var itemViewModel in _viewModel.VisibleItems)
{
if (!itemViewModel.HasItem) continue;
// 아이템 슬롯 UI 생성
var slotGameObject = Instantiate(_itemSlotPrefab, _slotParent);
var slotUi = slotGameObject.GetComponent<ItemSlotUi>();
// 기존 방식대로 슬롯 초기화
slotUi.Initialize(itemViewModel, new InventorySlotUiStrategy());
slotGameObject.name = $"ItemSlotUi_{itemViewModel.Id}";
// 인터랙터 설정
var interactor = slotGameObject.GetComponent<ItemSlotInteractor>();
if (itemViewModel.ItemType == ItemType.Recipe)
{
interactor.Initialize(TodayMenuEventType.Add, new TodayMenuInteractorStrategy());
}
else if (DataManager.Instance.GetDataSo<CookwareDataSo>().TryGetDataById(itemViewModel.Id, out var cookwareData))
{
interactor.Initialize(TodayMenuEventType.Add, new TodayCookwareInteractorStrategy());
}
slotGameObject.transform.SetSiblingIndex(siblingIndex++);
}
}
/// <summary>
/// 카테고리 버튼 상태 업데이트
/// </summary>
private void UpdateCategoryButtons()
{
if (_categoryButtons == null || _viewModel == null) return;
for (int i = 0; i < _categoryButtons.Length; i++)
{
var button = _categoryButtons[i];
var isSelected = (int)_viewModel.CurrentCategory == i;
button.interactable = !isSelected;
}
}
/// <summary>
/// 정렬 버튼 상태 업데이트
/// </summary>
private void UpdateSortButtons()
{
if (_sortButtons == null || _viewModel == null) return;
for (int i = 0; i < _sortButtons.Length; i++)
{
var button = _sortButtons[i];
var isSelected = (int)_viewModel.CurrentSortType == i;
button.interactable = !isSelected;
}
}
/// <summary>
/// 초기 선택 UI 업데이트
/// </summary>
private void UpdateInitialSelection()
{
var initialSelected = GetInitialSelected();
if (initialSelected != null)
{
UnityEngine.EventSystems.EventSystem.current.SetSelectedGameObject(initialSelected);
}
}
/// <summary>
/// 기존 슬롯들 정리
/// </summary>
private void ClearSlots()
{
foreach (Transform child in _slotParent)
{
Destroy(child.gameObject);
}
}
/// <summary>
/// ItemViewModel에 해당하는 UI GameObject 찾기
/// </summary>
private GameObject FindSlotGameObject(ItemViewModel itemViewModel)
{
foreach (Transform child in _slotParent)
{
if (child.name == $"ItemSlotUi_{itemViewModel.Id}")
{
return child.gameObject;
}
}
return null;
}
// UI 이벤트 핸들러들 - ViewModel 메서드 호출
public void OnCategoryButtonClicked(int categoryIndex)
{
_viewModel?.SetCategory((InventoryCategoryType)categoryIndex);
}
public void OnSortButtonClicked(int sortIndex)
{
_viewModel?.SetSortType((InventorySortType)sortIndex);
}
public void OnItemSlotClicked(ItemViewModel item)
{
_viewModel?.SelectItem(item);
}
// 입력 처리 - ViewModel로 위임
protected override bool OnInputPerformed(RestaurantUiActions actionEnum, UnityEngine.InputSystem.InputAction.CallbackContext context)
{
var isHandled = base.OnInputPerformed(actionEnum, context);
// 특별한 입력 처리 로직이 필요한 경우 여기에 추가
if (isHandled)
{
switch (actionEnum)
{
case RestaurantUiActions.Cancel:
Close();
break;
// 기타 액션들은 ViewModel로 위임됨
}
}
return isHandled;
}
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 12f3141cdd485054e8c73be9d549feb7

View File

@ -5,36 +5,65 @@ namespace DDD
{
public abstract class BasePopupUi : BaseUi
{
public abstract InputActionMaps InputActionMaps { get; }
protected abstract GameObject GetInitialSelected();
public bool IsTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this);
public InputActionMaps InputActionMaps;
protected override void Awake()
{
base.Awake();
// BasePopupUi의 기본값 적용
_enableBlockImage = true;
}
protected override void OnEnable()
{
base.OnEnable();
}
protected override void Update()
{
base.Update();
// BasePopupUi의 Update 로직 구현
if (IsOpenPanel() == false) return;
var currentSelectedGameObject = EventSystem.current.currentSelectedGameObject;
if (currentSelectedGameObject == null || currentSelectedGameObject.activeInHierarchy == false)
{
if (GetInitialSelected() == null) return;
EventSystem.current.SetSelectedGameObject(GetInitialSelected());
var initialSelected = GetInitialSelected();
if (initialSelected != null)
{
EventSystem.current.SetSelectedGameObject(initialSelected);
}
}
}
protected override void TryRegister()
{
base.TryRegister();
UiManager.Instance.PopupUiState?.RegisterPopupUI(this);
}
protected override void TryUnregister()
{
base.TryUnregister();
UiManager.Instance?.PopupUiState?.UnregisterPopupUI(this);
}
public virtual void Open(OpenPopupUiEvent evt)
{
base.OpenPanel();
EventSystem.current.SetSelectedGameObject(GetInitialSelected());
OpenPanel();
var initialSelected = GetInitialSelected();
if (initialSelected != null)
{
EventSystem.current.SetSelectedGameObject(initialSelected);
}
transform.SetAsLastSibling();
}
public virtual void Close()
@ -43,5 +72,7 @@ public virtual void Close()
evt.UiType = GetType();
EventBus.Broadcast(evt);
}
protected abstract GameObject GetInitialSelected();
}
}

View File

@ -18,26 +18,55 @@ public static IEnumerable<T> GetFlags<T>(this T input) where T : Enum
}
}
}
public abstract class PopupUi<T> : BasePopupUi where T : Enum
public abstract class PopupUi<TInputEnum, TViewModel> : BasePopupUi
where TInputEnum : Enum
where TViewModel : SimpleViewModel
{
[SerializeField, Required] protected BaseUiActionsInputBinding<T> _uiActionsInputBinding;
[SerializeField, Required] protected BaseUiActionsInputBinding<TInputEnum> _uiActionsInputBinding;
protected readonly List<(InputAction action, Action<InputAction.CallbackContext> handler)> _registeredHandlers = new();
public override InputActionMaps InputActionMaps => _uiActionsInputBinding.InputActionMaps;
protected TViewModel _viewModel;
protected override void Awake()
{
base.Awake();
_viewModel = GetComponent<TViewModel>();
}
private bool _isTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this);
protected override void OnEnable()
{
base.OnEnable();
if (_viewModel && _bindingContext != null)
{
_bindingContext.SetDataContext(_viewModel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
}
protected override void OnDisable()
{
base.OnDisable();
if (_viewModel != null)
{
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
}
protected override void TryRegister()
{
base.TryRegister();
UiManager.Instance.PopupUiState.RegisterPopupUI(this);
// PopupUi의 입력 바인딩 등록
foreach (var actionEnum in _uiActionsInputBinding.BindingActions.GetFlags())
{
if (actionEnum.Equals(default(T))) continue;
var inputAction = InputManager.Instance.GetAction(_uiActionsInputBinding.InputActionMaps, actionEnum.ToString());
if (actionEnum.Equals(default(TInputEnum))) continue;
var inputAction =
InputManager.Instance.GetAction(_uiActionsInputBinding.InputActionMaps, actionEnum.ToString());
if (inputAction == null) continue;
var startedHandler = new Action<InputAction.CallbackContext>(context =>
@ -62,13 +91,15 @@ protected override void TryRegister()
_registeredHandlers.Add((inputAction, performedHandler));
_registeredHandlers.Add((inputAction, canceledHandler));
}
InputActionMaps = _uiActionsInputBinding.InputActionMaps;
}
protected override void TryUnregister()
{
base.TryUnregister();
UiManager.Instance?.PopupUiState?.UnregisterPopupUI(this);
// 입력 핸들러 해제
foreach (var (action, handler) in _registeredHandlers)
{
if (action != null)
@ -81,32 +112,29 @@ protected override void TryUnregister()
_registeredHandlers.Clear();
}
public override void Open(OpenPopupUiEvent evt)
{
base.Open(evt);
transform.SetAsLastSibling();
_viewModel?.Initialize();
if (UiManager.Instance.PopupUiState.IsTopPopup(this))
if (IsTopPopup)
{
InputManager.Instance.SwitchCurrentActionMap(_uiActionsInputBinding.InputActionMaps);
}
}
protected virtual bool OnInputStarted(T actionEnum, InputAction.CallbackContext context)
public override void Close()
{
return _isTopPopup;
base.Close();
_viewModel?.Cleanup();
}
protected virtual bool OnInputPerformed(T actionEnum, InputAction.CallbackContext context)
{
return _isTopPopup;
}
protected virtual bool OnInputCanceled(T actionEnum, InputAction.CallbackContext context)
{
return _isTopPopup;
}
// 입력 처리 메서드들
protected virtual bool OnInputStarted(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
protected virtual bool OnInputPerformed(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
protected virtual bool OnInputCanceled(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup;
}
}

View File

@ -1,114 +1,108 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using DDD.MVVM;
namespace DDD
{
public class RestaurantManagementUi : PopupUi<RestaurantUiActions>, IEventHandler<TodayMenuRemovedEvent>
/// <summary>
/// MVVM 패턴을 적용한 새로운 레스토랑 관리 UI
/// 기존 RestaurantManagementUi의 기능을 ViewModel과 분리하여 구현
/// </summary>
[RequireComponent(typeof(RestaurantManagementViewModel))]
public class RestaurantManagementUi : PopupUi<RestaurantUiActions, RestaurantManagementViewModel>
{
[Header("Sub Views")]
[SerializeField] private ChecklistView _checklistView;
[SerializeField] private InventoryView _inventoryView;
[SerializeField] private ItemDetailView _itemDetailView;
[SerializeField] private TodayMenuView _todayMenuView;
[SerializeField] private TodayRestaurantStateView _todayRestaurantStateView;
[Header("Tab Groups")]
[SerializeField] private TabGroupUi _sectionTabs;
[SerializeField] private TabGroupUi _menuCategoryTabs;
[SerializeField] private TabGroupUi _cookwareCategoryTabs;
[SerializeField] private Image _completeBatchFilledImage;
[SerializeField] private float _holdCompleteTime = 1f;
private float _elapsedTime;
private bool _isHolding;
[Header("Hold Progress UI")]
[SerializeField, BindTo(nameof(RestaurantManagementViewModel.NormalizedHoldProgress))]
private Image _completeBatchFilledImage;
private const string ChecklistFailedMessageKey = "checklist_failed_message";
protected override void Awake()
{
base.Awake();
SetupViewModelEvents();
}
protected override void Update()
{
base.Update();
if (_isHolding)
if (_viewModel != null && _viewModel.IsHolding)
{
UpdateHoldProgress();
_viewModel.UpdateHoldProgress();
}
}
private void UpdateHoldProgress()
{
if (_holdCompleteTime <= 0f)
{
HandleInteract2Canceled();
ProcessCompleteBatchAction();
return;
}
_completeBatchFilledImage.fillAmount = _elapsedTime;
var multiply = 1f / _holdCompleteTime;
if (_elapsedTime >= 1f)
{
HandleInteract2Canceled();
ProcessCompleteBatchAction();
return;
}
_elapsedTime += Time.deltaTime * multiply;
}
private void ProcessCompleteBatchAction()
{
if (RestaurantState.Instance.ManagementState.GetChecklistStates().Any(state => state == false))
{
ShowChecklistFailedPopup();
}
else
{
Close();
}
}
private void ShowChecklistFailedPopup()
{
var evt = GameEvents.OpenPopupUiEvent;
evt.UiType = typeof(ConfirmUi);
evt.IsCancelButtonVisible = true;
evt.NewMessageKey = ChecklistFailedMessageKey;
evt.OnConfirm = ClosePanel;
EventBus.Broadcast(evt);
}
protected override GameObject GetInitialSelected()
{
var inventoryViewInitialSelectedObject = _inventoryView.GetInitialSelected();
if (inventoryViewInitialSelectedObject) return inventoryViewInitialSelectedObject;
var menuCategoryFirstButton = _menuCategoryTabs.GetFirstInteractableButton();
if (menuCategoryFirstButton != null && menuCategoryFirstButton.activeInHierarchy)
{
return menuCategoryFirstButton;
}
var cookwareCategoryFirstButton = _cookwareCategoryTabs.GetFirstInteractableButton();
if (cookwareCategoryFirstButton != null && cookwareCategoryFirstButton.activeInHierarchy)
{
return cookwareCategoryFirstButton;
}
return null;
}
public override void Open(OpenPopupUiEvent evt)
{
base.Open(evt);
InitializeViews();
SetupCategoryTabs();
InitializeTabGroups();
SelectInitialTabs();
RegisterEventHandlers();
SetupTabs();
}
protected override GameObject GetInitialSelected()
{
// ViewModel의 현재 상태에 따라 초기 선택 UI 결정
var inventoryInitialSelected = _inventoryView.GetInitialSelected();
if (inventoryInitialSelected) return inventoryInitialSelected;
var menuCategoryButton = _menuCategoryTabs.GetFirstInteractableButton();
if (menuCategoryButton != null && menuCategoryButton.activeInHierarchy)
return menuCategoryButton;
var cookwareCategoryButton = _cookwareCategoryTabs.GetFirstInteractableButton();
if (cookwareCategoryButton != null && cookwareCategoryButton.activeInHierarchy)
return cookwareCategoryButton;
return null;
}
protected override void SetupBindings()
{
// Attribute 기반 자동 바인딩이 처리됨
// 추가적인 수동 바인딩이 필요한 경우 여기에 구현
}
protected override void HandleCustomPropertyChanged(string propertyName)
{
switch (propertyName)
{
case nameof(RestaurantManagementViewModel.CurrentSection):
UpdateSectionTabs();
break;
case nameof(RestaurantManagementViewModel.CurrentCategory):
UpdateCategoryTabs();
break;
}
}
private void SetupViewModelEvents()
{
if (!_viewModel) return;
_viewModel.OnBatchCompleted = HandleBatchCompleted;
_viewModel.OnChecklistFailed = HandleChecklistFailed;
_viewModel.OnMenuSectionSelected = HandleMenuSectionSelected;
_viewModel.OnCookwareSectionSelected = HandleCookwareSectionSelected;
_viewModel.OnCategoryChanged = HandleCategoryChanged;
_viewModel.OnTabMoved = HandleTabMoved;
_viewModel.OnInteractRequested = HandleInteractRequested;
_viewModel.OnCloseRequested = HandleCloseRequested;
_viewModel.OnMenuCategorySelected = HandleMenuCategorySelected;
}
private void InitializeViews()
@ -120,12 +114,15 @@ private void InitializeViews()
_todayRestaurantStateView.Initialize();
}
/// <summary>
/// 각 그룹별로 허용된 카테고리를 설정합니다.
/// </summary>
private void SetupTabs()
{
SetupCategoryTabs();
InitializeTabGroups();
SelectInitialTabs();
}
private void SetupCategoryTabs()
{
// 각 그룹별로 기본 허용 값 사용 (자동으로 적절한 카테고리 필터링)
_menuCategoryTabs.UseDefaultAllowedValues();
_cookwareCategoryTabs.UseDefaultAllowedValues();
}
@ -143,113 +140,137 @@ private void SelectInitialTabs()
_menuCategoryTabs.SelectFirstTab();
}
private void RegisterEventHandlers()
private void UpdateSectionTabs()
{
EventBus.Register<TodayMenuRemovedEvent>(this);
if (_viewModel == null) return;
_sectionTabs.SelectTab((int)_viewModel.CurrentSection);
}
public override void Close()
private void UpdateCategoryTabs()
{
base.Close();
EventBus.Unregister<TodayMenuRemovedEvent>(this);
}
protected override bool OnInputPerformed(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
if (base.OnInputPerformed(actionEnum, context) == false) return false;
switch (actionEnum)
if (_viewModel == null) return;
switch (_viewModel.CurrentSection)
{
case RestaurantUiActions.Cancel:
HandleCancelPerformed();
case SectionButtonType.Menu:
_menuCategoryTabs.SelectTab((int)_viewModel.CurrentCategory);
break;
case RestaurantUiActions.PreviousTab:
HandleMoveTabPerformed(-1);
break;
case RestaurantUiActions.NextTab:
HandleMoveTabPerformed(1);
break;
case RestaurantUiActions.Interact1:
HandleInteract1Performed();
break;
case RestaurantUiActions.Interact2:
HandleInteract2Performed();
case SectionButtonType.Cookware:
_cookwareCategoryTabs.SelectTab((int)_viewModel.CurrentCategory);
break;
}
return true;
}
protected override bool OnInputCanceled(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
if (base.OnInputPerformed(actionEnum, context) == false) return false;
switch (actionEnum)
{
case RestaurantUiActions.Interact2:
HandleInteract2Canceled();
break;
}
return true;
}
private void HandleCancelPerformed()
// ViewModel 이벤트 핸들러들
private void HandleBatchCompleted()
{
Close();
}
private void HandleMoveTabPerformed(int direction)
private void HandleChecklistFailed()
{
var evt = GameEvents.OpenPopupUiEvent;
evt.UiType = typeof(ConfirmUi);
evt.IsCancelButtonVisible = true;
evt.NewMessageKey = "checklist_failed_message";
evt.OnConfirm = ClosePanel;
EventBus.Broadcast(evt);
}
private void HandleMenuSectionSelected()
{
_menuCategoryTabs.SelectFirstTab();
}
private void HandleCookwareSectionSelected()
{
_cookwareCategoryTabs.SelectFirstTab();
}
private void HandleCategoryChanged(InventoryCategoryType category)
{
_inventoryView.UpdateCategoryView(category);
_itemDetailView.UpdateCategory(category);
}
private void HandleTabMoved(int direction)
{
_sectionTabs.Move(direction);
}
private void HandleInteract1Performed()
private void HandleInteractRequested()
{
var selected = EventSystem.current.currentSelectedGameObject;
var interactable = selected?.GetComponent<IInteractableUi>();
interactable?.OnInteract();
}
private void HandleInteract2Performed()
private void HandleCloseRequested()
{
_isHolding = true;
Close();
}
private void HandleInteract2Canceled()
private void HandleMenuCategorySelected(InventoryCategoryType category)
{
_isHolding = false;
_elapsedTime = 0f;
_completeBatchFilledImage.fillAmount = 0f;
_menuCategoryTabs.SelectTab((int)category);
}
// UI 이벤트 핸들러들 (TabGroupUi 콜백)
private void OnSectionTabSelected(int sectionValue)
{
var section = (SectionButtonType)sectionValue;
switch (section)
{
case SectionButtonType.Menu:
_menuCategoryTabs.SelectFirstTab();
break;
case SectionButtonType.Cookware:
_cookwareCategoryTabs.SelectFirstTab();
break;
default:
throw new ArgumentOutOfRangeException(nameof(section), section, null);
}
_viewModel?.SetSection((SectionButtonType)sectionValue);
}
private void OnCategoryTabSelected(int categoryValue)
{
var category = (InventoryCategoryType)categoryValue;
_inventoryView.UpdateCategoryView(category);
_itemDetailView.UpdateCategory(category);
_viewModel?.SetCategory((InventoryCategoryType)categoryValue);
}
public void Invoke(TodayMenuRemovedEvent evt)
// 입력 처리 - ViewModel로 위임
protected override bool OnInputPerformed(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
_menuCategoryTabs.SelectTab((int)evt.InventoryCategoryType);
var isHandled = base.OnInputPerformed(actionEnum, context);
if (isHandled && _viewModel != null)
{
switch (actionEnum)
{
case RestaurantUiActions.Cancel:
_viewModel.CloseUi();
break;
case RestaurantUiActions.PreviousTab:
_viewModel.MoveTab(-1);
break;
case RestaurantUiActions.NextTab:
_viewModel.MoveTab(1);
break;
case RestaurantUiActions.Interact1:
_viewModel.InteractWithSelected();
break;
case RestaurantUiActions.Interact2:
_viewModel.StartHold();
break;
}
}
return isHandled;
}
protected override bool OnInputCanceled(RestaurantUiActions actionEnum, InputAction.CallbackContext context)
{
var isHandled = base.OnInputCanceled(actionEnum, context);
if (isHandled && _viewModel != null)
{
switch (actionEnum)
{
case RestaurantUiActions.Interact2:
_viewModel.CancelHold();
break;
}
}
return isHandled;
}
}
}