1. Attribute와 DI를 이용하는 방법
메뉴 구성 정보는 원칙적으로 로직과 관계가 없으며 ViewModel을 사용자에게 표현하는데 필요한 정보이므로, ViewModel을 설명하는 메타데이터로 간주할 수 있습니다. 따라서 이를 특성으로 분리한 다음 애플리케이션을 시작할 때 reflection으로 특성을 읽어와 메뉴를 구성하는 방식으로 구현을 진행해 보겠습니다.
이 방법에서 사용한 외부 라이브러리는 아래와 같으며, Nuget으로 패키지를 설치할 수 있습니다.
- CommunityToolkit.MVVM
- Microsoft.Extensions.DependencyInjection
먼저 아이콘 바인딩에 필요한 것들을 만들어 주도록 하겠습니다.
문자열을 리소스 키로 이용하기 위해 View 어셈블리 안에 MarkupExtension을 구현합니다. 이 내용은 stackoverflow의 동일 질문에 대한 답변을 이용했으므로, 해당 답변을 인용하는 것으로 소스코드를 대신합니다.
다만 해당 유저의 구현은 TypeConverter에 대한 처리가 들어있지 않아, TypeConverter를 사용할 경우 소스코드를 일부 수정해야 합니다. 본 구현에서는 enum을 Full Name으로 바인딩하기 위해 TypeConverter를 이용할 예정이므로 해당 답변의 ResourceKeyChanged 메서드를 다음과 같이 수정합니다.
var typeConverter = TypeDescriptor.GetConverter(newVal.Item1.GetType()); // 추가
var value = typeConverter?.ConvertTo(newVal.Item1, typeof(string)) ?? newVal.Item1; // 추가
target.SetResourceReference(dp, value);
이제 바인딩 타입의 TypeConverter가 정의되어 있다면 해당 타입컨버터의 컨버팅을 진행한 다음 바인딩하게 됩니다.
그 다음으로 ViewModel 또는 ViewModel이 참조할 어셈블리 안에 enum을 풀네임으로 변환하기 위한 TypeConverter와 Icon 바인딩을 위한 enum을 각각 만들어 줍니다.
public class EnumFullNameConverter<T> : EnumConverter where T : Enum
{
public EnumFullNameConverter() : base(typeof(T))
{
}
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => $"{typeof(T).Name}.{value}";
}
[TypeConverter(typeof(EnumFullNameConverter<IconType>))]
public enum IconType
{
None,
Home,
Desktop
}
View에서 ViewModel의 IconType을 가져와 리소스 사전 안에 해당 키가 정의되어 있다면 아이콘 바인딩에 해당 리소스를 이용하게 되는데, 만약 enum 안에 보편적으로 사용되는 단어가 들어가는 경우 View에서 리소스를 이용할 때 문제가 발생할 가능성이 있습니다.
하지만 이렇게 리소스 사전에서 각 리소스 키를 IconType.Home 등 풀네임으로 지정하고 enum을 풀네임으로 변환해 바인딩하면 문제가 될 가능성을 줄일 수 있습니다.
그 다음으로 메뉴 구성에 필요한 정보를 Attribute로 분리합니다. 저는 헤더, 부모 아이템, 부모 아이템 안에서의 순서, 아이콘 타입을 Attribute로 분리해 아래와 같이 만들었습니다.
- 헤더: Header - string
- 부모 아이템: Parent - Type(부모 뷰모델의 타입)
- 순서: Order - ushort
- 아이콘: IconType - IconType(enum)
[AttributeUsage(AttributeTargets.Class)]
public sealed class HeaderAttribute : Attribute
{
public string? Header { get; set; }
public HeaderAttribute(string? header) => Header = header;
}
그 다음으로는 이러한 특성을 가지고 메뉴를 표현하는데 이용될 ModulePresentationItem 클래스를 만들어 줍니다.
public partial class ModulePresentationItem : ObservableRecipient
이 클래스는 다음과 같이 구성됩니다.
- 앞서 Attribute로 분리한 메뉴 구성 정보를 저장하는 프로퍼티
일단 메뉴가 구성되고 나면 변경될 일이 없으므로 변경 통보를 구현하지 않고 setter도 init으로 설정합니다. 만약 런타임에 변경될 필요가 있다면 변경 통보를 구현해야 합니다.
public string? Header { get; init; }
public Type? Parent { get; init; }
public ushort Order { get; init; }
public IconType? IconType { get; init; }
- 하위 아이템 및 하위 아이템 관리에 필요한 메서드
private readonly ObservableCollection<ModulePresentationItem> _child = new();
public IEnumerable<ModulePresentationItem> Child
{
get => this._child;
set
{
this._child.Clear();
foreach (var item in value)
{
AddChild(item);
}
OnPropertyChanged(nameof(Child));
}
}
internal void AddChild(ModulePresentationItem child)
{
this._child.Add(child);
}
- 연결되는 모듈의 ViewModel
public IModuleViewModel ViewModel { get; init; }
- 메뉴 선택 시 메인 ViewModel에 메뉴 변경을 통보하는 프로퍼티 및 Command
[ObservableProperty]
private bool _isSelected;
private RelayCommand? _changeMenuCommand;
public ICommand ChangeMenuCommand => this._changeMenuCommand ??= new RelayCommand(OnSelected);
protected virtual void OnSelected()
{
Messenger.Send(new ChangeModulePresentationItemMessage(this));
}
- ViewModel의 attribute를 읽어와 클래스로 파싱해주는 메서드
public static (bool IsValid, ModulePresentationItem? Value) TryParse(IModuleViewModel viewModel)
{
var viewModelType= viewModel.GetType();
// Header가 존재하지 않는다면 메뉴를 갖는 ViewModel이 아님
if (viewModelType.GetCustomAttribute(typeof(HeaderAttribute), false) is not HeaderAttribute header)
return (false, null);
var parent = viewModelType.GetCustomAttribute(typeof(ParentAttribute), false) as ParentAttribute;
var order = viewModelType.GetCustomAttribute(typeof(OrderAttribute), false) as OrderAttribute;
var iconType = viewModelType.GetCustomAttribute(typeof(IconTypeAttribute), false) as IconTypeAttribute;
var item = new ModulePresentationItem(viewModel)
{
Header = header?.Header,
Parent = parent?.Parent,
Order = order?.Order ?? ushort.MaxValue,
IconType = iconType?.IconType
};
return (true, item);
}
메뉴 아이템과 연결되는 뷰모델의 타입을 추상화한 ModuleViewModel은 다음과 같이 만듭니다.
public interface IModuleViewModel
{
bool IsVisible { get; set; }
}
우선 본 구현에서는 View의 Visibility에 바인딩할 프로퍼티만 있으면 되니 bool 타입으로 정의해 주었습니다.
ModulePresentationItem에서 메인 ViewModel에 메뉴 변경을 통보할 때 사용할 Message는 아래와 같이 ValueChangedMessage를 상속해 간단히 만들어 줍니다.
public class ChangeModulePresentationItemMessage : ValueChangedMessage<ModulePresentationItem>
{
public ChangeModulePresentationItemMessage(ModulePresentationItem newItem) : base(newItem)
{
}
}
그 다음으로는 뷰모델을 추상화한 IModuleViewModel과 뷰모델의 메뉴 구성 정보를 갖는 ModulePresentationItem을 연결해 주고 이를 관리할 클래스가 필요합니다. 이 클래스를 추상화한 IModuleViewModelFactory를 다음과 같이 만듭니다.
public interface IModuleViewModelFactory
{
// 계층적 메뉴 트리
IReadOnlyList<ModulePresentationItem> ModulePresentationHierarchy { get; }
// ModulePresentationItem으로부터 해당되는 IModuleViewModel을 반환
IModuleViewModel GetModuleViewModel(ModulePresentationItem presentationItem);
}
그 다음으로 IModuleViewModelFactory을 구현하는 ModuleViewModelFactory 클래스를 만듭니다. 이 클래스는 다음과 같이 구성됩니다.
- IModuleViewModel을 캐시해 저장하는 Dictionary
private readonly Dictionary<ModulePresentationItem, IModuleViewModel> _moduleViewModelCache = new();
- IModuleViewModelFactory에서 정의된 프로퍼티 및 메서드
private readonly IReadOnlyList<ModulePresentationItem> _modulePresentationHierarchy;
public IReadOnlyList<ModulePresentationItem> ModulePresentationHierarchy => this._modulePresentationHierarchy;
public IModuleViewModel GetModuleViewModel(ModulePresentationItem item)
{
if (!this._moduleViewModelCache.ContainsKey(item))
{
this._moduleViewModelCache.Add(item, item.ViewModel);
}
return this._moduleViewModelCache[item];
}
ModulePresentationItem을 인자로 받아 캐시에 해당 아이템과 연결되는 모듈의 ViewModel이 존재하는지 확인한 후 존재하면 캐시된 ViewModel을 반환하고, 존재하지 않으면 캐시한 후 반환합니다.
- 생성자
public ModuleViewModelFactory(IEnumerable<IModuleViewModel> viewModels)
=> this._modulePresentationHierarchy = BuildPresentationHierarchy(viewModels);
- 계층적 메뉴 트리를 구성하는 메서드
private List<ModulePresentationItem> BuildPresentationHierarchy(IEnumerable<IModuleViewModel> viewModels)
{
var ret = new List<ModulePresentationItem>();
var presentationItems = viewModels.Select(viewModels=> ModulePresentationItem.TryParse(viewModels))
.Where(item => item.IsValid)
.Select(item => item.Value!)
.ToList();
foreach (var item in presentationItems)
{
var parentType = item.Parent;
if (parentType is null)
{
ret.Add(item);
}
else
{
var parentItem = presentationItems.SingleOrDefault(x => x.ViewModel.GetType().Equals(parentType)) ?? throw new InvalidOperationException();
parentItem.AddChild(item);
}
}
return SortByOrder(ret);
}
private List<ModulePresentationItem> SortByOrder(IEnumerable<ModulePresentationItem> items)
{
items = items.OrderBy(x => x.Order);
foreach (var item in items)
{
if (item.Child.Any())
{
item.Child = SortByOrder(item.Child);
}
}
return items.ToList();
}
BuildPresentationHierarchy 메서드는 IModuleViewModel 타입의 열거형을 받아 ModulePresentationItem으로 파싱할 수 있는지, 즉 메뉴 구성에 필요한 Attribute가 존재하는지 확인합니다. 이후 파싱한 각 아이템에 대해 메뉴 트리상 부모 아이템이 존재하는지, 존재한다면 현재 보유한 메뉴 아이템 중 부모 아이템의 타입이 존재하는지 확인합니다. 만약 존재한다면 해당 메뉴 아이템의 자식 아이템에 추가하고, 부모 아이템이 존재하지 않는다면 부모 아이템이 존재하지 않으므로 예외를 드랍합니다.
그 다음 Order에 맞게 순서를 정렬한 후 리스트로 반환하게 됩니다.
이제 각 모듈의 뷰모델에 다음과 같이 메뉴 구성 정보를 Attribute로 입력할 수 있습니다.
// Root Menu Item
[Header("Module A")]
[Order(0)]
[IconType(IconType.Desktop)]
public partial class ModuleAViewModel : ModuleViewModelBase
{
}
// Child Menu Item
[Header("Module AA")]
[Parent(typeof(ModuleAViewModel))]
[Order(0)]
[IconType(IconType.None)]
public partial class ModuleAAViewModel : ModuleViewModelBase
{
}
다음으로 메뉴 컬렉션 및 메인 모듈을 갖는 메인 ViewModel을 만들 차례입니다.
public partial class ApplicationViewModel : ObservableRecipient, IRecipient<ChangeModulePresentationItemMessage>
{
private readonly IModuleViewModelFactory _moduleViewModelFactory;
public ApplicationViewModel(IModuleViewModelFactory moduleViewModelFactory)
{
this._moduleViewModelFactory = moduleViewModelFactory;
this._menuItems = this._moduleViewModelFactory.ModulePresentationHierarchy.ToObservable();
Messenger.Register(this);
}
[ObservableProperty]
private ObservableCollection<ModulePresentationItem> _menuItems = new();
[ObservableProperty]
private ObservableCollection<IModuleViewModel> _modules = new();
public void Receive(ChangeModulePresentationItemMessage message) => ChangeModulePresentationItem(message.Value);
private void ChangeModulePresentationItem(ModulePresentationItem newToolItem)
{
this._modules.Where(x => x.IsVisible)
.Foreach(x => x.IsVisible = false);
this._menuItems.Flatten()
.Where(x => x.IsSelected)
.Foreach(x => x.IsSelected = false);
var viewModel = this._moduleViewModelFactory.GetModuleViewModel(newToolItem);
if (!this._modules.Contains(viewModel))
this._modules.Add(viewModel);
viewModel.IsVisible = true;
newToolItem.IsSelected = true;
}
}
메뉴 트리와 Module 컬렉션을 가지고 있다가, 메뉴 선택 이벤트가 발생하는 경우 ChangeModulePresentationItem 메서드에서 선택된 메뉴 및 연결된 뷰모델의 선택 상태를 활성화하는 간단한 기능만 구현했습니다. ModulePresentationItem의 확장 메서드 Flatten은 계층 구조에서 일반적인 linq로는 자식 아이템의 선택 상태를 비활성화할 수 없으므로 메뉴 트리 내의 모든 메뉴 아이템을 열거형으로 가져오는 기능을 하고, ToObservable과 Foreach는 각각 ObservableCollection 변환 및 열거형에 반복문을 적용하는 기능을 합니다.
internal static IEnumerable<ModulePresentationItem> Flatten(this IEnumerable<ModulePresentationItem> e) => e.SelectMany(s => s.Child.Flatten().Prepend(s));
public static ObservableCollection<T> ToObservable<T>(this IEnumerable<T> list) => new(list);
public static void Foreach<T>(this IEnumerable<T> items, Action<T> action)
{
if (action is null)
throw new ArgumentNullException(nameof(action));
var list = items.ToList();
for (int i = 0; i < list.Count; i++)
{
action(list[i]);
}
}
이제 애플리케이션 진입점에서 각 뷰모델 및 Factory를 서비스로 등록해 DI로 넣어줄 수 있게만 만들어주면 됩니다. 이를 위한 클래스 Bootstrapper를 다음과 같이 정의합니다.
internal static class Bootstrapper
{
internal static void Initialize()
{
var services = ConfigureSerivces();
Ioc.Default.ConfigureServices(services);
}
private static IServiceProvider ConfigureSerivces()
{
var services = new ServiceCollection();
// Register Services //
services.AddSingleton<IModuleViewModelFactory, ModuleViewModelFactory>();
// Register Module ViewModels //
AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(c => c.GetTypes())
.Where(x => typeof(IModuleViewModel).IsAssignableFrom(x) && !x.IsAbstract)
.Foreach(type => services.AddTransient(typeof(IModuleViewModel), type));
// Register Application ViewModel //
services.AddTransient<ApplicationViewModel>();
return services.BuildServiceProvider();
}
}
IModuleViewModel 인터페이스를 구현하는 모든 클래스를 가져와, IModuleViewModel 타입과 함께 서비스로 등록하는 것 외에 따로 특이사항은 없습니다.
구현은 모두 끝났습니다. 이제 테스트를 진행해 보겠습니다.