Build ViewModel-first navigation in WPF: a ContentControl bound to the current view model, DataTemplates keyed by DataType, a navigation service, parameters and a Back button.

Bishal Biswas
Self Learner
One window, many screens
CoinJar is growing. Besides the expense list, we want a summary screen with totals per category and a details screen for one expense. You could open a new window for each, but most modern apps keep one main window and swap the content in the middle, with a menu or side bar to move between screens.
There are two common ways to do this in WPF:
- View-first: you pick the next view (a
Page) and WPF’sFrameshows it. The view then creates or finds its view model. - ViewModel-first: you pick the next view model, and WPF finds the matching view for you through a
DataTemplate.
ViewModel-first fits MVVM best, because navigation becomes plain C# in your view models: no view types, easy to test. That is the approach this lesson builds.
The core trick: a ContentControl and DataTemplates
A ContentControl shows whatever object you put in its Content property. If that object is not a UI element, WPF looks for a DataTemplate whose DataType matches the object’s type, and uses it to draw the object. You saw this idea in Data Templates.
So if the content is an ExpenseListViewModel, and there is a template for that type, WPF shows an ExpenseListView:
<Window.Resources>
<DataTemplate DataType="{x:Type vm:ExpenseListViewModel}">
<views:ExpenseListView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SummaryViewModel}">
<views:SummaryView />
</DataTemplate>
</Window.Resources>
<ContentControl Content="{Binding Navigation.CurrentViewModel}" />
A template with a DataType and no x:Key is an implicit template: the type itself acts as its key. WPF also sets the view’s DataContext to the view model automatically, so every binding inside ExpenseListView just works.
Change CurrentViewModel to a SummaryViewModel, and the screen switches to SummaryView. That is all navigation is.
Step 1: turn screens into user controls
Each screen becomes a UserControl in the Views folder. In Visual Studio: right click the Views folder, Add, User Control (WPF). Move the XAML that used to be in MainWindow into ExpenseListView.xaml:
<UserControl x:Class="CoinJar.Views.ExpenseListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Text="Expenses" FontSize="20" Margin="0,0,0,8" />
<TextBlock DockPanel.Dock="Bottom" Text="{Binding Total, StringFormat='Total: {0:C}'}" />
<ListBox ItemsSource="{Binding Expenses}" DisplayMemberPath="Title" />
</DockPanel>
</UserControl>
Notice there is no DataContext set here. The DataTemplate provides it. You will learn much more about this kind of control in User Controls.
Step 2: a navigation service
Who changes CurrentViewModel? A small service. View models ask it to navigate, and it creates the next view model from the DI container you set up in Dependency Injection with the Generic Host.
public interface INavigationService : INotifyPropertyChanged
{
ObservableObject? CurrentViewModel { get; }
bool CanGoBack { get; }
void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ObservableObject;
void GoBack();
}
The where TViewModel : ObservableObject part is a generic constraint: you can only navigate to view models.
The implementation asks the container (IServiceProvider) for the view model:
public partial class NavigationService(IServiceProvider services) : ObservableObject, INavigationService
{
[ObservableProperty]
public partial ObservableObject? CurrentViewModel { get; private set; }
public void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ObservableObject
{
CurrentViewModel = services.GetRequiredService<TViewModel>();
}
}
private set means only the service can change the current screen. Everyone else just reads it. The full version with a Back button is in the code panel.
Step 3: the shell view model
The main window now hosts screens instead of being one. Its view model, often called the shell, holds the navigation service and the menu commands:
public partial class MainViewModel : ObservableObject
{
public MainViewModel(INavigationService navigation)
{
Navigation = navigation;
Navigation.NavigateTo<ExpenseListViewModel>(); // first screen
}
public INavigationService Navigation { get; }
[RelayCommand]
private void ShowExpenses() => Navigation.NavigateTo<ExpenseListViewModel>();
[RelayCommand]
private void ShowSummary() => Navigation.NavigateTo<SummaryViewModel>();
}
And the window binds to it:
<DockPanel>
<StackPanel DockPanel.Dock="Left" Width="160" Margin="8">
<Button Content="Expenses" Command="{Binding ShowExpensesCommand}" Margin="0,0,0,4" />
<Button Content="Summary" Command="{Binding ShowSummaryCommand}" />
</StackPanel>
<ContentControl Content="{Binding Navigation.CurrentViewModel}" />
</DockPanel>
Step 4: register everything
Every view model you navigate to must be registered, plus the service and the shell:
builder.Services.AddSingleton<INavigationService, NavigationService>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<ExpenseListViewModel>(); // keeps its state between visits
builder.Services.AddTransient<SummaryViewModel>(); // fresh numbers on every visit
builder.Services.AddTransient<ExpenseDetailsViewModel>();
builder.Services.AddSingleton<MainWindow>();
The lifetime now has a visible effect:
- A singleton screen keeps what the user typed. Leave the list and come back, and the half filled form is still there.
- A transient screen starts fresh each time. The summary recalculates on every visit, because a new
SummaryViewModelis built.
You do not register the views (ExpenseListView and friends). The DataTemplate creates them.
Passing a parameter
The details screen needs to know which expense to show. Add a small interface for view models that want a parameter:
public interface INavigationAware
{
void OnNavigatedTo(object? parameter);
}
The navigation service hands the parameter over before showing the screen:
var next = services.GetRequiredService<TViewModel>();
if (next is INavigationAware aware)
aware.OnNavigatedTo(parameter);
CurrentViewModel = next;
The details view model reads it with a pattern match:
public partial class ExpenseDetailsViewModel : ObservableObject, INavigationAware
{
[ObservableProperty]
public partial Expense? Expense { get; set; }
public void OnNavigatedTo(object? parameter) => Expense = parameter as Expense;
}
And the list view model navigates with the selected expense:
[RelayCommand]
private void ShowDetails(Expense? expense)
{
if (expense is not null)
_navigation.NavigateTo<ExpenseDetailsViewModel>(expense);
}
Adding a Back button
To go back, remember where you came from. A Stack<T> is perfect: push the old screen when you navigate, pop it to go back (see Stack in the C# course).
private readonly Stack<ObservableObject> _history = new();
public bool CanGoBack => _history.Count > 0;
public void GoBack()
{
if (_history.Count > 0)
CurrentViewModel = _history.Pop();
}
Add [NotifyPropertyChangedFor(nameof(CanGoBack))] on CurrentViewModel, then bind the button’s IsEnabled to Navigation.CanGoBack. Going back shows the same view model object as before, so any scroll position or typed text in it is still there.
The view-first alternative: Frame and Page
WPF has a built in navigation system too. A Frame shows Page objects and keeps a Back and Forward history, like a browser:
<Frame x:Name="MainFrame" NavigationUIVisibility="Hidden" />
MainFrame.Navigate(new SummaryPage());
It works, and it is handy for wizard style apps. But navigation happens in code-behind with view types, which makes it harder to test and to combine with DI. Most MVVM apps use the ViewModel-first approach from this lesson.
Common mistakes
- Forgetting the DataTemplate. If there is no template for a view model, the
ContentControlshows the class name as text, likeCoinJar.ViewModels.SummaryViewModel. That is your hint. - Setting
DataContextinside the view. The template already sets it. WritingDataContext = new SummaryViewModel()in the view’s constructor creates a second, disconnected view model. RelativeSource AncestorType=Windowinside a view. The window’sDataContextis nowMainViewModel, not the screen’s view model. UseAncestorType=UserControlto reach the view’s own view model.- Forgetting to register a view model.
GetRequiredServicethrows “No service for type … has been registered”. - Wrong lifetime. A screen that should reset (a “new expense” form) must be transient. A screen that should remember things should be a singleton.
Quick recap
- ViewModel-first navigation: a
ContentControlshows the current view model, and implicitDataTemplates keyed byDataTypepick the view. - A navigation service owns
CurrentViewModeland creates view models from the DI container. - The shell
MainViewModelexposes the service and the menu commands. INavigationAwarepasses a parameter, and aStackof old view models gives you a Back button.- Singleton screens keep their state, transient screens start fresh.
FrameandPageexist for view-first navigation, but fit MVVM less well.
// Services/INavigationService.cs
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CoinJar.Services;
public interface INavigationService : INotifyPropertyChanged
{
ObservableObject? CurrentViewModel { get; }
bool CanGoBack { get; }
void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ObservableObject;
void GoBack();
}
// View models that want a navigation parameter implement this
public interface INavigationAware
{
void OnNavigatedTo(object? parameter);
}
// Services/NavigationService.cs
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
namespace CoinJar.Services;
public partial class NavigationService(IServiceProvider services) : ObservableObject, INavigationService
{
private readonly Stack<ObservableObject> _history = new();
// The ContentControl in MainWindow binds to this
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGoBack))]
public partial ObservableObject? CurrentViewModel { get; private set; }
public bool CanGoBack => _history.Count > 0;
public void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ObservableObject
{
// The container decides: same instance (singleton) or a new one (transient)
TViewModel next = services.GetRequiredService<TViewModel>();
if (next is INavigationAware aware)
aware.OnNavigatedTo(parameter);
if (CurrentViewModel is not null && !ReferenceEquals(CurrentViewModel, next))
_history.Push(CurrentViewModel);
CurrentViewModel = next;
}
public void GoBack()
{
if (_history.Count > 0)
CurrentViewModel = _history.Pop();
}
}
// ViewModels/MainViewModel.cs
using CoinJar.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace CoinJar.ViewModels;
// The "shell": owns the menu, hosts the current screen
public partial class MainViewModel : ObservableObject
{
public MainViewModel(INavigationService navigation)
{
Navigation = navigation;
Navigation.NavigateTo<ExpenseListViewModel>();
}
public INavigationService Navigation { get; }
[RelayCommand]
private void ShowExpenses() => Navigation.NavigateTo<ExpenseListViewModel>();
[RelayCommand]
private void ShowSummary() => Navigation.NavigateTo<SummaryViewModel>();
[RelayCommand]
private void GoBack() => Navigation.GoBack();
}
// ViewModels/ExpenseDetailsViewModel.cs
using CoinJar.Models;
using CoinJar.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CoinJar.ViewModels;
public partial class ExpenseDetailsViewModel : ObservableObject, INavigationAware
{
[ObservableProperty]
public partial Expense? Expense { get; set; }
// Receives the expense passed to NavigateTo
public void OnNavigatedTo(object? parameter) => Expense = parameter as Expense;
}
// ViewModels/SummaryViewModel.cs
using CoinJar.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CoinJar.ViewModels;
public record CategoryTotal(string Category, decimal Amount);
// Registered as transient, so the numbers are fresh on every visit
public partial class SummaryViewModel : ObservableObject
{
public SummaryViewModel(IExpenseStore store)
{
var all = store.GetAll();
Total = all.Sum(e => e.Amount);
ByCategory = all.GroupBy(e => e.Category)
.Select(g => new CategoryTotal(g.Key, g.Sum(e => e.Amount)))
.OrderByDescending(c => c.Amount)
.ToList();
}
public decimal Total { get; }
public IReadOnlyList<CategoryTotal> ByCategory { get; }
}
// App.xaml.cs
using System.Windows;
using CoinJar.Services;
using CoinJar.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace CoinJar;
public partial class App : Application
{
private readonly IHost _host;
public App()
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Services.AddSingleton<IExpenseStore, InMemoryExpenseStore>();
builder.Services.AddSingleton<INavigationService, NavigationService>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<ExpenseListViewModel>();
builder.Services.AddTransient<SummaryViewModel>();
builder.Services.AddTransient<ExpenseDetailsViewModel>();
builder.Services.AddSingleton<MainWindow>();
// Views are NOT registered: the DataTemplates create them
_host = builder.Build();
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host.Start();
_host.Services.GetRequiredService<MainWindow>().Show();
}
protected override void OnExit(ExitEventArgs e)
{
_host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
_host.Dispose();
base.OnExit(e);
}
}
<!-- MainWindow.xaml -->
<Window x:Class="CoinJar.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CoinJar.ViewModels"
xmlns:views="clr-namespace:CoinJar.Views"
Title="CoinJar" Height="480" Width="720">
<Window.Resources>
<!-- Implicit templates: the DataType is the key -->
<DataTemplate DataType="{x:Type vm:ExpenseListViewModel}">
<views:ExpenseListView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SummaryViewModel}">
<views:SummaryView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:ExpenseDetailsViewModel}">
<views:ExpenseDetailsView />
</DataTemplate>
</Window.Resources>
<DockPanel>
<StackPanel DockPanel.Dock="Left" Width="160" Margin="8">
<Button Content="Back" Command="{Binding GoBackCommand}"
IsEnabled="{Binding Navigation.CanGoBack}" Margin="0,0,0,12" />
<Button Content="Expenses" Command="{Binding ShowExpensesCommand}" Margin="0,0,0,4" />
<Button Content="Summary" Command="{Binding ShowSummaryCommand}" />
</StackPanel>
<!-- Shows the current view model, drawn by its DataTemplate -->
<ContentControl Content="{Binding Navigation.CurrentViewModel}" />
</DockPanel>
</Window>
// MainWindow.xaml.cs
using System.Windows;
using CoinJar.ViewModels;
namespace CoinJar;
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready