Настройка NavigationService и NavigationView в одном месте
Введение
Основная цель заключается в настройке NavigationService и NavigationView в одном месте. Для того, чтобы минимизировать последствия в случае добавления или удаления элемента навигации NavigationItem. Не заниматься поиском нужного кода в разных местах проекта. Я бы хотел получить отзыв о достоинствах и недостатках моей реализации поставленной задачи. Код рабочий, ставлю тег инспекция-кода.
У нас есть Page с NavigationView и Frame. Расположено в ShellPage и контролируется ShellViewModel. Когда пользователь нажимает один из элементов навигации NavigationItem, нужно выполнить переход (NavigatonTo()) к выбранной странице. Чтобы делать это необходимо выполнить два действия. Во первых, создать словарь Dictionary в качестве реестра допустимых страниц. Где ключ это полное имя типа ViewModel, а значение это тип View.
private readonly ConcurrentDictionary<string, Type> _pages;
typeof(TViewModel).FullName; // Ключ
typeof(TView); // Значение
Второе действие состоит в создании элементов навигации NavigationItem и добавлении их к NavigationView.
private void AddNavItem<TViewModel>(string title, Symbol symbol)
{
var item = new NavigationViewItem
{
Content = title,
Icon = new SymbolIcon(symbol),
Tag = ConvertToKey<TViewModel>()
};
_navigationView.MenuItems.Add(item);
}
Я видел некоторые примеры по созданию навигации в UWP приложениях. Например, приложения созданные с помощью Window Template Studio, или в большом примере приложения от Microsoft. Оба имею несколько мест для описанных двух действий. Я бы хотел иметь одно место. Первый абзац описывает почему.
Описание
Приложение стартует с app.xaml.cs. Методы OnLaunched и OnActivated вызывают ActivationService. Цель по умолчанию, создать ShellPage через вызов конструктора и перейти на MainPage Больше информации здесь. Не относится к посту.
Начну описание с вызова конструктора ShellPage. Внутри вызов метода InitializeNavigation().
// shellFrame и navigationView это имена Frame и NavigationView элементов разметки XAML
ViewModel.InitializeNavigation(shellFrame, navigationView);
Метод выглядит так
public void InitializeNavigation(Frame frame, NavigationView navigationView)
{
_navigationView = navigationView;
_navigationView.BackRequested += OnBackRequested;
NavigationService.Initialize(frame);
NavigationService.Navigated += Frame_Navigated;
NavigationService.NavigationFailed += Frame_NavigationFailed;
new NavigationConfig(NavigationService, _navigationView);
}
Необходимые два шага для настройки навигации находятся здесь (то самое одно место).
new NavigationConfig(NavigationService, _navigationView);
С одной стороны, мне не нужна ссылка на этот экземпляр в будущем. С другой стороны, я бы хотел разделить настройку навигации от ShellViewModel. Может сделать его полностью статическим? Нынешний вариант выглядит экзотично.
Для добавления в словарь
private void ConfigureNavService()
{
_navigationService.Configure<ShellViewModel, ShellPage>();
_navigationService.Configure<MainViewModel, MainPage>();
_navigationService.Configure<SettingsViewModel, SettingsPage>();
}
Для добавления NavigationItem в NavigationView
private void ConfigureNavView()
{
AddNavItem<MainViewModel>("Home", Symbol.Home);
// Элемент Параметры (Settings) создается разметкой XAML
// IsSettingsVisible="True"
}
И наконец, до показа всего кода, нужно отметить два статических метода.
public static string ConvertToKey<TViewModel>() => typeof(TViewModel).FullName;
public static string GetPageKey(NavigationViewItem item) => item.Tag.ToString();
Только ConvertToKey() знает как создать ключ из TViewModel. Это сделано для того, чтобы иметь только одно место по созданию ключа и не повторять этот код там, где ключ нужен. NavigationConfig как раз отличное место. Поскольку именно тут ключ создается и добавляется к элементу и в словарь. Метод нужен в трех местах: NavigationConfig.AddNavItem(), NavigationService.Configure(), NavigationService.NavigateTo().
Похожая история с GetPageKey(). Только этот метод знает где хранится ключ в NavigationItem (свойство Tag). Этот метод нужен в двух местах: ShellViewModel.OnItemInvoked() и ShellViewModel.IsMenuItemForPageType().
Спасибо за любую помощь!
Код
ViewModelLocator
private static ViewModelLocator _current;
public static ViewModelLocator Current => _current ?? (_current = new ViewModelLocator());
private ViewModelLocator()
{
// Services
SimpleIoc.Default.Register<IActivationService, ActivationService>();
SimpleIoc.Default.Register<INavigationService, NavigationService>();
// ViewModels
SimpleIoc.Default.Register<ShellViewModel>();
SimpleIoc.Default.Register<MainViewModel>();
SimpleIoc.Default.Register<SettingsViewModel>();
}
public ShellViewModel ShellViewModel => SimpleIoc.Default.GetInstance<ShellViewModel>();
public MainViewModel MainViewModel => SimpleIoc.Default.GetInstance<MainViewModel>();
public SettingsViewModel SettingsViewModel => SimpleIoc.Default.GetInstance<SettingsViewModel>();
public IActivationService ActivationService => SimpleIoc.Default.GetInstance<IActivationService>();
ShellPage.xaml
<NavigationView x:Name="navigationView"
IsSettingsVisible="True"
IsBackButtonVisible="Visible"
IsBackEnabled="{x:Bind ViewModel.IsBackEnabled, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.Selected, Mode=OneWay}"
Header="{x:Bind ViewModel.Selected.Content, Mode=OneWay}">
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="ItemInvoked">
<ic:InvokeCommandAction Command="{x:Bind ViewModel.ItemInvokedCommand}" />
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
<Grid>
<Frame x:Name="shellFrame" />
</Grid>
</NavigationView>
ShellPage.xaml.cs
private ShellViewModel ViewModel => ViewModelLocator.Current.ShellViewModel;
public ShellPage()
{
InitializeComponent();
ViewModel.InitializeNavigation(shellFrame, navigationView);
}
ShellViewModel
private bool _isBackEnabled;
private NavigationView _navigationView;
private NavigationViewItem _selected;
private ICommand _itemInvokedCommand;
public bool IsBackEnabled
{
get => _isBackEnabled;
set => Set(ref _isBackEnabled, value);
}
public NavigationViewItem Selected
{
get => _selected;
set => Set(ref _selected, value);
}
public ICommand ItemInvokedCommand
{
get
{
return _itemInvokedCommand ?? (_itemInvokedCommand = new RelayCommand<NavigationViewItemInvokedEventArgs>(OnItemInvoked));
}
}
private INavigationService NavigationService { get; }
public ShellViewModel(INavigationService navigationService)
{
NavigationService = navigationService;
}
public void InitializeNavigation(Frame frame, NavigationView navigationView)
{
_navigationView = navigationView;
_navigationView.BackRequested += OnBackRequested;
NavigationService.Initialize(frame);
NavigationService.Navigated += Frame_Navigated;
NavigationService.NavigationFailed += Frame_NavigationFailed;
new NavigationConfig(NavigationService, _navigationView);
}
private void OnItemInvoked(NavigationViewItemInvokedEventArgs args)
{
if (args.IsSettingsInvoked)
{
NavigationService.NavigateTo<SettingsViewModel>();
return;
}
var item = _navigationView.MenuItems.OfType<NavigationViewItem>().First(menuItem => (string)menuItem.Content == (string)args.InvokedItem);
var pageKey = NavigationConfig.GetPageKey(item);
NavigationService.NavigateTo(pageKey);
}
private void OnBackRequested(NavigationView sender, NavigationViewBackRequestedEventArgs args)
{
NavigationService.GoBack();
}
private void Frame_Navigated(object sender, NavigationEventArgs e)
{
IsBackEnabled = NavigationService.CanGoBack;
Selected = e.SourcePageType == typeof(SettingsPage)
? _navigationView.SettingsItem as NavigationViewItem
: _navigationView.MenuItems.OfType<NavigationViewItem>().FirstOrDefault(menuItem => IsMenuItemForPageType(menuItem, e.SourcePageType));
}
private void Frame_NavigationFailed(object sender, NavigationFailedEventArgs e)
{
throw e.Exception;
}
private bool IsMenuItemForPageType(NavigationViewItem item, Type sourcePageType)
{
var pageKey = NavigationConfig.GetPageKey(item);
var navigatedPageKey = NavigationService.GetKeyForPage(sourcePageType);
return pageKey == navigatedPageKey;
}
INavigationService
public interface INavigationService
{
event NavigatedEventHandler Navigated;
event NavigationFailedEventHandler NavigationFailed;
bool CanGoBack { get; }
bool CanGoForward { get; }
void Initialize(Frame frame);
void Configure<TViewModel, TView>() where TView : Page;
string GetKeyForPage(Type page);
void GoBack();
void GoForward();
void NavigateTo<TViewModel>(object parameter = null, NavigationTransitionInfo infoOverride = null);
void NavigateTo(string pageKey, object parameter = null, NavigationTransitionInfo infoOverride = null);
}
NavigationService
public event NavigatedEventHandler Navigated;
public event NavigationFailedEventHandler NavigationFailed;
private Frame _frame;
private object _lastParamUsed;
private readonly ConcurrentDictionary<string, Type> _pages = new ConcurrentDictionary<string, Type>();
private Frame Frame
{
get
{
if (_frame == null)
{
_frame = Window.Current.Content as Frame ?? new Frame();
RegisterFrameEvents();
}
return _frame;
}
set
{
UnregisterFrameEvents();
_frame = value;
RegisterFrameEvents();
}
}
private Type CurrentPage => Frame.Content?.GetType();
public bool CanGoBack => Frame.CanGoBack;
public bool CanGoForward => Frame.CanGoForward;
public void Initialize(Frame frame) => Frame = frame;
public void Configure<TViewModel, TView>() where TView : Page
{
var key = NavigationConfig.ConvertToKey<TViewModel>();
_ = _pages.TryAdd(key, typeof(TView))
? true
: throw new InvalidOperationException($"ViewModel already registered '{key}'");
}
public string GetKeyForPage(Type page)
{
string key = _pages.Where(p => p.Value == page).Select(p => p.Key).FirstOrDefault();
return key ?? throw new ArgumentException(string.Format("ExceptionNavigationServicePageUnknown", page.Name));
}
public void GoBack() => Frame.GoBack();
public void GoForward() => Frame.GoForward();
public void NavigateTo<TViewModel>(object parameter = null, NavigationTransitionInfo infoOverride = null)
{
NavigateTo(NavigationConfig.ConvertToKey<TViewModel>(), parameter, infoOverride);
}
public void NavigateTo(string pageKey, object parameter = null, NavigationTransitionInfo infoOverride = null)
{
_ = _pages.TryGetValue(pageKey, out Type targetPage)
? true
: throw new ArgumentException(string.Format("ExceptionNavigationServicePageNotFound", pageKey), nameof(pageKey));
if (CurrentPage != targetPage || (parameter != null && !parameter.Equals(_lastParamUsed)))
{
_lastParamUsed = Frame.Navigate(targetPage, parameter, infoOverride)
? parameter
: _lastParamUsed;
}
}
private void RegisterFrameEvents()
{
if (_frame != null)
{
_frame.Navigated += Frame_Navigated;
_frame.NavigationFailed += Frame_NavigationFailed;
}
}
private void UnregisterFrameEvents()
{
if (_frame != null)
{
_frame.Navigated -= Frame_Navigated;
_frame.NavigationFailed -= Frame_NavigationFailed;
}
}
private void Frame_Navigated(object sender, NavigationEventArgs e) => Navigated?.Invoke(sender, e);
private void Frame_NavigationFailed(object sender, NavigationFailedEventArgs e) => NavigationFailed?.Invoke(sender, e);
NavigationConfig
private readonly INavigationService _navigationService;
private readonly NavigationView _navigationView;
public NavigationConfig(INavigationService navigationService, NavigationView navigationView)
{
_navigationService = navigationService;
_navigationView = navigationView;
ConfigureNavService();
ConfigureNavView();
}
private void ConfigureNavService()
{
_navigationService.Configure<ShellViewModel, ShellPage>();
_navigationService.Configure<MainViewModel, MainPage>();
_navigationService.Configure<SettingsViewModel, SettingsPage>();
}
private void ConfigureNavView()
{
AddNavItem<MainViewModel>("Home", Symbol.Home);
// Элемент Параметры (Settings) создается разметкой XAML
// IsSettingsVisible="True"
}
private void AddNavItem<TViewModel>(string title, Symbol symbol)
{
var item = new NavigationViewItem
{
Content = title,
Icon = new SymbolIcon(symbol),
Tag = ConvertToKey<TViewModel>()
};
_navigationView.MenuItems.Add(item);
}
public static string ConvertToKey<TViewModel>() => typeof(TViewModel).FullName;
public static string GetPageKey(NavigationViewItem item) => item.Tag.ToString();
Ответы (1 шт):
Опишу к чему пришел самостоятельно на данный момент.
Преимущества
Задача одного места для настройки NavigationService и NavigationView действительно создает удобство для внесения изменений. Но касающихся только регистрации ViewModel для NavigationService и элементов для NavigationView. Процесс превращается в одну строку.
private void ConfigureNavService()
{
_navigationService.Configure<ShellViewModel, ShellPage>();
_navigationService.Configure<MainViewModel, MainPage>();
_navigationService.Configure<SettingsViewModel, SettingsPage>();
}
private void ConfigureNavView()
{
AddNavItem<MainViewModel>("Home", Symbol.Home);
}
Также наличие логики по вычислению ключа и его извлечения, опять же, в одном месте NavigationConfig. Удобно для изменения. Не нужно править в разных частях кода.
public static string ConvertToKey<TViewModel>() => typeof(TViewModel).FullName;
public static string GetPageKey(NavigationViewItem item) => item.Tag.ToString();
Недостатки
Отрицательная сторона полученной реализации заключается в смешении классов. Изначально независимые, самостоятельные NavigatonServica и ShellViewModel начинают зависеть от NavigationConfig и использовать его не только для настройки, но и в обычной работе для создания и извлечения ключа. Имею ввиду методы ConvertToKey(), GetPageKey() требующиеся в: NavigationConfig.AddNavItem(), NavigationService.Configure(), NavigationService.NavigateTo(), ShellViewModel.OnItemInvoked(), ShellViewModel.IsMenuItemForPageType().
Не сказать, что сложно отследить такие связи, однако потребуется время.
Изменения
Сохраняю принцип одного места, но с точки зрения уровней.
Ранее процесс настройки откладывался до момента прихода в ShellViewModel.InitializeNavigation() (вызов NavigationConfig). Происходят следующие изменения. NavigationConfig удален. Регистрация допустимых страниц и создание элементов для NavigationView - разные уровни. Не находятся в одном месте.
- Создание
NavigationItemотдано разметкеXAML. То есть там, где определенNavigationView(ShellPage) там и описаны элементы навигации.
<NavigationView.MenuItems>
<NavigationViewItem x:Uid="ShellPage_MenuItem_Home" Icon="Home" Tag="Mergerify.ViewModels.MainViewModel" />
</NavigationView.MenuItems>
- Регистрация допустимых страниц происходит во
ViewModelLocator.
private ViewModelLocator()
{
SimpleIoc.Default.Register<IActivationService, ActivationService>();
SimpleIoc.Default.Register<INavigationService, NavigationService>();
Register<ShellViewModel, ShellPage>();
Register<MainViewModel, MainPage>();
Register<SettingsViewModel, SettingsPage>();
}
private void Register<TViewModel, TView>() where TViewModel : class
{
SimpleIoc.Default.Register<TViewModel>();
NavigationService.Configure<TViewModel, TView>();
}
Что произошло со статическими методами?
GetPageKey(). Необходим для получения ключа, находящегося вNavigationItem. Поскольку теперь элементы навигации находятся вShellPage, метод перенес вShellViewModel. И это единственное место, где требуется данные метод.ConvertToKey().NavigationServiceсодержит словарь_pagesс допустимыми страницами, имеет метод для добавления новых страницConfigure(). Поэтому это единственное место, которое знает как выглядит ключ. Для регистрации страниц создан методConfigure<TViewModel, TView>(). С его помощью регистрация проходит без необходимости знать как выглядит ключ. Отсюда методConvertToKey()стал приватным и находится вNavigationService.
Вывод
Описанные изменения позволили убрать смешение классов. Каждый использует только то, что нужно ему и не связывается с другим как это было раньше. Однако первоначальная цель единого места несколько отличается от новой реализации - единого места по уровням.
На данный момент найден один баг произведенных изменений. Он заключается в том, что ключ для NavigationItem вписывается в разметку элемента вручную. На данный момент перешел к сокращению ключа с Type.FullName к Type.Name. Тогда в элементах навигации достаточно писать имя от ViewModel.