Search code examples
c#wpfdata-binding

Setting the DataContext does not affect the UI


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

enter image description here

Screenshot 2: Navigated to "Settings" and "Environment"

enter image description here

Screenshot 3: Navigated from "Environment" to "Execution"

enter image description here

Screenshot 4: Navigated from "Execution" to "Environment" (You can see, the DataContext is applied correctly)

enter image description here

Screenshot 5: Navigated from "Settings" to "Scripting"

enter image description here

Screenshot 6: Navigated from "Scripting" back to "Settings" and "Environment" (You can see, the DataContext is NOT getting applied correctly.

enter image description here

Screenshot 7: Shows the runtime page.DataContext where we can see that the DataContext is applied correctly.

enter image description here

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.


Solution

  • 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

    1. 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,
      }
      
    2. 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();
          }
        }
      }
      
    3. 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.

    4. 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);
      }
      
    5. 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;
        }          
      }
      
    6. 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.

    7. 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).