3
\$\begingroup\$

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();

\$\endgroup\$
1
\$\begingroup\$

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 to XAMLmarkup. 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 to ShellViewModel. 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 pages Configure (). Therefore, it is the only place that knows what the key looks like. For check of the pages created method Configure<TViewModel, TView>(). With its help, registration takes place without need to know what the key looks like. From here, the ConvertToKey() method became private and is located in the NavigationService.

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.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.