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 sameMainWindowclass, split in two withpartial.
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 |
<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:
<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>Griddivides the space into rows and columns. Here there are three rows:Autois as much as the content needs, and*is all that is left. A control says which row it is on withGrid.Row="1".StackPanellines up the controls one after another, vertically, or horizontally withOrientation="Horizontal".TextBlockis text to read,TextBoxis a field to type in,Buttonis a button, andListBoxis 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
WidthandHeightare 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
GridandStackPanel, there areDockPanel(controls are "docked" at the top, bottom or sides, and the last one takes the middle) andWrapPanel(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, andPriceBoxisnull.- A handler always has two parameters.
senderis the control that raised the event (here, the button), andecarries the details about it. PriceBox.Textis the text in the field.decimal.TryParseturns 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, andTotalText.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:TextChangedon aTextBoxfor every letter,SelectionChangedon aListBoxwhen a row is picked,Loadedon the window when it appears, andClosingbefore 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 voidand usesawait: the window stays responsive while it waits.async voidis 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" />Click="AddButton_Click". The name of the method has to match exactly, or the project does not compile: "'MainWindow' does not contain a definition for '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. WhenTotalgets a new value,OnPropertyChanged("Total")raises the event, and WPF reads the property again.?.Invokemeans "call it if someone is listening": with no listener, the event isnull.ObservableCollection<T>: a list that says by itself when an item goes in or out. AListBoxwithItemsSource="{Binding Items}"watches it, and each row shows the item'sToString().
{ 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
- DataContext = cart: the ListBox reads Items, and the TextBlock reads Total. The arrows are the bindings.
- Add(Espresso beans): Items.Add. The ObservableCollection reports the new row, and the ListBox shows it.
- Total becomes 24.90. The setter stores the value and calls OnPropertyChanged("Total").
- The event reaches the TextBlock: WPF reads Total again, and the text becomes "Total: €24.90".
- Add(Peppermint): Items.Add. The ObservableCollection reports the new row, and the ListBox shows it.
- Total becomes 29.10. The setter stores the value and calls OnPropertyChanged("Total").
- The event reaches the TextBlock: WPF reads Total again, and the text becomes "Total: €29.10".
- 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 fromcart.ItemsList.SelectedItem is CartItem itemchecks that a row is selected and gives it to you as aCartItem.Items.Countin the XAML is bound too: theObservableCollectionalso 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).
Cartis a ViewModel: the model, prepared for the screen. Buttons are bound not withClickbut with commands:Command="{Binding AddCommand}", whereAddCommandis a property of typeICommand. 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 writeOnPropertyChanged(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.
List<CartItem> does not tell anyone that it has changed. The ListBox read the list when it was empty and does not know that products were added later. With ObservableCollection<CartItem>, every Add and Remove raises an event, and the ListBox updates. The total works because Total raises PropertyChanged.
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