Problem
Context
The main goal located a configuration NavigationService
and NavigationView
in one place. In order to minimalization a consequence in case adding, modifying or removing of NavigationItem
. I would like to get some feedback about advantages and disadvantages of the my implementation.
We have a Page
with NavigationView
and Frame
. Located in ShellPage
and control by ShellViewModel
. When a user clicks on NavigationItem
, we need to navigate to selected page. In order to do that we create dictionary with items for all pages. The key is full name of ViewModel type and the value is Page type. In addition we need to add NavigationItem
into NavigationView
to display items. In this way we have two settings: add to dictionary and add to NavigationView
.
I saw some example by Window Template Studio
and a large app sample by Microsoft. Both have some places for our two action. I want to one place. I find it easy to add or remove an element without looking for multiple places in the code.
Description
The app starts from app.xaml.cs
. Both OnLaunched
and OnActivated
call ActivationService
. The goal by default is creage ShellPage
by call constructor and navigate to MainPage
. More information on here. Not relevant to the post.
Let’s start from ShellPage
. Inside constructor we call a method InitializeNavigation()
.
// shellFrame and navigationView are names of Frame and NavigationView element into XAML
ViewModel.InitializeNavigation(shellFrame, navigationView);
This method looks like
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);
}
The steps described earlier for adding items to the dictionary and creating items to display are here (one place).
new NavigationConfig(NavigationService, _navigationView);
On the one hand, I don’t need a reference to this class in the future. On the other hand, I want to separate this setting from the ShellViewModel
. Maybe I need to make it completely static?
For add to dictionary do
private void ConfigureNavService()
{
_navigationService.Configure<ShellViewModel, ShellPage>();
_navigationService.Configure<MainViewModel, MainPage>();
_navigationService.Configure<SettingsViewModel, SettingsPage>();
}
For add NavigationItem
do
private void ConfigureNavView()
{
AddNavItem<MainViewModel>("Home", Symbol.Home);
// Settings is separate item, creates by NavigatonView with XAML IsSettingsVisible="True"
}
Finally, before showing the entire code, you need to note two static methods.
public static string ConvertToKey<TViewModel>() => typeof(TViewModel).FullName;
public static string GetPageKey(NavigationViewItem item) => item.Tag.ToString();
Only ConvertToKey()
known how to create key from TViewModel
. This is done in order to have only one way to get the key and not repeat it where the key is needed. NavigationConfig
is a great place because it is where this key is added to the dictionary. The method is required in three places: NavigationConfig.AddNavItem()
, NavigationService.Configure()
, NavigationService.NavigateTo()
.
A similar story with GetPageKey()
. Only this method knows where the NavigationItem
key is located (Tag
property). The method is required in two places: ShellViewModel.OnItemInvoked()
and ShellViewModel.IsMenuItemForPageType()
.
Thanks for any help!
Code
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 is separate item, creates by NavigatonView with 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();
Solution
I will describe what I came to on my own at the moment.
Advantages
The task of one place to configure NavigationService
and NavigationView
really creates a convenience for making changes. But it only applies to registering ViewModel
for NavigationService
and elements for NavigationView
. The process turns into a single line.
private void ConfigureNavService()
{
_navigationService.Configure<ShellViewModel, ShellPage>();
_navigationService.Configure<MainViewModel, MainPage>();
_navigationService.Configure<SettingsViewModel, SettingsPage>();
}
private void ConfigureNavView()
{
AddNavItem<MainViewModel>("Home", Symbol.Home);
}
Also, the presence of logic for calculating the key and extracting it, again, in one place Navigation Config
. Convenient to change. You don’t need to edit different parts of the code.
public static string ConvertToKey<TViewModel>() => typeof(TViewModel).FullName;
public static string GetPageKey(NavigationViewItem item) => item.Tag.ToString();
Disadvantages
The negative side of the resulting implementation is the mixing of classes. Initially independent, independent Navigationservice
and ShellViewModel
begin to depend on NavigationConfig
and use it not only for configuration, but also in normal work to create and retrieve a key. I mean the methods ConvertToKey()
, GetPageKey()
required in: NavigationConfig.AddNavItem()
, NavigationService.Configure()
, NavigationService.NavigateTo()
, ShellViewModel.OnItemInvoked()
, ShellViewModel.IsMenuItemForPageType()
.
Not to say that it is difficult to track such connections, but it will take time.
Changes
I keep the principle of one place, but in terms of levels.
Previously, the configuration process was delayed until the ShellViewModel.InitializeNavigation()
(calling NavigationConfig
). The following changes occur. NavigationConfig
has been removed. Registering valid pages and creating elements for NavigationView
are different levels. They are not in the same place.
- Creating
NavigationItem
is given toXAML
markup. That is, where
NavigationView
(ShellPage
) is defined there and the elements are described
navigations.
<NavigationView.MenuItems>
<NavigationViewItem x_Uid="ShellPage_MenuItem_Home" Icon="Home" Tag="Mergerify.ViewModels.MainViewModel" />
</NavigationView.MenuItems>
- Pages are registered in
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>();
}
What happened to static methods?
GetPageKey()
. Required to obtain a key located in the
NavigationItem
. Because now the navigation elements are in
ShellPage
, method moved toShellViewModel
. And that’s the only thing
the place where this method is required.ConvertToKey()
.NavigationService
contains the_pages
dictionary with valid
it has a method for adding new pagesConfigure ()
. Therefore, it is
the only place that knows what the key looks like. For check of the pages created
methodConfigure<TViewModel, TView>()
. With its help, registration takes place
without need to know what the key looks like. From here, theConvertToKey()
method
became private and is located in theNavigationService
.
Conclusion
The described changes made it possible to remove the mixing of classes. Each uses only what he needs and does not associate with the other as it was before. However, the original goal of a single location differs somewhat from the new implementation of a single location by level.
At the moment, one bug of the changes made has been found. It consists in the fact that the key for NavigationItem
fits into the element’s markup manually. At the moment, I switched to reducing the key with Type.FullName
to Type.Name
. Then it is enough to write the name from ViewModel
in the navigation elements. For example, instead full name I write only name MainViewModel
.