Hey I have a question about the DataContext. I built myself a NavigationService because I wanted to try it out. I am caching the ViewModel from a page and reuse it when navigating back (I know there is a built in Navigation History but I wanted to try this myself).
I face the issue that under a certain scenario, the DataContext is not getting applied to the UI. So basically what I do is register a Frame in my service on which the Navigated event is subscribed to:
public void RegisterFrame(string frameKey, Frame frame) {
if (!frames.TryGetValue(frameKey, out FrameNavigationModel? frameNavigationModel)) {
throw new InvalidOperationException($"Frame with key {frameKey} not found.");
}
if (frameNavigationModel.Frame != null) {
return;
}
frameNavigationModel.Frame = frame;
frame.Navigated += OnFrameNavigated;
}
private void OnFrameNavigated(object sender, NavigationEventArgs e) {
Page page = (Page)e.Content;
if (e.ExtraData is ViewModelBase viewModelBase) {
page.DataContext = viewModelBase;
}
}
Let me show you some screenshots for more context:
Screenshot 1: Startup
Screenshot 2: Navigated to "Settings" and "Environment"
Screenshot 3: Navigated from "Environment" to "Execution"
Screenshot 4: Navigated from "Execution" to "Environment" (You can see, the DataContext is applied correctly)
Screenshot 5: Navigated from "Settings" to "Scripting"
Screenshot 6: Navigated from "Scripting" back to "Settings" and "Environment" (You can see, the DataContext is NOT getting applied correctly.
Screenshot 7: Shows the runtime page.DataContext
where we can see that the DataContext is applied correctly.
Now let me give you some more Context on how I do the Navigation:
public void Navigate(string frameKey, Uri pageUri) {
if (!frames.TryGetValue(frameKey, out FrameNavigationModel? frameModel))
throw new InvalidOperationException($"Frame model with key {frameKey} not found.");
if (frameModel.Frame == null) {
throw new InvalidOperationException($"Frame with key {frameKey} not registered.");
}
// Frame will not have absolut path as CurrentSource
// -> Combine with BasePath
Uri currentSource = new Uri(frameModel.BasePath + frameModel.Frame.CurrentSource.ToString());
if (!frameModel.ViewModelMapping.TryGetValue(currentSource, out Type? viewModelType)) {
throw new InvalidOperationException($"Page Uri {frameModel.Frame.CurrentSource} not found.");
}
if (viewModelCache.TryGet(viewModelType, out ViewModelBase? viewModel)) {
viewModelCache.AddOrUpdate((ViewModelBase)((Page)frameModel.Frame.Content).DataContext);
}
if (frameModel.ViewModelMapping.TryGetValue(pageUri, out Type? newViewModelType)) {
ViewModelBase newViewModel = viewModelCache.GetOrNew(newViewModelType);
frameModel.Frame.Navigate(pageUri, newViewModel);
}
else {
frameModel.Frame.Navigate(pageUri);
}
}
I have no idea what I am missing here.
The only thing I tried is debugging the application. I cannot see a difference for the first time navigating to "Settings" and the second time navigating to "Settings". Because both times the cache will already hold the "SettingsPageViewModel" and use it.
Maybe cleaning up the navigation design and implementation will fix the issue.
I think you are using way too many lookup tables too. Changing the design would simplify your code. Less complexity means less potential bugs (like in your situation). In the end, all you want to do is to dynamically show a page that reuses its previous DataContext
(page view model). This is a trivial task.
Setting the DataContext
explicitly is usually a code smell. Most of the time there are better ways to generate view elements that automatically get their DataContext
set by the framework (for example when using a DataTemplate
). See the example below.
All you have to do is
Move the path combination to the FrameNavigationModel
as both segments are already managed by the FrameNavigationModel
. It looks like you on using the combined Uri
to identify the required view model type. Instead of creating assembled URIs that look error prone, introduce an enum for each page type or page context. Each enum value will map to a view model type:
public enum PageId
{
Default = 0,
Scripting,
Settings,
}
Create a factory that creates the view models based on the provided PageId
:
public class PageViewModelFactory
{
public ViewModelBase Create(PageId pageId)
{
switch (pageId)
{
case Scripting: return new ScriptingPageViewModel();
case Settings: return new SettingsViewModel();
default: throw new NotImplementedException();
}
}
}
Don't navigate by URI. URIs will potentially change during the development process. As you are not relying on the Frame
for a navigation history, you should navigate by view model. You simply set the next page's view model as Frame.Content
.
To make it work, first move each Page
to a DataTemplate
where each page's DataTemplate
maps to its associated view model. The DataTemplate
definitions should all be implicit (keyless):
<!-- App.xaml -->
<!--
Define a DataTemplate that contains a Page or custom control/UserControl.
The DataContext is automatically set to the templated item,
the page's view model
-->
<DataTemplate DataType="{x:type ScriptingPageViewModel}">
<Page>
...
</Page>
</DataTemplate>
In FrameNavigationModel
you basically navigate with
Frame.Navigate(scriptingPageViewModel)
which causes the Frame
to fetch the appropriate DataTemplate
for its Frame.Content
. This works because Frame
is actually a ContentControl
. You should make use of this fact to enhance your data model handling (see below). When following this example, you can safely replace Frame
with the lkightweight ContentControl
.
Move the retrieval of the next page's view model to the FrameNavigationModel
. You are currently asking the FrameNavigationModel
for data only to pass this data directly back to the FrameNavigationModel
to tell it to navigate using this data. This is commonly considered a code smell (see Martin Fowler's postulated Tell-Don't-Ask principle). Instead, you should a) use the frameKey
parameter to get the FrameNavigationModel
and b) take the pageUri
parameter and simply call FrameNavigationModel.Navigate
. The navigation logic itself should be in the object (the FrameNavigationModel
) that manages the required state:
// All the logic that was previously implemented in this method
// is now moved to the FrameNavigationModel to improve the design
// and to reduce lines of code
public void Navigate(string frameKey, PageId pageId)
{
if (!frames.TryGetValue(frameKey, out FrameNavigationModel? frameModel))
{
throw new InvalidOperationException($"Frame model with key {frameKey} not found.");
}
bool isNavigationSuccessful = frameModel.NavigateTo(pageId);
}
The FrameNavigationModel
then internally gets or creates the view model using the previously defined PageViewModelFactory
. Finally, FrameNavigationModel
delegates the navigate command to the Frame
. Since we are now navigating by view model, we no longer have to care about any DataContext
and perform wild type castings. This is all gone now and much simplified. If for what reason you have to interact with the current page's view model, directly store this view model in a field. There is no need to reference any Page
object. The Frame
is the only object that actually deals with Page
objects. The same way you don't deal with ListBoxItem
objects of the ListBox
.
For the sake of completeness, I recommend to replace the Frame
with a lightweight ContentControl
. Then bind the below CurrentPageViewModel
property to the ContentControl.Content
property.
The Frame
is a very heavy control. Unless you want to navigate to HTTP sources, a plain ContentControl
is favorable (note, Frame
extends ContentControl
and adds lots of commonly useless features and overhead). Frame
would create a Page and assign it to its inherited Content
property. You can do that yourself.
When assigning a data model to the Content.Content
property WPF will try to find a matching DataTemplate
e.g. from the ContentControl.ContentTemplate
property or from e.g. App.xaml* (see above):
public class FrameNavigationModel
{
// If you bind to this property, let FrameNavigationModel implement
// INotifyPropertyChanged and raise the PropertyChanged event
// from the property setter
public ViewModelBase CurrentPageViewModel { get; private set; }
// Instead of using the Frane to navigate, simply bind
// the Frame.Content property to the CurrentPageViewModel property.
// This makes the frame field obsolete.
// In XAML you can even replace the heavy Frame
// with a simple and lightweight ContentControl.
// Getting rid of the Frame reference is only beneficial.
private readonly Frame frame;
private readonly Dictionary<PageId, ViewModelBase> pageModelMap;
private readonly PageViewModelFactory pageViewModelFactory;
public FrameNavigationModel()
{
// TODO::Initialize read-only fields
}
public bool NavigateTo(PageId pageId)
{
if (!this.pageModelMap.TryGetValue(pageId, out ViewModelBase pageViewModel))
{
pageViewModel = this.pageViewModelFactory.Create(pageId);
this.pageModelMap.Add(pageId, pageViewModelFactory);
}
bool isNavigationSuccessful = this.frame.Navigate(pageViewModel);
if (isNavigationSuccessful)
{
this.CurrentPageViewModel = pageViewModel;
}
return isNavigationSuccessful;
}
}
To provide the (probably different) DataContext
for the item column (the middle column that contains the items "Environment", "Execution" and "WinRAR"), you would use composition. For example, for the "Scripting" page, the ScriptingPageViewModel
could expose a EnvironmentViewModel
. If this is useful depends on the context, which is not apparent from your question.
To trigger the navigation of your master-detail view you use commands where the Button.CommandParameter
is the PageId
. Alternatively, handle the Button.Cick
event and get the PageId
from the sender
<StackPanel>
<Button Content="Scripting"
Click="OnNavigateToPageButtonClicked"
CommandParameter="{x:Static PageId.Scripting}" />
<Button Content="Settings"
Click="OnNavigateToPageButtonClicked"
CommandParameter="{x:Static PageId.Settings}" />
</StackPanel>
// Use a single event handler for all navigation buttons.
// Use the PageId assigned to the Button.CommandParameter property
// to identify the navigation destination.
private void OnNavigateToPageButtonClicked(object sender, RoutedEventArgs e)
{
var button = (Button)sender;
var pageId = (PageId)button.CommandParameter;
// Not sure why the frameKey parameter is needed.
Navigate(frameKey, pageId);
}
Following these steps will make your code more robust and easier to maintain.
To get another idea about how to navigate between screens using a ContentControl
in place of a Frame
, you can check the following small but complete example: C# WPF Navigation Between Pages (Views).