Skip to content
wannacode

Windows with WPF

Problem

The till in the shop

The person at the till is a sales clerk, not a programmer. A console with "Enter price:" will not do: you need fields, buttons, a list of the products on the receipt, and the total in a large font. The program has to start on the till computer, which runs Windows, and work without the internet too.

A web page could do it, but it needs a browser and a server. For a program that lives on one computer, there are desktop frameworks. In .NET for Windows, the main one is WPF (Windows Presentation Foundation).

The idea

The window is XAML, the behavior is C#

A WPF program has two sides:

  • How it looks is described in XAML: XML with tags for the controls. <Button Content="Add" /> is a button with a label. The tags are nested: the window contains a layout, and the layout contains controls.
  • What it does is in C#, in the code-behind: MainWindow.xaml.cs. The two files are one and the same MainWindow class, split in two with partial.

A new project from dotnet new wpf -n Shop has four files:

File What it is
App.xaml the application; StartupUri says which window to open
App.xaml.cs the application's code (empty here)
MainWindow.xaml the window: the controls and their layout
MainWindow.xaml.cs the code behind the window: the handlers
App.xaml
<Application x:Class="Shop.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
</Application>

xmlns and xmlns:x at the start tell the compiler where the tags come from: the first is the WPF controls, the second is the special attributes such as x:Class and x:Name.

WPF works only on Windows. You open the project in Visual Studio and press F5, or type dotnet run in its folder. The programs in this lesson were checked by compiling them, but not by running them: the site's check runs on Linux, where a window cannot open. Compiling catches mistakes in the C# and in the XAML, such as a handler that does not exist, but not how the window looks.

More precisely, if you are curious
  • Other desktop frameworks in .NET: WinForms (the older one, also Windows only, where you draw the window in a designer), WinUI 3 (the new one for Windows), and .NET MAUI and Avalonia, which run on Windows, macOS and Linux. Avalonia uses XAML almost like WPF.
  • XAML is not only for WPF: WinUI, MAUI and Avalonia use the same language. So what you learn here applies there too.

See it

Controls and layout

The till: at the top, fields for the product and the price with a button; in the middle, the list; at the bottom, the total and a button to remove an item. MainWindow.xaml:

MainWindow.xaml
<Window x:Class="Shop.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Till" Width="440" Height="360">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Text="Product:" VerticalAlignment="Center" />
<TextBox x:Name="NameBox" Width="150" Margin="6,0" />
<TextBlock Text="Price:" VerticalAlignment="Center" />
<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Add" Click="AddButton_Click" />
</StackPanel>
<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" />
<StackPanel Grid.Row="2" Orientation="Horizontal">
<TextBlock x:Name="TotalText" Text="Total: €0.00" FontWeight="Bold" VerticalAlignment="Center" />
<Button Content="Remove selected" Click="RemoveButton_Click" Margin="16,0,0,0" />
</StackPanel>
</Grid>
</Window>
  • Grid divides the space into rows and columns. Here there are three rows: Auto is as much as the content needs, and * is all that is left. A control says which row it is on with Grid.Row="1".
  • StackPanel lines up the controls one after another, vertically, or horizontally with Orientation="Horizontal".
  • TextBlock is text to read, TextBox is a field to type in, Button is a button, and ListBox is a list you can pick a row from.
  • Margin="6,0" is the empty space around the control: 6 on the left and right, 0 at the top and bottom. With four numbers it is left, top, right, bottom.
  • x:Name="PriceBox" gives the control a name. In the code behind the window, it is a field with that name.
More precisely, if you are curious
  • Width and Height are in units of 1/96 inch, not in pixels. On a screen scaled to 150%, the window is bigger in pixels but looks the same. That is why a WPF window is sharp on any screen.
  • Besides Grid and StackPanel, there are DockPanel (controls are "docked" at the top, bottom or sides, and the last one takes the middle) and WrapPanel (like text: when a row is full, it continues on the next one).
  • Visual Studio shows the window while you write XAML, and you can drag controls into it. But the XAML is still text that you can read and compare in git.

The idea

The button and the handler

Click="AddButton_Click" on the button says: "when I am pressed, call the AddButton_Click method". The press is an event, and the method that handles it is an event handler. The code behind the window:

using System.Windows;
namespace Shop;
public partial class MainWindow : Window
{
private readonly List<decimal> prices = new List<decimal>();
public MainWindow()
{
InitializeComponent();
}
// the handlers are below
}
private void AddButton_Click(object sender, RoutedEventArgs e)
{
if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0)
{
MessageBox.Show("Enter a price, for example 4.50.");
return;
}
ItemsList.Items.Add(NameBox.Text + ": €" + price.ToString("0.00"));
prices.Add(price);
ShowTotal();
NameBox.Clear();
PriceBox.Clear();
}
  • InitializeComponent() creates the controls from the XAML. Without it the window is empty, and PriceBox is null.
  • A handler always has two parameters. sender is the control that raised the event (here, the button), and e carries the details about it.
  • PriceBox.Text is the text in the field. decimal.TryParse turns it into a number and tells you whether that worked, without an exception. The clerk might type "abc", and the program must not crash.
  • ItemsList.Items.Add(...) adds a row to the list, and TotalText.Text = ... changes the total. The window redraws itself.

TryParse reads the number according to the Windows regional settings. On a computer with US settings, the clerk types 4.50; with German or Bulgarian settings, it is 4,50 with a comma.

More precisely, if you are curious
  • Besides Click, controls have dozens of events: TextChanged on a TextBox for every letter, SelectionChanged on a ListBox when a row is picked, Loaded on the window when it appears, and Closing before it closes (that is where you ask "Save changes?").
  • The handler runs on the window's thread. While it runs, the window does not respond. If you need to get data from a server, the handler becomes async void and uses await: the window stays responsive while it waits. async void is acceptable only for event handlers.
  • In C# you can also attach a handler yourself from code: AddButton.Click += AddButton_Click;. The attribute in XAML does the same.

Try it

Connect the button

Which attribute connects pressing the button to the method?

<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Add" ="AddButton_Click" />

The idea

Binding: the data gets to the screen by itself

In the till, every handler has to remember to update the list, then the total, then maybe a counter. Forget one, and the screen lies. With five places like this, a bug is only a matter of time.

The better way: the data lives in a separate class, and the XAML says "show Total here". When Total changes, the screen updates by itself. This is called data binding:

<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" ItemsSource="{Binding Items}" />
<TextBlock Text="{Binding Total, StringFormat='Total: €{0:0.00}'}" FontWeight="Bold" VerticalAlignment="Center" />
<TextBlock Text="{Binding Items.Count, StringFormat=' ({0} items)'}" VerticalAlignment="Center" />

{Binding Total} reads the Total property of the object that is the window's DataContext. StringFormat shows it with two decimal places. The class with the data:

public class CartItem
{
public string Name { get; set; }
public decimal Price { get; set; }
public override string ToString() => Name + ": €" + Price.ToString("0.00");
}
public class Cart : INotifyPropertyChanged
{
private decimal total;
public ObservableCollection<CartItem> Items { get; } = new ObservableCollection<CartItem>();
public decimal Total
{
get { return total; }
private set
{
total = value;
OnPropertyChanged("Total");
}
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string name)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
public void Add(CartItem item)
{
Items.Add(item);
Total = Items.Sum(i => i.Price);
}
public void Remove(CartItem item)
{
Items.Remove(item);
Total = Items.Sum(i => i.Price);
}
}

Two things make the binding live:

  • INotifyPropertyChanged: an interface with one event, PropertyChanged. When Total gets a new value, OnPropertyChanged("Total") raises the event, and WPF reads the property again. ?.Invoke means "call it if someone is listening": with no listener, the event is null.
  • ObservableCollection<T>: a list that says by itself when an item goes in or out. A ListBox with ItemsSource="{Binding Items}" watches it, and each row shows the item's ToString().

{ get; } = new ... is a read-only property with a value from the very start.

How the change gets to the screen, with a shorter way of writing the same Total property:

public decimal Total
{
get => total;
private set { total = value; OnPropertyChanged("Total"); }
}
public void Add(CartItem item)
{
Items.Add(item);
Total = Items.Sum(i => i.Price);
}
Step by step, in words
  1. DataContext = cart: the ListBox reads Items, and the TextBlock reads Total. The arrows are the bindings.
  2. Add(Espresso beans): Items.Add. The ObservableCollection reports the new row, and the ListBox shows it.
  3. Total becomes 24.90. The setter stores the value and calls OnPropertyChanged("Total").
  4. The event reaches the TextBlock: WPF reads Total again, and the text becomes "Total: €24.90".
  5. Add(Peppermint): Items.Add. The ObservableCollection reports the new row, and the ListBox shows it.
  6. Total becomes 29.10. The setter stores the value and calls OnPropertyChanged("Total").
  7. The event reaches the TextBlock: WPF reads Total again, and the text becomes "Total: €29.10".
  8. Add changes only the data. No line in the code touches the ListBox or the TextBlock.

See it

The till with binding

The handlers now only call Cart. They do not think about the screen:

public partial class MainWindow : Window
{
private readonly Cart cart = new Cart();
public MainWindow()
{
InitializeComponent();
DataContext = cart;
}
private void AddButton_Click(object sender, RoutedEventArgs e)
{
if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0)
{
MessageBox.Show("Enter a price, for example 4.50.");
return;
}
cart.Add(new CartItem { Name = NameBox.Text, Price = price });
NameBox.Clear();
PriceBox.Clear();
}
private void RemoveButton_Click(object sender, RoutedEventArgs e)
{
if (ItemsList.SelectedItem is CartItem item) cart.Remove(item);
}
}
  • DataContext = cart; tells all the {Binding ...}s in the window to read from cart.
  • ItemsList.SelectedItem is CartItem item checks that a row is selected and gives it to you as a CartItem.
  • Items.Count in the XAML is bound too: the ObservableCollection also reports when the count changes.

Binding to a TextBox works both ways: <TextBox Text="{Binding Euros, UpdateSourceTrigger=PropertyChanged}" /> writes to the property on every letter, and a change to the property from code changes the text in the field. That is how the converter in the exercise works.

More precisely, if you are curious
  • The layout where the window has almost no code, and the data and actions are in a separate class, is called MVVM (Model-View-ViewModel). Cart is a ViewModel: the model, prepared for the screen. Buttons are bound not with Click but with commands: Command="{Binding AddCommand}", where AddCommand is a property of type ICommand. Then the code behind the window is empty, and the ViewModel can be tested without a window.
  • The name in OnPropertyChanged("Total") is text, and a typo is not caught. It is safer to write OnPropertyChanged(nameof(Total)), or to put the [CallerMemberName] attribute on the parameter, which passes the name by itself.
  • A mistake in {Binding ...}, such as {Binding Totl}, does not stop the build. The program starts, the field is empty, and the warning is in the Output window of Visual Studio.

Try it

The list does not grow

The total updates, but the new products do not appear in the list. Which line is the bug on?

Pick the line with the bug.

Exercise

Three small programs

Make them with dotnet new wpf and run them on Windows. The solutions on the site were checked by compiling them.

1. A bill with a tip. Fields for the bill, the tip percentage (10 by default) and the number of people, a "Calculate" button and a result. For a bill of €46.80, 10% and 3 people, it says "Tip: €4.68, total: €51.48, per person: €17.16". For an invalid number, a negative percentage or 0 people, it says "Check the numbers." instead of crashing. Do it with a Click handler.

2. Tasks for the day. A field, an "Add" button, a list and a "Remove selected" button. An empty task, or a task that is already there, is not added. The tasks are in an ObservableCollection<string> that is the window's DataContext, and the ListBox has ItemsSource="{Binding}": with no path, the binding takes the DataContext itself. Lay out the window with a DockPanel: the field at the top, the remove button at the bottom, the list in the middle.

3. A currency converter. One field for euros and two lines that change as you type: dollars and pounds, at example rates of €1 = $1.08 and €1 = £0.85. No button and no handler: the field is bound both ways to Euros on a class with INotifyPropertyChanged, and Dollars and Pounds are properties calculated from Euros. When Euros changes, the class raises PropertyChanged for all three.

Test

Check yourself

1. Where do you describe how a WPF window looks?
2. What does Click="AddButton_Click" connect in XAML?
3. The Total property changes, but a TextBlock with {Binding Total} shows the old value. What is missing?
4. Why do you use an ObservableCollection, not a List, for the items in a ListBox?
5. On which systems does a WPF program run?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·