Understanding Windows Presentation Foundation (WPF) Data Binding with the DataContext Class In .NET Core

September 24, 2020
Written by
Jeff Rosenthal
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
AJ Saulsberry
Contributor
Opinions expressed by Twilio contributors are their own

wpf-datacontext.png

The Windows Presentation Foundation (WPF) framework allows you to develop desktop applications with amazing graphic capabilities, but it does not stop there. An application needs to display data of some sort and connecting UIElements to underlying data structures needs to be flexible. That is where DataContext comes into play.

DataContext works hand-in-hand with data binding to provide hierarchical data presentation. It is what connects the front end to the code-behind and enables changes made to data in the user interface to update the data source, and conversely, while maintaining the order of your data structure. You’ll see  examples of this in the case study project for this post.

This tutorial will guide you through building a WPF application where you can explore DataContext at work. You will create a data dashboard that visualizes data from different simulated sources.

Prerequisites

You’ll need the following tools and resources to build and run this project:

Windows 10 – It puts the Windows in Windows Presentation Foundation.

.NET Core SDK 3.1 – The SDK includes the APIs, runtime, and CLI.

Visual Studio 2019 with the following workloads and individual components:

  • .NET desktop development workload (Includes C#)
  • GitHub Extension for Visual Studio (If you want to clone the companion repository.)

You should have a general knowledge of Visual Studio and the C# language syntax. You will be adding and editing files, and debugging code.

There is a companion repository for this post available on GitHub. It contains the complete source code for the tutorial project. The code uses the dependency injection features of .NET Core, but you won’t need extensive knowledge of it to understand the WPF features that are the focus of this post.

Creating the project

Begin this tutorial by creating a WPF App (.NET Core) project for C# named DataContextExamples. You can put the solution and project folders wherever it’s most convenient for you.

Adding Simulation Data

In this project you will simulate a couple of tanks in a fictitious industrial setting along with environmental data such as temperature and humidity. This data will be used to show the relationships between view-models and object models

WPF has the ability to show data but needs to be notified when the data changes. The INotifyPropertyChanged interface does just that. To implement the interface, declare a PropertyChangedEventHandler and create a way to invoke it.  By placing code that will be used in several places into a base class, the code will be more maintainable.

In the project, add a folder under the DataContextExamples project root and call it Models. You do this by right-clicking on the DataContextExamples project in the Solution Explorer and selecting Add > New Folder.

Adding a folder to the solution

In the Models folder, add a class and name it DataBaseClass.cs. Replace the entire contents with the following C# code:

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace DataContextExamples.Models
{
    public class DataBaseClass : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public void NotifyPropertyChanged([CallerMemberName] string propName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
        }

    }
}

This will create a class that will be used as a base class for the other data classes required. It implements the needed INotifyPropertyChanged interface and also implements a method NotifyPropertyChanged. The parameter defined in the method is a string called propName which, in itself, is not interesting, but it is decorated with CallerMemberNameAttribute. This obtains the property name of the caller and allows code to be written like:

public string Name
{
get => _name;
             set
             {
          _name = value;
          NotifyPropertyChanged();
             }
}        

Without the CallerMemberName attribute you’d have to hard-code the property name, which is a code smell:

public string Name
{
get => _name;
     set
     {
          _name = value;
          NotifyPropertyChanged("Name");
     }
}

The elimination of the property name in the method will be automatically added, reducing errors.

Next you will add several classes that will be used for data. In the Models folder, add a class and name it TankData.cs. Replace the template-generated code in the new file with the following:

using System;
using System.Timers;

namespace DataContextExamples.Models
{
    public class TankData : DataBaseClass
    {
        private string _name;
        private int _dataValue;
        private int _minimum;
        private int _maximum;

        public string Name
        {
            get => _name;
            set
            {
                _name = value;
                NotifyPropertyChanged("Name");
            }
        }

        public int Minimum
        {
            get => _minimum;
            set
            {
                _minimum = value;
                NotifyPropertyChanged();
            }
        }

        public int Maximum
        {
            get => _maximum;
            set
            {
                _maximum = value;
                NotifyPropertyChanged();
            }

        }

        public int DataValue
        {
            get => _dataValue;
            set
            {
                if (value < Minimum || value > Maximum)
                    return;
                _dataValue = value;
                NotifyPropertyChanged();
            }
        }

        
        public void Initialize(string name, int min = -100, int max= 100)
        {
            Name = name;
            Minimum = min;
            Maximum = max;

            // Create a timer with a one second interval.
            var aTimer = new Timer(1000);
            // Hook up the Elapsed event for the timer. 
            aTimer.Elapsed += OnTimedEvent;
            aTimer.Enabled = true;

        }

        private void OnTimedEvent(Object source, ElapsedEventArgs e)
        {
            Random rnd = new Random();
            DataValue = rnd.Next(DataValue-10, DataValue+10);
        }
    }
}

The TankData class has four properties that define the characteristics of a storage tank. Notice that NotifyPropertyChanged is called by each property whenever the property value is changed. The base class DataBaseClass provides access to that method through class inheritance. An Initialization method takes three parameters; a name, a minimum value, and a maximum value.  The Initialize method sets the configuration parameters of the class. The minimum value and the maximum value have default values defined that will be used unless specified in the calling method. After storing the min and max values in the Minimum and Maximum properties, a timer is created that calls the OnTimedEvent method every second. This will change the DataValue every second as a way to simulate changing data.

Since dependency injection is going to be implemented, a parameterless constructor has to be provided. In the absence of one explicitly defined in the class, the compiler creates one automatically. 

In the Models folder add another class and name it EnvironmentData.cs. Replace the boilerplate code in the new file with the following:

using System;
using System.Timers;

namespace DataContextExamples.Models
{
    public class EnvironmentData : DataBaseClass
    {
        private int _temperature;
        private int _humidity;

        public int Temperature
        {
            get => _temperature;
            set
            {
                _temperature = value;
                NotifyPropertyChanged();
            }
        }

        public int Humidity
        {
            get => _humidity;
            set
            {
                _humidity = value;
                NotifyPropertyChanged();
            }
        }


        public EnvironmentData()
        {
            Temperature = 70;
            Humidity = 50;
            // Create a timer with a two second interval.
            var aTimer = new Timer(2000);
            // Hook up the Elapsed event for the timer. 
            aTimer.Elapsed += OnTimedEvent;
            aTimer.Enabled = true;
        }
        private void OnTimedEvent(Object source, ElapsedEventArgs e)
        {
            Random rnd = new Random();
            Temperature = rnd.Next(Temperature - 10, Temperature + 10);
            Humidity = rnd.Next(Humidity - 5, Humidity + 5);
        }
    }
}

This class is similar to TankData and will simulate environmental data. The frequency of the timer in this case is 2 seconds.

Adding ViewModels

A ViewModel is a design pattern that provides data to a view. It is a class that either directly or indirectly holds or has access to data. The view-model does not have access or knowledge of the view (or specifically the presentation in WPF).

In the project, add another folder and call it ViewModels.

In the ViewModels folder, add a class file and name it TankViewModel.cs. Replace the code in the generated file with the following:

using DataContextExamples.Models;

namespace DataContextExamples.ViewModels
{
    public class TankViewModel
    {
        public TankData Tank1 { get; set; }

        public TankData Tank2 { get; set; }
        public TankViewModel(TankData tankData1, TankData tankData2)
        {
            Tank1 = tankData1;
            Tank1.Initialize("Tank1");
            Tank2 = tankData2;
            Tank2.Initialize("Tank2");
        }
    }
}

This is the view-model for the tanks. It holds references to two tanks: Tank1 and Tank2. Notice how the constructor makes reference to two TankData objects which will be constructed via dependency injection. You will hook that up next.

For the purposes of illustration, another view-model will be added to hold the environmental data.  In the ViewModels folder add another class file and name it EnvironmentViewModel.cs. Replace the default code in the new file with the following:

using DataContextExamples.Models;

namespace DataContextExamples.ViewModels
{
    public class EnvironmentViewModel
    {
        public EnvironmentData EnviroData { get; set; }

        public EnvironmentViewModel(EnvironmentData environmentData)
        {
            EnviroData = environmentData;
        }
    }
}

Adding Dependencies

You need to add a NuGet package to this project so it can implement dependency injection. Add the following package:

Dependency Injection (DI) provides a cleaner way to manage dependencies. To implement it, open App.xaml.cs and replace the content with the following:

using System.Windows;
using DataContextExamples.Models;
using DataContextExamples.ViewModels;
using Microsoft.Extensions.DependencyInjection;


namespace DataContextExamples
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        private readonly ServiceProvider _serviceProvider;

        public App()
        {
            ServiceCollection services = new ServiceCollection();
            ConfigureServices(services);
            _serviceProvider = services.BuildServiceProvider();
        }
        private void ConfigureServices(ServiceCollection services)
        {
            services.AddTransient<EnvironmentData>(); 
            services.AddTransient<TankData>();
            services.AddSingleton<TankViewModel>();
            services.AddSingleton<EnvironmentViewModel>();
            services.AddSingleton<MainWindow>();
        }
        private void OnStartup(object sender, StartupEventArgs e)
        {
            var mainWindow = _serviceProvider.GetService<MainWindow>();
            mainWindow.Show();
        }
    }
}

This block of code initializes dependency injection and configures the objects that will be injected. You will be injecting singleton versions of the TankViewMode, EnvironmentViewModel and MainWindow objects. A singleton object is one where there is only one instance of it for the application.

The EnvironmentData and TankData objects will be transient objects. Transient objects are constructed as needed in code.

The OnStartup method is a handler for the Startup event. This will be the entrance to the running code where the MainWindow will be shown. In order to invoke the OnStartup method, a change must be made to the App.xaml file.

Find the following attribute of the Application element:

StartupUri="MainWindow.xaml" 

Change it to:

Startup="OnStartup"

This tells the application to invoke the OnStartup handler instead of executing the code in the MainWindow.xaml file. It will be the OnStartup method that will show the MainWindow. Note that you’re changing the reference from a URI to a method, so StartupUri changes to Startup.

One piece remains to be completed, the MainMindow user interface which was created when the project was created. MainWindow.xaml is the view in this application and will pull all the pieces together. MainWindow.xaml.cs is the code behind file and will contain the C# code, while MainWindow.xaml will hold the XAML code.

Starting with MainWindow.xaml.cs, replace the content of the file with the following code:

using System.Windows;
using DataContextExamples.ViewModels;

namespace DataContextExamples
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private readonly TankViewModel _tankViewModel;
        private readonly EnvironmentViewModel _enviroViewModel;

        public MainWindow(TankViewModel tankvm, EnvironmentViewModel envirovm)
        {
            _tankViewModel = tankvm;
            _enviroViewModel = envirovm;

            InitializeComponent();
            Tank1StackPanel.DataContext = _tankViewModel.Tank1;
            Tank2StackPanel.DataContext = _tankViewModel.Tank2;
            EnviroStackPanel.DataContext = _enviroViewModel;
        }
    }
}

Note that three of the object references will lint. Don’t panic; you’ll add those references when you create the user interface markup next.

After the defining fields for the view-models, the constructor injects the view-models into the constructor where they are stored for later use. The constructor then sets the DataContext for the panels that will be defined in the XAML.

Open MainWindow.xaml and replace the contents of the file with the following code:

<Window x:Class="DataContextExamples.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:DataContextExamples"
        mc:Ignorable="d"
        Title="MainWindow" Height="400" Width="850">
    <StackPanel Orientation="Horizontal" Background="AntiqueWhite" TextElement.FontSize="20">
        <StackPanel Name="Tank1StackPanel" Orientation="Vertical" Margin="20" Background="DarkCyan">
            <Label FontSize="40">Tank Data</Label>
            <StackPanel Orientation="Horizontal">
                <StackPanel>
                    <TextBlock Margin="20" Text="{Binding Name}"></TextBlock>
                <TextBlock Margin="20" Text="{Binding DataValue}"></TextBlock>
            </StackPanel>
            <StackPanel>
                <ProgressBar Margin="20,10" Height="100" Width="30" Orientation="Vertical"
                         Minimum="{Binding Minimum }"
                         Maximum="{Binding Maximum}"
                         Value="{Binding DataValue}"
                         Foreground="Blue">
                </ProgressBar>
            </StackPanel>
            </StackPanel>
        </StackPanel>
        <StackPanel Name="Tank2StackPanel" Orientation="Vertical" Margin="20" Background="CornflowerBlue">
            <Label FontSize="40">Tank Data</Label>
            <StackPanel Orientation="Horizontal">
                <StackPanel>
                    <TextBlock Margin="20" Text="{Binding Name}"></TextBlock>
                    <TextBlock Margin="20" Text="{Binding DataValue}"></TextBlock>
                </StackPanel>
                <StackPanel>
                    <ProgressBar Margin="20,10" Height="100" Width="30" Orientation="Vertical"
                                 Minimum="{Binding Minimum }"
                                 Maximum="{Binding Maximum}"
                                 Value="{Binding DataValue}"
                                 Foreground="Aqua">
                    </ProgressBar>
                </StackPanel>
            </StackPanel>
        </StackPanel>
        <StackPanel Name="EnviroStackPanel" Margin="20" Background="DarkKhaki">
            <Label FontSize="40">Environment Data</Label>
            <StackPanel Name="TempPanel" DataContext="{Binding EnviroData}" Margin="20" >
                <Label>Temperature</Label>
                <TextBlock Text="{Binding Temperature}" FontSize="40"></TextBlock>
                <Label>Humidity</Label>
                <TextBlock Text="{Binding Humidity}" FontSize="40"></TextBlock>
            </StackPanel>
        </StackPanel>
    </StackPanel>
</Window>

The window is organized into three panels:

  1. Tank1StackPanel
  2. Tank2StackPanel
  3. EnviroStackPanel

As you can see in MainWindow.xaml.cs, the DataContext for the tank panels is found in TankViewModel class Tank1 and Tank2 properties.

Tank1StackPanel.DataContext = _tankViewModel.Tank1;
Tank2StackPanel.DataContext = _tankViewModel.Tank2;

The DataContext is associated with the stackpanel and therefore available to all child elements. In this case the Name and DataValue are implemented within the TextBlock and ProgressBar elements.

The third panel is implemented slightly differently. In MainWindow.xaml.cs the DataContext for the EnviroStackPanel is the whole EnvironmentViewModel. In this case the TempPanel in the XAML code is bound to the EnviroData within the view-model. The TextBlocks can then access the Temperature and Humidity properties. Without the binding for the TempPanel stackpanel, those properties would not be visible directly.

Note the following code:

<StackPanel Orientation="Horizontal" Background="AntiqueWhite" TextElement.FontSize="20">

The StackPanel does not have a fontsize property, but by specifying that TextElements contained within it are to have a certain font size the property is applied to all child elements.

Testing the completed application

Build and run the application. You should see a window presenting the data that is being simulated. All of the values should be updating every couple seconds.

Screenshot of the application running

Potential enhancements

Much of the code is focused on simulating data. Try adding additional properties and displaying them. Embellishments to the UI can always be made to make the display prettier.

Summary

A DataContext class defines a data source, while the bindings associate what specific data is shown and how. The nesting hierarchical nature of WPF both in terms of visual components and data is very powerful and enhances the flexibility of the framework. Some of this can be seen in  the routing of events, the routing of commands and the propagation of properties as seen above with the TextElements.

Additional resources

The following resources will enable you to dive deeper and fly higher with the technologies discussed in this post:

Getting Started with Windows Presentation Foundation (WPF) in .NET Core – The first post in this series introduces the basic concepts of WPF and walks you through creating a working example.

Understanding WPF Routed Events In .NET Core – The sophisticated event handling system in WPF is one of its many strengths. This post introduces you to event handling concepts like bubbling and tunneling.

How to: Implement Property Change Notification – This article in the docs.microsoft.com Desktop Guide describes how to set up OneWay and TwoWay binding and provides a code sample.

Events and routed events overview – Although this article is written for UWP rather than WPF, the fundamental concepts of event handling and routing are similar. Read for conceptual understanding and refer to WPF-specific pages for functional reference material. (Note that as of this writing the .NET 5.0 documentation for WPF is a work in progress.)

Using Twilio Lookup in .NET Core WPF Applications – Learn how to use the Twilio Lookup API to verify phone numbers and find caller information for numbers entered in a WPF application.

TwilioQuest – If you’d like to learn more about programming C# and other languages, try this action-adventure game inspired by the 16-bit golden era of computer gaming.

Jeffrey Rosenthal is a C/C++/C# developer and enjoys the architectural aspects of coding and software development. Jeff is a MCSD and has operated his own company, The Coding Pit, since 2008. When not coding, Jeff enjoys his home projects, rescuing dogs, and flying his drone. Jeff is available for consulting on various technologies and can be reached via email, Twitter, or LinkedIn.

Updated 2020-10-26