Implement navigation function in MVVM mode in WPF

Implementing navigation function in MVVM mode in WPF

1. Using TabControl

Usage scenario: The project is small, so there is no need to consider memory overhead.
splashScreen

Implementation method 1-manually specify ViewModel

  1. Define 3 UserControl as Views for demonstration
 <UserControl
     ...>
     <Grid>
         <StackPanel Orientation="Vertical">
             <TextBlock
                 HorizontalAlignment="Center"
                 VerticalAlignment="Top"
                 Text="Page 1" />
             <TextBlock
                 d:Text="Page 1"
                 FontSize="50"
                 Text="{Binding PageMessage}" />
         </StackPanel>
     </Grid>
 </UserControl>
  1. Define ViewModel separately
 public abstract class PageViewModelBase
 {<!-- -->
     public string? Header {<!-- --> get; set; }
 }
 public class MainViewModel
 {<!-- -->
     public List<PageViewModelBase> ViewModels {<!-- --> get; }
 
     public MainViewModel(Page1ViewModel p1, Page2ViewModel p2, Page3ViewModel p3)
     {<!-- -->
         ViewModels = new List<PageViewModelBase> {<!-- --> p1, p2, p3 };
     }
 }
 public class Page1ViewModel : PageViewModelBase
 {<!-- -->
     public Page1ViewModel() => Header = "Page 1";
 
     public string PageMessage {<!-- --> get; set; } = "Hello, Page 1";
 }
 
 public class Page2ViewModel : PageViewModelBase
 {<!-- -->
     public Page2ViewModel() => Header = "Page 2";
 
     public string PageMessage {<!-- --> get; set; } = "Hello, Page 2";
 }
 
 public class Page3ViewModel : PageViewModelBase
 {<!-- -->
     public Page3ViewModel() => Header = "Page 3";
 
     public string PageMessage {<!-- --> get; set; } = "Hello, Page 3";
 }
  1. Define Tabcontrol on MainWindow
 <Window
            ...>
     <Grid>
         <TabControl ItemsSource="{Binding ViewModels}">
             <TabItem Header="Pag1">
                 <view:Page1>
                     <view:Page1.DataContext>
                         <local:Page1ViewModel />
                     </view:Page1.DataContext>
                 </view:Page1>
             </TabItem>
             <TabItem Header="Pag2">
                 <view:Page1>
                     <view:Page1.DataContext>
                         <local:Page2ViewModel />
                     </view:Page1.DataContext>
                 </view:Page1>
             </TabItem>
             <TabItem Header="Pag3">
                 <view:Page1>
                     <view:Page1.DataContext>
                         <local:Page3ViewModel />
                     </view:Page1.DataContext>
                 </view:Page1>
             </TabItem>
         </TabControl>
     </Grid>
 </Window>

This method requires manually specifying the ViewModel of each View.

Implementation method 2-using ItemTemplate

  1. Declare a ViewModel list in MainViewModel
public class MainViewModel
{<!-- -->
    public List<PageViewModelBase> ViewModels {<!-- --> get; }

    public MainViewModel(Page1ViewModel p1, Page2ViewModel p2, Page3ViewModel p3)
    {<!-- -->
        ViewModels = new List<PageViewModelBase> {<!-- --> p1, p2, p3 };
    }
}
  1. Specify an ItemTemplate for the TabControl in MainWindow, and use the ViewModel list declared in the previous step as the ItemsSource of the TabControl; add multiple DataTemplates to TabControl.Resources and specify what kind of Page the VM corresponds to.
<Window d:DataContext="{d:DesignInstance Type=local:MainViewModel}"
         ....>
    <Grid>
        <TabControl ItemsSource="{Binding ViewModels}">
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Header}"/>
                </DataTemplate>
            </TabControl.ItemTemplate>
            <TabControl.Resources>
                <DataTemplate DataType="{x:Type local:Page1ViewModel}">
                    <view:Page1/>
                </DataTemplate>
                <DataTemplate DataType="{x:Type local:Page2ViewModel}">
                    <view:Page2/>
                </DataTemplate>
                <DataTemplate DataType="{x:Type local:Page3ViewModel}">
                    <view:Page3/>
                </DataTemplate>
            </TabControl.Resources>
        </TabControl>
    </Grid>
</Window>

The advantage of this is that corresponding ViewModels are automatically bound to different Views.

Tips: Add d:DataContext="{d:DesignInstance Type=local:MainViewModel} in xaml, so that you have intelligence when writing Binding hint.

Both of the above two methods can be implemented in combination with dependency injection

2. Customize NavigationService service

splashScreen

  1. Implement a NavigationService service and serve as a singleton
 class NavigationService
 {<!-- -->
 //Set up a singleton service
     public static NavigationService Instance {<!-- --> get; private set; } = new NavigationService();
     //Declare an event to be triggered when the CurrentViewModel is changed
     public event Action? CurrentViewModelChanged;
  //Set a property of the current VM and trigger CurrentViewModelChanged when the property changes
     private ViewModelBase? currentViewModel;
     public ViewModelBase? CurrentViewModel
     {<!-- -->
         get => currentViewModel;
         set
         {<!-- -->
             currentViewModel = value;
             CurrentViewModelChanged?.Invoke();
         }
     }
     //Page navigation method, assign value to CurrentViewModel, trigger CurrentViewModelChanged event
     public void NavigateTo(ViewModelBase viewModel)=>CurrentViewModel = viewModel;
 }
  1. Set the CurrentViewModel property in MainViewModel
 public class ViewModelBase : ObservableObject{<!-- -->}
 public partial class MainViewModel : ViewModelBase
 {<!-- -->
     [ObservableProperty]
     private ViewModelBase? currentViewModel; //Current VM
 
     public MainViewModel()
     {<!-- -->
 //Bind the delegate method for the event, and set CurrentVM to be consistent with CurrentVM in NavigationService.
         NavigationService.Instance.CurrentViewModelChanged + = () =>
         {<!-- -->
             CurrentViewModel = NavigationService.Instance.CurrentViewModel;
         };
 
         //Call navigation method
         NavigationService.Instance.NavigateTo(new LoginViewModel());
     }
 }

The other two ViewModels are

 public partial class LoginViewModel : ViewModelBase
 {<!-- -->
     [ObservableProperty]
     string? userName = "Sean";

     [RelayCommand]
     void Login()
     {<!-- -->
         NavigationService.Instance.NavigateTo(new HomeViewModel());
     }
 }
 public partial class HomeViewModel : ViewModelBase
 {<!-- -->
     [ObservableProperty]
     string?userName;

     [RelayCommand]
     void Logout()
     {<!-- -->
         NavigationService.Instance.NavigateTo(new LoginViewModel());
     }
 }
  1. Use ContentControl as the carrier of different pages on MainWindow to display content, and use DataTemplate to realize the mapping of View and ViewModel
 <Window ...>
     <ContentControl Content="{Binding CurrentViewModel}">
         <ContentControl.Resources>
             <DataTemplate DataType="{x:Type vm:LoginViewModel}">
                 <view:Login />
             </DataTemplate>
             <DataTemplate DataType="{x:Type vm:HomeViewModel}">
                 <view:Home />
             </DataTemplate>
         </ContentControl.Resources>
     </ContentControl>
 </Window>

Set the DataTemplate in ContentControl.Resources and automatically select the corresponding VM based on the DataType. The advantage of this is that the View and VM will be automatically bound.

Improvements

  1. The singleton approach can be implemented using dependency injection
  2. In the NavigationService service, the method of page navigation can be improved
public void NavigateTo<T>() where T : ViewModelBase
    => CurrentViewModel = App.Current.Services.GetService<T>();

//Can be used when calling navigation methods
navigationService.NavigateTo<HomeViewModel>();

3. Use ValueConverter

To implement the function in the previous chapter, this method essentially automatically binds the VM through View.

  1. Define the enumeration of Page
 public enum ApplicationPage
 {<!-- -->
     Empty,
     Login,
     Home
 }
  1. Define each ViewModel
 public class ViewModelBase : ObservableObject{<!-- -->}
 public partial class MainViewModel : ViewModelBase
 {<!-- -->
     //CurrentPage in MainViewModel is an enumeration type
     [ObservableProperty]
     ApplicationPage currentPage;
 
     public MainViewModel()
     {<!-- -->
         CurrentPage = ApplicationPage.Login;
     }
 }
 public partial class LoginViewModel : ViewModelBase
 {<!-- -->
     public string UserName {<!-- --> get; set; } = "AngelSix";
 
     [RelayCommand]
     void Login()
     {<!-- -->
         var mainVM= App.Current.MainWindow.DataContext as MainViewModel;
         mainVM!.CurrentPage = ApplicationPage.Home;
     }
 }
 public partial class HomeViewModel : ViewModelBase
 {<!-- -->
     [RelayCommand]
     void Logout()
     {<!-- -->
         var mainVM = App.Current.MainWindow.DataContext as MainViewModel;
         mainVM!.CurrentPage = ApplicationPage.Login;
     }
 }
  1. Define the Page base class and each Page

    This method essentially automatically binds the VM through View, so use generics here

 public abstract class BasePage<VM> : UserControl where VM : ViewModelBase, new()
 {<!-- -->
     publicBasePage()
     {<!-- -->
         DataContext = new VM();
     }
 }
  • Implement Home page

Delete the inheritance in Home.xaml.cs, thinking that it and Home.xaml are partial classes of each other, and only implement inheritance on one partial class.

 <local:BasePage
    x:TypeArguments="vm:HomeViewModel"
                 ...>
      <!--x:TypeArguments specifies generics-->
     <Grid>
         <TextBlock HorizontalAlignment="Center"
             VerticalAlignment="Center"
             Text="Home"
             FontSize="32" />
         <Button Margin="10" Grid.Row="1"
          HorizontalAlignment="Right"
          VerticalAlignment="Bottom"
          Content="Logout"
          Command="{Binding LogoutCommand}" />
     </Grid>
 </local:BasePage>
  • Implement Login page

The method is the same as that of implementing the Home page

 <local:BasePage
     x:TypeArguments="vm:LoginViewModel" ...>
     <Grid>
         <Border
             Padding="10"
             HorizontalAlignment="Center"
             VerticalAlignment="Center"
             BorderBrush="LightGray"
             BorderThickness="1"
             CornerRadius="10">
             <StackPanel Width="300">
                 <TextBlock HorizontalAlignment="Center" FontSize="28">Login</TextBlock>
                 <Separator Margin="0,10" />
                 <TextBlock>User name:</TextBlock>
                 <TextBox
                     Margin="0,10"
                     InputMethod.IsInputMethodEnabled="False"
                     Text="{Binding UserName}" />
                 <TextBlock>Password:</TextBlock>
                 <PasswordBox Margin="0,10" Password="123456" />
                 <Button Command="{Binding LoginCommand}" Content="Login" />
             </StackPanel>
         </Border>
     </Grid>
 </local:BasePage>
  1. DefinePageViewConverter

    public class PageViewConverter : IValueConverter
    {<!-- -->
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {<!-- -->
            switch ((ApplicationPage)value)
            {<!-- -->
                case ApplicationPage.Empty:
                    return new TextBlock {<!-- --> Text = "404 Not Found" };
                case ApplicationPage.Login:
                    return new Login();
                case ApplicationPage.Home:
                    return new Home();
                default:
                    throw new ArgumentException("Invalid value passed to ApplicationPageViewConverter");
            }
        }
    
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {<!-- -->
            throw new NotImplementedException();
        }
    }
    
  2. Complete MainWindow

 <Window ...>
     <Window.DataContext>
         <local:MainViewModel/>
     </Window.DataContext>
     <Window.Resources>
         <share:PageViewConverter x:Key="pageConv"/>
     </Window.Resources>
     <ContentControl Content="{Binding CurrentPage,Converter={StaticResource pageConv}}"/>
 </Window>

Improvements

  1. Can be implemented by combining dependency injection

  2. The navigation method can be encapsulated as a NavigationService service

 //Encapsulation service
 class NavigationService
 {<!-- -->
     public static NavigationService Instance {<!-- --> get; } = new NavigationService();
 
     private MainViewModel mainVM;
 
     public void Navigate(ApplicationPage page)
     {<!-- -->
         if (mainVM == null)
         {<!-- -->
             mainVM = (MainViewModel)App.Current.MainWindow.DataContext;
         }
 
         mainVM.CurrentPage = page;
     }
 }
 //original way
 void Logout()
 {<!-- -->
    var mainVM = App.Current.MainWindow.DataContext as MainViewModel;
    mainVM!.CurrentPage = ApplicationPage.Login;
 }
 //Use the encapsulated service
 void Login()
 {<!-- -->
     NavigationService.Instance.Navigate(ApplicationPage.Login);
 }

4. Using Frame and NavigationService

To implement the function in the previous chapter, you essentially use dependency injection to bind View and ViewModel, and use Frame’s own Navigate method for navigation.

  1. DefineViewModel
 public class ViewModelBase : ObservableObject{<!-- -->}
 
 public partial class MainWindowViewModel : ViewModelBase
 {<!-- -->
     private readonly NavigationService navigationService;
     //Dependency injection
     public MainWindowViewModel(NavigationService navigationService)
     {<!-- -->
         this.navigationService = navigationService;
     }
 
     [RelayCommand]
     void Loaded()
     {<!-- --> //Navigation method implemented by navigationService
         navigationService.Navigate<LoginViewModel>();
     }
 }
 
 public partial class HomeViewModel : ViewModelBase
 {<!-- -->
     [ObservableProperty]
     string?userName;
 }
 
 public partial class LoginViewModel : ViewModelBase
 {<!-- -->
     private readonly NavigationService navigationService;
     //Dependency injection
     public string UserName {<!-- --> get; set; } = "Sergio";
 
     public LoginViewModel(NavigationService navigationService)
     {<!-- -->
         this.navigationService = navigationService;
     }
 
     [RelayCommand]
     void Login()
     {<!-- --> //Navigation method implemented by navigationService, parameters are passed here
         navigationService.Navigate<HomeViewModel>(new Dictionary<string, object?>
         {<!-- -->
             [nameof(HomeViewModel.UserName)] = UserName
         });
     }
 }
  1. DefineView

Main window, using Behaviors to implement mvvm mode

<Window
    xmlns:b="http://schemas.microsoft.com/xaml/behaviors">
    <b:Interaction.Triggers>
        <b:EventTrigger>
            <b:InvokeCommandAction Command="{Binding LoadedCommand}" />
        </b:EventTrigger>
    </b:Interaction.Triggers>
</Window>

Main window background class

public partial class MainWindow : Window
{<!-- -->
    public MainWindow(MainWindowViewModel viewModel,Frame frame)
    {<!-- -->
        InitializeComponent();
        DataContext = viewModel;
        AddChild(frame);
    }
}

Other Views

<!--Use Page to host content-->
<Page...>
    <Grid>
        <TextBlock HorizontalAlignment="Center"
                   VerticalAlignment="Center"
                   d:Text="Hello, world!"
                   Text="{Binding UserName, StringFormat='Hello, {0}!'}"
                   FontSize="32" />
    </Grid>
</Page>

<Page...>
    <Grid>
        <Border Padding="10"
                HorizontalAlignment="Center"
                VerticalAlignment="Center"
                BorderThickness="1"
                CornerRadius="10"
                BorderBrush="LightGray">
            <StackPanel Width="300">
                <TextBlock HorizontalAlignment="Center" FontSize="28">Login</TextBlock>
                <Separator Margin="0,10" />
                <TextBlock>User name:</TextBlock>
                <TextBox Margin="0,10" Text="{Binding UserName}" InputMethod.IsInputMethodEnabled="False" />
                <TextBlock>Password:</TextBlock>
                <PasswordBox Margin="0,10" Password="123456" />
                <Button Content="Login" Command="{Binding LoginCommand}" />
            </StackPanel>
        </Border>
    </Grid>
</Page>

Define DataContext using dependency injection in the background class

public Home(HomeViewModel viewModel)
{<!-- -->
    InitializeComponent();
    DataContext = viewModel;
}

public Login(LoginViewModel viewModel)
{<!-- -->
    InitializeComponent();
    DataContext = viewModel;
}
  1. Implement NavigationService
 public class NavigationService
 {<!-- -->
     //Registered singleton Frame
     private readonly Frame? mainFrame;
 
     public NavigationService(Frame? frame)
     {<!-- -->
         mainFrame = frame;
         //To use the LoadCompleted event
         mainFrame.LoadCompleted + = MainFrame_LoadCompleted;
     }
 
     private void MainFrame_LoadCompleted(object sender, System.Windows.Navigation.NavigationEventArgs e)
     {<!-- -->
         if (e.ExtraData is not Dictionary<string,object?> extraData)
         {<!-- -->
             return;
         }
         if ((mainFrame?.Content as FrameworkElement)?.DataContext is not ViewModelBase vm)
         {<!-- -->
             return;
         }
         foreach (var item in extraData)
         {<!-- -->
             //Assign a value to each attribute
             vm.GetType().GetProperty(item.Key)?.SetValue(vm, item.Value);
         }
     }
    //Find View according to VM type, pay attention to the naming convention of VM and View
     private Type? FindView<T>()
     {<!-- -->
         return Assembly.GetAssembly(typeof(T))?.GetTypes().FirstOrDefault(x => x.Name == typeof(T).Name.Replace("ViewModel", ""));
     }
     public void Navigate<T>(Dictionary<string,object?>? extraData=null) where T:ViewModelBase
     {<!-- -->
         var viewType = FindView<T>();
         if (viewType is null)
             return;
         var page = App.Current.Services.GetService(viewType) as Page;
 //Use Frame's Navigate method to navigate and pass parameters
         mainFrame?.Navigate(page,extraData);
     }
 }
  1. Register the required classes. In this case, register them in App.cs
 public partial class App : Application
 {<!-- -->
     public IServiceProvider Services {<!-- --> get; }
 
     public static new App Current => (App)Application.Current;
 
     publicApp()
     {<!-- -->
         var container = new ServiceCollection();
 
         container.AddSingleton(_ => new Frame {<!-- --> NavigationUIVisibility = NavigationUIVisibility.Hidden });
 
         container.AddSingleton<MainWindow>();
         container.AddSingleton<MainWindowViewModel>();
 
         container.AddTransient<Login>();
         container.AddTransient<Home>();
 
         container.AddTransient<LoginViewModel>();
         container.AddTransient<HomeViewModel>();
 
         container.AddSingleton<NavigationService>();
 
         Services = container.BuildServiceProvider();
     }
 
     protected override void OnStartup(StartupEventArgs e)
     {<!-- -->
         base.OnStartup(e);
 
         MainWindow = Services.GetRequiredService<MainWindow>();
         MainWindow.Show();
     }
 }