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.
Implementation method 1-manually specify ViewModel
- 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>
- 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"; }
- 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
- 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 }; } }
- 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
- 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; }
- 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()); } }
- 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
- The singleton approach can be implemented using dependency injection
- 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.
- Define the enumeration of Page
public enum ApplicationPage {<!-- --> Empty, Login, Home }
- 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; } }
-
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>
-
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(); } }
-
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
-
Can be implemented by combining dependency injection
-
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.
- 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 }); } }
- 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; }
- 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); } }
- 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(); } }