After W5.1.1, Kladde.Desktop shows an empty template. In this lesson the program gets its framework: a navigation bar on the left, two pages (appointments and contacts), a
list, a dialog for deleting, and an appearance that stays readable in the light and dark themes and in Windows' high contrast mode. The data is fixed sample data in the code.
In this lesson the logic lives in the code-behind. That is on purpose, because MVVM comes in W5.2.1: there you pull the logic out of the pages into the library
Kladde.Desktop.Logic and test it without a window. Wherever this lesson writes code-behind that moves out later, it says so.
State of the checks. The XAML and C# lines of this lesson come from the reference and the samples on Microsoft Learn, or were read against them and tailored to the Kladde. They were not run, compiled or started against a real WinUI project. The descriptions of what the window shows rest on Learn and not on a measurement. Where your observation differs, yours applies; where Learn says nothing about something, the text says "Learn says nothing about this, check …". The approach for the exercises: build after every section before you move on.
The course W2 .NET MAUI also knows XAML. If you know it, you will recognize a lot; but the names differ in many places (for example Page and NavigationView instead of ContentPage
and Shell, ListView without cells, ContentDialog instead of DisplayAlert). This lesson does not assume W2 and compares only where it helps.
XAML: the basics
XAML is a declarative language that creates objects and sets their properties. You describe visible user interface elements with it as markup; every XAML file has a
code-behind file that reacts to events and changes the objects. That is the split you know from W5.1.1: the XAML file declares a class with x:Class, and the
code-behind file is the second part of the same partial class.
A page from the "Blank Page" template looks like this at its core (the template inserts the namespace lines in the header; you do not have to write them by hand):
<Page
x:Class="Kladde.Desktop.Pages.ContactsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:Kladde.Desktop.Models">
<Grid>
<TextBlock Text="Contacts" />
</Grid>
</Page>
On the namespaces: the default namespace (xmlns="…") contains WinUI's controls, xmlns:x the keywords of XAML itself (x:Class, x:Name, x:Key, x:Bind, x:DataType).
You bring in your own types with xmlns:prefix="using:Namespace"; Learn writes it that way, for example, for xmlns:muxc="using:Microsoft.UI.Xaml.Controls". In this lesson the prefix is called
models and points to Kladde.Desktop.Models. The template creates pages in the namespace that your folder yields; your page may therefore carry Kladde.Desktop instead of Kladde.Desktop.Pages. Keep
x:Class and the code-behind file the same, otherwise the compiler does not find the class.
The notations at a glance:
| Notation | Example | When |
|---|---|---|
| Attribute | <TextBlock Text="Contacts" FontSize="24" /> |
simple values |
| Property element | <Grid.RowDefinitions> … </Grid.RowDefinitions> |
values that are objects or lists themselves |
| Attached property | Grid.Row="1" |
a property that a parent element defines for its child |
| Markup extension | {x:Bind Name}, {StaticResource Key}, {ThemeResource Key} |
a value is looked up or bound |
| Name | x:Name="NavView" |
address the element in the code-behind |
| Event | Click="Delete_Click" |
name of a method in the code-behind |
Important in practice: x:Name makes an element available in the code-behind as a field. An event handler needs a method in the code-behind file with the signature of the event,
for example private void Delete_Click(object sender, RoutedEventArgs e). Both are the cause of many error messages when a name in XAML and in code does not match. After
W5.1.1 you know that new handlers in the code-behind require a restart instead of Hot Reload.
A caveat up front: x:Bind and {Binding}
In this lesson {x:Bind} appears in one place, in data templates for lists. The whole binding story (modes, INotifyPropertyChanged, commands) is taught in W5.2.1. You need two things
now: according to Learn, {x:Bind} is the compiled binding, faster and with less code than {Binding}, and it is checked at build time. Its default mode is OneTime:
the value is read once. For fixed sample data that is enough; where values are supposed to change, set the mode explicitly. And: in a DataTemplate the path does not apply to the page but to the
data object, and for that the template has to name the type with x:DataType:
<DataTemplate x:DataType="models:ContactResponse">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
Layouts
A layout is made of panels: containers that arrange their children and determine their size. Learn lists RelativePanel, StackPanel, Grid, VariableSizedWrapGrid
and Canvas as built-in panels. What matters in the choice is how the panel positions and sizes the children, how overlapping children are layered, and how many nested panels you need.
Learn also says that the choice between Grid, StackPanel and RelativePanel is normally not a question of speed: take the panel that comes closest to your design.
Two terms belong here. Sizes in XAML are effective pixels, not physical pixels (more on that in the section on scaling). And a layout should flow: most sizes are Auto
(as large as the content) or proportional (*), and fixed dimensions are the exception, so that the page is not cut off at other window sizes.
Grid
Grid arranges elements in rows and columns, which you define through RowDefinitions and ColumnDefinitions. You place an element in a cell with the attached properties Grid.Row and
Grid.Column; Grid.RowSpan and Grid.ColumnSpan let it span several cells. The size of a row or column has three forms:
| Value | Meaning |
|---|---|
Auto |
as large as the content needs |
* or n* |
proportional: after the Auto rows are calculated, the rest is divided by weight; 5* next to * is five times as wide |
a number, such as 44 |
fixed size in effective pixels |
The default width of a column is *; a column without Width therefore needs no specification. Learn shows a grid with four columns (Auto, *, 44, 2*):
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
<ColumnDefinition Width="44"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Column 1 sizes to its content." FontSize="24"/>
</Grid>
The second column is half as wide as the fourth. You read the size of an element at run time with ActualWidth and ActualHeight, not with Width and Height.
StackPanel
StackPanel stacks its children in one direction (Orientation="Vertical" or Horizontal). The gap between the children is called Spacing; Learn shows it in samples as
<StackPanel Spacing="8">. A StackPanel gives its children unlimited space in the stacking direction. That is handy for short rows of a label and an input, but unsuitable as a
container for a long, scrollable list: more on that right away under ListView.
RelativePanel
RelativePanel arranges elements by their position relative to each other and to the panel. Without a specification, an element sits at the top left. The attached properties come in three groups:
relative to the panel (AlignTopWithPanel, AlignBottomWithPanel and more), relative to a sibling element (AlignTopWith and more), and the position relative to a sibling element (Above, Below,
RightOf, LeftOf). Learn combines RelativePanel with VisualStateManager and AdaptiveTrigger to rearrange the user interface for different window sizes. A form with two columns looks like this in Learn's example:
<RelativePanel>
<StackPanel x:Name="Customer" Margin="20">
<!--Content-->
</StackPanel>
<StackPanel x:Name="Associate" Margin="20" RelativePanel.RightOf="Customer">
<!--Content-->
</StackPanel>
<StackPanel x:Name="Save" Orientation="Horizontal" RelativePanel.Below="Customer">
<!--Save and Cancel buttons-->
</StackPanel>
</RelativePanel>
The reference (RightOf="Customer") is the x:Name of the sibling element. A page reacts to window width with an AdaptiveTrigger (property MinWindowWidth) in the
states of the VisualStateManager; Learn's NavigationView example below shows the pattern for a NavigationView with a bar that sits at the top when the window is wide and on the left when it is narrow. The course
uses it only as a pointer; the exercises get by without it.
Lists: ListView and ItemsRepeater
ListView
ListView shows a collection stacked. It brings presentation and operation with it: selection, keyboard, scrolling. You specify the data with ItemsSource and the presentation of an item with a
DataTemplate in ItemTemplate. Without a template, ListView shows the string representation of the object.
<ListView ItemsSource="{x:Bind Customers}">
<ListView.ItemTemplate>
<DataTemplate x:DataType="local:Customer">
<TextBlock Text="{x:Bind Name}"/>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
That is Learn's example ("Display customers in a list") with an ObservableCollection<Customer> in the page's code-behind. The property has to be public on the page so that {x:Bind} finds
it. How changes to the collection show up in the list is taught in W5.2.1; the ObservableCollection<T> reports changes, a List<T> does not.
You set how a click works with two properties:
| Effect | Setting | Event | Read the result |
|---|---|---|---|
| no interaction | SelectionMode="None", IsItemClickEnabled="False" |
– | – |
| single selection (default) | SelectionMode="Single" |
SelectionChanged |
SelectedItem, SelectedIndex |
| multiple selection | SelectionMode="Multiple" or "Extended" |
SelectionChanged |
SelectedItems |
| click | SelectionMode="None", IsItemClickEnabled="True" |
ItemClick |
ItemClickEventArgs.ClickedItem |
The default is single selection, and IsItemClickEnabled is False by default. Learn does not explicitly name the property for the click on the argument (ClickedItem) on the pages read;
look it up in the reference for ItemClickEventArgs if you use the click mode. If ItemClick is used together with a selection, ItemClick comes first; if the handler navigates to another
page, SelectionChanged may no longer be raised.
An item aligns its content to the left by default (HorizontalContentAlignment of the ListViewItem is Left). If elements lying side by side are supposed to fill the full width, set
HorizontalContentAlignment to Stretch, through a style for the container, which Learn shows like this:
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListView.ItemContainerStyle>
A trap with large lists. ListView creates items only for the visible area (UI virtualization). That works only if the list is bounded in the scrolling direction. If it sits in a
container with unlimited space, such as a StackPanel or a ScrollViewer, it cannot tell what is visible and creates all items. Learn recommends a Grid row with a fixed
height or with * as the container. In this lesson every list therefore sits in a * row. For custom panels in a list, Learn adds: StackPanel, WrapGrid and VariableSizedWrapGrid do not virtualize;
ItemsStackPanel and ItemsWrapGrid do.
ItemsRepeater
ItemsRepeater is a building block, not a finished control. It has no default presentation and no rules for focus, selection and operation. It is a data-driven panel: you specify
data (ItemsSource), a template (ItemTemplate) and a layout, and it creates the elements from them. Learn recommends it when the user interface cannot be built with the existing list controls. The layout is determined by a Layout object, which makes virtualization possible; Learn names StackLayout (one row or column, with Spacing; the default is vertical)
and UniformGridLayout (wrapping, all elements the same size).
<ItemsRepeater ItemsSource="{x:Bind Tags}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="8"/>
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Padding="8,2" CornerRadius="4" Background="{ThemeResource KladdeCardBrush}">
<TextBlock Text="{x:Bind}" />
</Border>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
This pattern is modeled on Learn's example for StackLayout; Tags here is a public property of the page with a list of strings. KladdeCardBrush is a brush that you define yourself
in the section on themes further down. {x:Bind} without a path stands for the data object itself, here the string.
Which control when? ListView for lists with selection and keyboard operation, ItemsRepeater for displays that no list control provides. Learn also names ItemsView, which builds on ItemsRepeater; the course sticks with ListView.
Navigation: Frame, Page, NavigationView
A WinUI application navigates by having a Frame load pages (Page). The Frame is a container with a navigation history, and a Page is the content it navigates to.
Frame.Navigate(typeof(ContactsPage)) loads the page; CanGoBack and GoBack() belong to the history. A page is notified when someone navigates to it
by overriding OnNavigatedTo (Learn shows it in the method with NavigationEventArgs). You pass a parameter as the second argument; Learn's example navigates with Frame.Navigate(typeof(NotePage), args.InvokedItem); how the target page reads it is shown by the tutorials under "Further reading".
The NavigationView is the navigation bar. One sentence from Learn matters here: it does not navigate by itself. When someone taps an item, it shows it as selected and raises
ItemInvoked; if a new item was selected in the process, SelectionChanged follows. You write the navigation itself in the handler: with a Frame as the content of the NavigationView.
Learn shows a complete example; it stores the full type name of the page in the Tag of each NavigationViewItem, resolves it to a Type in the handler and navigates. That has an advantage that Learn
names: you can write a test that checks that the values of the tags are valid types. For the Kladde the frame looks like this. It lives in the file that your template shows at startup
(MainWindow.xaml, or MainPage.xaml for templates with a page); from here on the lesson calls it the shell:
<NavigationView x:Name="NavView"
IsSettingsVisible="False"
Loaded="NavView_Loaded"
ItemInvoked="NavView_ItemInvoked">
<NavigationView.MenuItems>
<NavigationViewItem Content="Appointments" Tag="Kladde.Desktop.Pages.AppointmentsPage" />
<NavigationViewItem Content="Contacts" Tag="Kladde.Desktop.Pages.ContactsPage" />
</NavigationView.MenuItems>
<Frame x:Name="ContentFrame"
IsTabStop="True"
NavigationFailed="ContentFrame_NavigationFailed" />
</NavigationView>
The shell's code follows Learn's pattern:
using Kladde.Desktop.Pages;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
// In MainWindow.xaml.cs (or MainPage.xaml.cs): this code is code-behind
// and partly moves into a view model in W5.2.1.
private void NavView_Loaded(object sender, RoutedEventArgs e)
{
// The NavigationView does not load a page on its own.
ContentFrame.Navigated += ContentFrame_Navigated;
NavView.SelectedItem = NavView.MenuItems[0];
ContentFrame.Navigate(typeof(AppointmentsPage));
}
private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args)
{
if (args.InvokedItemContainer?.Tag is string typeName
&& Type.GetType(typeName) is Type pageType
&& ContentFrame.CurrentSourcePageType != pageType)
{
ContentFrame.Navigate(pageType, null, args.RecommendedNavigationTransitionInfo);
}
}
private void ContentFrame_Navigated(object sender, NavigationEventArgs e)
{
// The bar's selection follows the page, even when a page navigates in code.
NavView.SelectedItem = NavView.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => item.Tag is string tag && tag == e.SourcePageType.FullName);
}
private void ContentFrame_NavigationFailed(object sender, NavigationFailedEventArgs e)
{
throw new InvalidOperationException("Failed to load Page " + e.SourcePageType.FullName);
}
The line Type.GetType(typeName) takes the name with its namespace; if a page is in a different namespace than in the Tag, null comes back and nothing happens.
Learn says nothing about Type.GetType and trimming or ahead-of-time compilation; if that is an issue for your package, check it in W5.3.2. An alternative without type names as strings is a
switch statement on the Tag, like Learn's shorter example with switch (args.InvokedItemContainer.Name) and ContentFrame.NavigateToType(typeof(Section1Page), null, navOptions).
Other properties that Learn names for the NavigationView: PaneDisplayMode (different styles of the bar: left, top and more), IsBackButtonVisible with the event BackRequested
(for a back button that the application operates itself with ContentFrame.CanGoBack and GoBack()), AutoSuggestBox for search within the application, and an item for the
settings, which IsSettingsVisible="False" hides (the Kladde does not need it yet). Learn shows icons for the items (Icon="Home" or a FontIcon with Glyph) in its example; the Kladde
starts without them so that glyphs you have not checked do not get in the way. Which icons exist is shown by the WinUI 3 Gallery.
How the interplay of shell, OnLaunched and the template looks depends on your template (W5.1.1): the window that OnLaunched creates has to show the shell in the end. If the template shows
MainWindow with content of its own, replace that content with the NavigationView. If OnLaunched shows a Frame with MainPage, put the NavigationView in MainPage. Change
OnLaunched only if necessary; Learn warns against replacing the surrounding Page or the Frame when the template uses them.
A dialog: ContentDialog and XamlRoot
ContentDialog shows a modal prompt over the window. Learn describes it with a title, content and up to three buttons: PrimaryButtonText, SecondaryButtonText and CloseButtonText.
ShowAsync() returns as soon as someone presses a button and delivers a ContentDialogResult: Learn's example checks result == ContentDialogResult.Primary and otherwise does nothing. The
button for closing (Esc, the Back key, the close button) delivers a result other than Primary.
Two rules from Learn:
- In WinUI 3 you have to set
XamlRootbefore you callShowAsync(); otherwise the dialog throws an exception at run time. From aPageyou takethis.XamlRoot.Windowhas no propertyXamlRoot: if you show a dialog from aWindow, take theXamlRootof the root element in the window (Learn showsrootPanel.XamlRootfor aGrid x:Name="rootPanel"). The Kladde's pages are elements in the tree; therethis.XamlRootis enough. - Only one
ContentDialogmay be open. Learn describes it once as "per window" and once as "per thread" and says both times: a second one throws an exception. Never show two at the same time. If a dialog is triggered by an event that can fire more than once, lock the trigger while the dialog is open.
A third rule concerns design: every dialog should have at least one safe button that executes nothing, for example "Cancel" or "Close". Learn's example for deleting a file shows what that looks like:
private async void DisplayDeleteFileDialog()
{
ContentDialog deleteFileDialog = new ContentDialog
{
XamlRoot = this.XamlRoot,
Title = "Delete file permanently?",
Content = "If you delete this file, you won't be able to recover it. Do you want to delete it?",
PrimaryButtonText = "Delete",
CloseButtonText = "Cancel"
};
ContentDialogResult result = await deleteFileDialog.ShowAsync();
// Delete the file if the user clicked the primary button.
/// Otherwise, do nothing.
if (result == ContentDialogResult.Primary)
{
// Delete the file.
}
}
For more complex dialogs you create a dialog of your own: the item template Content Dialog creates a XAML file and a code-behind file whose class inherits from ContentDialog (not from Page).
It is shown with new MyDialog { XamlRoot = this.XamlRoot } and await dialog.ShowAsync(). In this lesson the Kladde needs only the simple dialog in code.
Styles, resources and themes
A resource is an object that you store with a key (x:Key) in a ResourceDictionary and look up elsewhere: brushes, styles, templates, dimensions. Dictionaries exist at
several levels: on an element (Page.Resources, Grid.Resources), in App.xaml for the whole application, and in files of their own (the item template Resource Dictionary), which you combine with
MergedDictionaries. A style (Style) collects property values for an element type with Setters; with a TargetType and without a key it applies to all elements of that type within its scope.
Two markup extensions look up a resource, and the difference matters:
{StaticResource Key}is evaluated once, when the XAML is loaded, and does not change afterward. Learn compares it to a find-and-replace with the value at application start.{ThemeResource Key}is evaluated on loading and every time the theme changes: through a change in the Windows settings or through a change in the program.
Themes: light, dark, high contrast
WinUI knows three themes: "Light", "Dark" and "HighContrast". By default an application runs in the theme that the application has in the Windows settings (Personalization, Colors,
default app mode). The system controls adapt by themselves because they work with {ThemeResource}; your own brushes have to do so too. For your own resources Learn gives these rules:
- Create theme dictionaries for "Light" and "Dark" and additionally for "HighContrast". A dictionary with the key "Default" also works; it is better to use the three names explicitly.
- Use
{ThemeResource}in styles, setters, control templates, property setters and animations. - Do not use
{ThemeResource}in the definitions in theThemeDictionaries, but{StaticResource}. Exception: theme-independent resources such as the accent color (SystemAccentColor) and system colors with the prefixSystemColor(for exampleSystemColorButtonFaceColor).
Anyone who does not follow these rules gets unexpected behavior at a theme change, according to Learn. An example for the Kladde, a background for cards, inserted in App.xaml into the existing ResourceDictionary
(not in place of it):
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="KladdeCardBrush" Color="#FFF3F3F3" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="KladdeCardBrush" Color="#FF2B2B2B" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="KladdeCardBrush" Color="{ThemeResource SystemColorButtonFaceColor}" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
The HighContrast line uses {ThemeResource} because it falls under the exception for system colors: the user chooses the colors of the contrast theme in Windows, and
the values are in the SystemColor… resources. The color values "#FFF3F3F3" and "#FF2B2B2B" are sample values from the Kladde, not requirements from Learn; you check the contrast ratio against the text yourself. The brush is used
with {ThemeResource}:
<Border Background="{ThemeResource KladdeCardBrush}" Padding="12" CornerRadius="4">
<TextBlock Text="Hoffmann consultation" />
</Border>
Two more properties on the topic, with two peculiarities that Learn stresses:
Application.RequestedThemeoverrides the application's theme, but can be set only at startup. Anyone who sets it at run time gets an exception. If you let the user choose, you save the choice and apply it at the next start in the constructor ofApp. Then there should also be an option to return to the Windows default.FrameworkElement.RequestedThemeapplies to an element and its children and can be changed at run time. Learn names menus and flyouts as an example, which may have a different theme than the application. For a "light/dark" switch in the window it is the means of choice:RootGrid.RequestedTheme = ElementTheme.Dark;.- Both are ignored in high contrast. The switch there comes from Windows (the key combination Alt+Shift+Print Screen, which Learn names, switches contrast themes), and the resources are then evaluated again.
As Learn describes the situation, it is therefore this: you supply the values for light, dark and contrast; Windows and the controls choose them.
Scaling and DPI
Dimensions in XAML are effective pixels. Learn explains them with the example of a row 44 effective pixels high; the number stays the same when Windows changes the display scale, and the actual pixels grow along with it. The usual scale according to Learn's page on the display: 100% corresponds to 96 DPI, 125% to 120 DPI, 150% to 144 DPI, 200% to 192 DPI, and the user can set custom values. Consequences:
- Layouts with
Autoand*survive any scale; fixed dimensions are the part that gets cut off at a large scale. Test at 150% and 200%. - Images: the resource management of the Windows App SDK manages images separately by scaling, language and contrast variant. How file names encode the scaling (the naming of the images) is stated on the page on resource management; it was not read here, and the Kladde does not use any images of its own yet.
- In testing: the scale can be changed in the Windows settings while the application is running (System, Display, Scale). Learn's Win2D page describes the same thing as a test that an application notices the change. Whether and how the WinUI template sets the DPI behavior in the manifest is not stated on the pages read: Learn's page on the DPI awareness of desktop applications says in general that applications without a specification count as "DPI-unaware" and are stretched by Windows, which looks blurry. Whether that applies to your template is answered by the test: if the window looks blurry at 150%, look at what your manifest contains on this.
Keyboard and screen reader
A Windows application has to be operable without a mouse and readable with a screen reader (Narrator, others). WinUI controls bring a lot with them; what is missing, you add.
Keyboard
- Tab order. A control is a tab stop if
IsEnabledandIsTabStoparetrue. By default the order follows the order in the XAML. If the visible arrangement differs from it (Learn namesGridand tables as a common cause), reorder the XAML or setTabIndex. Test it with the Tab key, not in your head.IsTabStop="False"takes an element out of the order that stays reachable another way; disabled elements are out automatically. - Focus. WinUI draws a focus rectangle when an element receives focus by keyboard. The first focus belongs on the most sensible element of the page.
- Accelerators (
KeyboardAccelerator). A keyboard shortcut for an action, with or without a visible control: a single keystroke such as F1 to F12, Esc, Delete, or a combination such as Ctrl+Shift+B. Accelerators apply globally by default. Learn shows them on a button:<Button.KeyboardAccelerators><KeyboardAccelerator Modifiers="Control" Key="…"/></Button.KeyboardAccelerators>. - Access keys (
AccessKey). Alt plus one or two characters that set the focus on a control:<Button Content="Accept" AccessKey="A" Click="…" />. With one character, Alt+key works immediately; when you press Alt, Windows shows small hint boxes (key tips). Avoid duplicate access keys.
Screen reader and AutomationProperties
A screen reader reads an element's name, its role and its value. WinUI builds the UI Automation tree from them. Where a name arises by itself and where not:
| Element | Name |
|---|---|
TextBlock |
the visible text, automatically |
Button, link |
the visible content; for a button that shows only an image, AutomationProperties.Name |
Image |
no equivalent to alt: AutomationProperties.Name or a visible label through LabeledBy |
TextBox |
not its text (that is the value); label the field with AutomationProperties.LabeledBy pointing to a TextBlock |
| Panels | usually no name; the children carry name and role |
The preferred way for form fields is a visible label in a TextBlock that the input field points to with LabeledBy, so that the spoken name matches the visible text:
<StackPanel Spacing="8">
<TextBlock x:Name="SearchLabel" Text="Search" />
<TextBox x:Name="SearchBox"
AutomationProperties.LabeledBy="{Binding ElementName=SearchLabel}" />
</StackPanel>
That is Learn's pattern for an image and a label, transferred to an input field; {Binding ElementName=…} is the notation Learn uses in this example. There is also
AutomationProperties.HelpText for additional explanation (Narrator reads it on request, according to Learn with CapsLock+F), and AutomationProperties.AcceleratorKey and AccessKey for
hints about shortcuts. The latter set no key behavior: the behavior stays with KeyboardAccelerator and AccessKey; the properties only report it to assistive technologies.
What Learn also stresses:
- Names from data. In lists filled through a template, names arise only after the binding. Check that every generated item has a sensible name; sometimes you have to set it in code.
- Localization. Names are to be localized like visible text, through resource files (
x:Uid); an explicitly setAutomationProperties.Namealso belongs in the resources. This course does not localize, but hard-coded names are a debt that you name. - Test early. With a screen reader, with Accessibility Insights for Windows (checks the automation tree) and with AccScope (shows it). Learn recommends not testing only before delivery.
- Text size and contrast. Windows lets users set the text size; according to Learn, all WinUI text controls support it without extras if your layout can take it (that is, does not rely on fixed heights).
Exercise 1
The data models and the sample data. Create the types and the fixed data that the pages work with.
- In
src/Kladde.Desktop, create a folderModels. CreateContactResponseandAppointmentResponsethere. The fields match the answers of the Kladde's web API from K2. - In the same file or in
SampleData.cs, create the fixed data: six contacts and four appointments on today's date. - Build the project.
How to check: the project builds without errors. The types are simple data types with no relation to XAML. That matters later: in W5.2.1 they move into the library.
Solution 1
The models. The fields match ContactResponse and AppointmentResponse from K2:
namespace Kladde.Desktop.Models;
public sealed record ContactResponse(
int Id, string Name, string? Email, string? Company, IReadOnlyList<string> Tags)
{
public string CompanyText => string.IsNullOrWhiteSpace(Company) ? "(no company)" : Company;
}
public sealed record AppointmentResponse(
string Title, DateTimeOffset Start, DateTimeOffset End, IReadOnlyList<int> ContactIds)
{
public string TimeText => $"{Start.LocalDateTime:t} to {End.LocalDateTime:t}";
}
The additional properties CompanyText and TimeText are display text. They are here so that the data templates bind only properties and no functions. That is a simplification of this lesson:
display text belongs in a view model, and that is where it ends up in W5.2.1. The string :t formats the time according to the computer's culture.
The fixed data, with placeholders in the form of addresses under example.com:
namespace Kladde.Desktop.Models;
/// <summary>Fixed sample data for W5.1; the web API replaces it in W5.2.2.</summary>
public static class SampleData
{
public static IReadOnlyList<ContactResponse> Contacts { get; } =
[
new(1, "Mira Hoffmann", "mira.hoffmann@example.com", "Hoffmann IT Consulting", ["Customer"]),
new(2, "Jonas Weber", "jonas.weber@example.com", "Weber and Partners", ["Customer", "Maintenance"]),
new(3, "Selin Aydin", "selin.aydin@example.com", "Aydin Logistics", ["Prospect"]),
new(4, "Tom Brandt", null, null, []),
new(5, "Lena Krüger", "lena.krueger@example.com", "Krüger Software", ["Partner"]),
new(6, "Paul Neumann", "paul.neumann@example.com", "Neumann Construction", ["Customer"]),
];
public static IReadOnlyList<AppointmentResponse> TodaysAppointments { get; } =
[
new("Hoffmann consultation", Today(9, 0), Today(10, 0), [1]),
new("Weber callback", Today(10, 30), Today(11, 0), [2]),
new("Aydin workshop", Today(13, 0), Today(14, 30), [3]),
new("Team sync", Today(16, 0), Today(16, 30), []),
];
private static DateTimeOffset Today(int hour, int minute) =>
new(DateTime.Today.AddHours(hour).AddMinutes(minute));
}
If the files are in a subfolder Models, the template assigns the namespace Kladde.Desktop.Models; adjust it if your IDE suggests a different one. A contact without an email and without a company (Tom Brandt) is
included on purpose: later it tests the display of empty values.
Exercise 2
The shell with NavigationView. Build the navigation bar with two items and empty pages behind it.
- In
src/Kladde.Desktop, create the folderPages. Create two pages there with the item template Blank Page (ordotnet new winui-page -n AppointmentsPage):AppointmentsPageandContactsPage. Give each aTextBlockwith its title. - Put the
NavigationViewwith theFrameinto the file that your template shows at startup (the shell), and add the code forNavView_Loaded,NavView_ItemInvoked,ContentFrame_NavigatedandContentFrame_NavigationFailed. - Start the application.
How to check: after the start, "Appointments" is selected on the left and the page shows its title. A click on "Contacts" shows the second page, and the selection changes. With the keyboard: Tab to the bar, arrow keys,
Enter. As a test, change a Tag to a name that is not a page: a click on it does nothing. Change it back.
Solution 2
The pages (here the first one; the second is identical with the title "Contacts"):
<Page
x:Class="Kladde.Desktop.Pages.AppointmentsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Padding="24">
<TextBlock Text="Appointments" />
</Grid>
</Page>
The shell: the XAML and the code are in the section on navigation above. Three things matter. The Tag values are the full type names of the pages, with namespace; if they do not match the namespace
of your files, adjust them. The names ContentFrame and NavView in the code have to match the x:Name in XAML. And: the method for Loaded hangs on the NavigationView, as Learn shows it in the example (Loaded="NavView_Loaded"): the element's event runs when the bar is in the tree, regardless of whether the shell is a Window or a Page.
If no page appears after the start: is ContentFrame.Navigate in NavView_Loaded reached? Learn points out that the NavigationView does not load a page on its own. If the error occurs while
navigating, ContentFrame_NavigationFailed throws the exception and names the type; it is usually a wrong name or namespace.
Cleanup: nothing.
Exercise 3
Today's appointments. Show the appointments on the "Appointments" page.
- The page gets a
Gridwith two rows: on top anAutorow with the heading "Today's appointments", below it a*row with aListView. - In the code-behind, the page has a public property
Appointmentsthat returnsSampleData.TodaysAppointments. TheListViewbinds to it with{x:Bind Appointments}. - For each appointment, the data template shows the title and below it the time (
TimeText). Usex:DataType. - If the list shows no item (as a test,
Appointmentsempty), a text "No appointments today." is supposed to appear.
How to check: four items appear with title and time. With too little window height the list scrolls. With an empty property the hint text appears.
Solution 3
<Page
x:Class="Kladde.Desktop.Pages.AppointmentsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:Kladde.Desktop.Models">
<Grid Padding="24" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock x:Name="HeadingText" Text="Today's appointments" FontSize="24" />
<ListView Grid.Row="1"
ItemsSource="{x:Bind Appointments}"
SelectionMode="None"
AutomationProperties.Name="Today's appointments">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:AppointmentResponse">
<StackPanel Padding="0,8">
<TextBlock Text="{x:Bind Title}" />
<TextBlock Text="{x:Bind TimeText}" Opacity="0.7" />
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock x:Name="EmptyText" Grid.Row="1" Text="No appointments today."
Visibility="Collapsed" />
</Grid>
</Page>
using Kladde.Desktop.Models;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Kladde.Desktop.Pages;
public sealed partial class AppointmentsPage : Page
{
// Code-behind for W5.1; in W5.2.1 this becomes a view model.
public IReadOnlyList<AppointmentResponse> Appointments { get; } = SampleData.TodaysAppointments;
public AppointmentsPage()
{
InitializeComponent();
EmptyText.Visibility = Appointments.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
}
On the details. RowSpacing (and in Exercise 4 ColumnSpacing) on the Grid sets a gap between rows and columns; Learn does not name these two properties on the pages read (for StackPanel it names Spacing).
If the compiler rejects them, use Margin on the children. SelectionMode="None" turns off selection and click; the list is only a display.
InitializeComponent() belongs in the constructor of every page, otherwise the XAML elements do not exist. The x:Bind on Appointments looks for the property on the page; it has to be public, and for
a fixed value the default mode OneTime is enough.
The hint text sits in the same row cell as the list and covers it when it is visible. That is harmless here, because the list is empty then. Collapsed takes an element out of the layout completely, Visible brings it back.
Cleanup: nothing.
Exercise 4
Contacts with a detail area, search and delete. The "Contacts" page has the list on the left, the selected contact on the right, a search on top and a "Delete" button with a dialog.
- The page's
Gridhas two columns (2*for the list,3*for the detail area) and two rows (anAutorow for the search and the button, below it a*row). - On top: a
TextBlock"Search" with aTextBoxthat belongs to it throughAutomationProperties.LabeledBy, and a "Delete" button with an access key and an accelerator (Delete key). - On the left: a
ListViewin the*row whose data template shows name and company. The data source is anObservableCollection<ContactResponse>. - On the right: a
RelativePanelwith name, company, email and anItemsRepeaterfor the tags. Show the selected contact's data in theSelectionChangedhandler; without a selection the "Delete" button is disabled. - When the "Delete" button is pressed, a
ContentDialogasks "Delete contact?" with the buttons "Delete" and "Cancel". Only with "Delete" does the contact disappear from the list. SetXamlRoot. - The search filters the list by name or company, on every change of the text.
How to check: the list shows six contacts. The selection shows the data on the right; Tom Brandt shows "(no company)" and no tags. "Delete" opens the dialog; "Cancel" and Esc change nothing; "Delete" removes the contact. The Tab key leads from the search to the button and to the list; the Delete key triggers the button. A second click on "Delete" while the dialog is open is blocked by the dialog itself (it is modal).
Solution 4
<Page
x:Class="Kladde.Desktop.Pages.ContactsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:Kladde.Desktop.Models">
<Grid Padding="24" ColumnSpacing="24" RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="3*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock x:Name="SearchLabel" Text="Search" VerticalAlignment="Center" />
<TextBox x:Name="SearchBox" Width="240"
AutomationProperties.LabeledBy="{Binding ElementName=SearchLabel}"
TextChanged="SearchBox_TextChanged" />
<Button x:Name="DeleteButton" Content="Delete" AccessKey="D"
IsEnabled="False" Click="DeleteButton_Click">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="Delete" />
</Button.KeyboardAccelerators>
</Button>
</StackPanel>
<ListView x:Name="ContactList" Grid.Row="1"
ItemsSource="{x:Bind Contacts}"
SelectionChanged="ContactList_SelectionChanged"
AutomationProperties.Name="Contacts">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:ContactResponse">
<StackPanel Padding="0,8">
<TextBlock Text="{x:Bind Name}" />
<TextBlock Text="{x:Bind CompanyText}" Opacity="0.7" />
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock x:Name="EmptyText" Grid.Row="1" Text="No contacts."
Visibility="Collapsed" />
<Border Grid.Row="1" Grid.Column="1" Padding="16" CornerRadius="4"
Background="{ThemeResource KladdeCardBrush}">
<RelativePanel>
<TextBlock x:Name="DetailName" FontSize="20" Text="No contact selected" />
<TextBlock x:Name="DetailCompany" RelativePanel.Below="DetailName" />
<TextBlock x:Name="DetailEmail" RelativePanel.Below="DetailCompany" />
<ItemsRepeater x:Name="TagRepeater" RelativePanel.Below="DetailEmail">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="8" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Padding="8,2" CornerRadius="4" Background="{ThemeResource KladdeCardBrush}">
<TextBlock Text="{x:Bind}" />
</Border>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</RelativePanel>
</Border>
</Grid>
</Page>
The brush KladdeCardBrush comes from the section on themes; if you have not created it yet, add it to App.xaml now or use a fixed color for the time being. The code-behind (code-behind for W5.1, in W5.2.1
it becomes a view model):
using System.Collections.ObjectModel;
using Kladde.Desktop.Models;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Kladde.Desktop.Pages;
public sealed partial class ContactsPage : Page
{
private readonly List<ContactResponse> allContacts = [.. SampleData.Contacts];
public ObservableCollection<ContactResponse> Contacts { get; }
public ContactsPage()
{
Contacts = new ObservableCollection<ContactResponse>(allContacts);
InitializeComponent();
UpdateEmptyText();
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var term = SearchBox.Text.Trim();
Contacts.Clear();
foreach (var contact in allContacts.Where(c => Matches(c, term)))
{
Contacts.Add(contact);
}
UpdateEmptyText();
}
private static bool Matches(ContactResponse contact, string term) =>
term.Length == 0
|| contact.Name.Contains(term, StringComparison.CurrentCultureIgnoreCase)
|| (contact.Company?.Contains(term, StringComparison.CurrentCultureIgnoreCase) ?? false);
private void ContactList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var contact = ContactList.SelectedItem as ContactResponse;
DeleteButton.IsEnabled = contact is not null;
DetailName.Text = contact?.Name ?? "No contact selected";
DetailCompany.Text = contact?.CompanyText ?? "";
DetailEmail.Text = contact?.Email ?? "";
TagRepeater.ItemsSource = contact?.Tags;
}
private async void DeleteButton_Click(object sender, RoutedEventArgs e)
{
if (ContactList.SelectedItem is not ContactResponse contact)
{
return;
}
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Delete contact?",
Content = $"“{contact.Name}” will be removed from the list.",
PrimaryButtonText = "Delete",
CloseButtonText = "Cancel"
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
allContacts.Remove(contact);
Contacts.Remove(contact);
UpdateEmptyText();
}
}
private void UpdateEmptyText() =>
EmptyText.Visibility = Contacts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
On the details: Contacts is filled before InitializeComponent() so that x:Bind finds the property during construction. The ObservableCollection reports changes; with a List<T> the display would stay put after Clear and Add.
The dialog gets XamlRoot = XamlRoot; without this line WinUI 3 throws an exception at ShowAsync(). "Cancel" is the safe button; Esc does not deliver Primary, and nothing is deleted. How the selection behaves when the selected element
disappears, Learn does not say; observe it. The screen reader reads the button through its visible content; the TextBox has no name from its text, which is why LabeledBy points to "Search"; the list gets its name through AutomationProperties.Name.
AccessKey="D" means Alt+D; Delete is a KeyboardAccelerator that applies globally. That can get in the way as soon as the page gets a text field in which Delete removes characters; Learn names ScopeOwner to restrict the scope.
Cleanup: nothing. The changes are in memory only; a restart brings the six contacts back.
Exercise 5
Check themes, high contrast and scaling. Create the brushes, switch the themes and test the scale.
- Add the brushes
KladdeCardBrushfor "Light", "Dark" and "HighContrast" toApp.xaml, into the existingResourceDictionary. - Start the application. In the Windows settings, set the app mode to light and to dark while the application is running.
- In the shell, add a "Switch theme" button that toggles
RequestedThemeon the shell's root element betweenElementTheme.LightandElementTheme.Dark. - Turn on a contrast theme (in the Windows settings; for the switch Learn also names the key combination Alt+Shift+Print Screen, but it does not name the labels of the settings pages) and observe the contacts page.
- Set the display scale to 150% and to 200% and observe layout and sharpness.
- Go through the "Contacts" page with the Tab key, with Narrator (or another screen reader) at hand, and pay attention to names and order.
How to check: the cards change color with the theme and are readable in both. In the contrast theme the background takes the system color. At 200% nothing is cut off; the list scrolls. The screen reader says "Search" for the text field, "Delete" for the button and "Contacts" for the list. Everything else you observe, you write down.
Solution 5
For step 1 the rule from the section on themes applies: {StaticResource} and color values in light and dark, {ThemeResource} only for the system color in the contrast dictionary. On step 2: the system controls switch immediately,
your own brushes only if you use them with {ThemeResource}. If you used {StaticResource KladdeCardBrush}, the card stays in the old color at the switch: that is the error that Learn names for
this rule.
Step 3, an example for the switch. The name of the root element, RootGrid, stands for the outermost element of the shell; give it an x:Name:
private void ThemeButton_Click(object sender, RoutedEventArgs e)
{
RootGrid.RequestedTheme = RootGrid.ActualTheme == ElementTheme.Dark
? ElementTheme.Light
: ElementTheme.Dark;
}
ActualTheme is a property of FrameworkElement that Learn does not name on the pages read; if it is missing for you, replace it with a field that remembers the state. FrameworkElement.RequestedTheme can be changed at run time,
unlike Application.RequestedTheme, which has to be set at startup. Observation on contrast: the property is ignored in high contrast, and the switch has no effect there.
Step 5: Learn's table for scaling names 100%, 125%, 150% and 200%. What you see depends on the template. If the window looks blurry, then according to Learn's page on the DPI awareness of desktop applications the cause is often an application with no DPI specification in the manifest; whether that is the case here, you check in the manifest. If it looks sharp, the scaling is fine.
Step 6: if names are missing, add AutomationProperties.Name or LabeledBy. If the Tab key jumps in an order that contradicts the arrangement, reorder the XAML or set TabIndex. If the list has no name,
the screen reader reads only its role.
Cleanup: set the Windows settings (scale, contrast theme, app mode) back to your usual values.
The Kladde: appointments and contacts as pages
After this lesson Kladde.Desktop has two pages in a NavigationView: today's appointments as a list and the contacts with search, a detail area and a dialog for deleting, still with fixed sample data and with the logic in the
code-behind. The projects from K2 are unchanged. Two things are provisional on purpose: the logic in the pages (filter, delete, display text) and the data in the code. W5.2.1 pulls the logic into view models in the library
Kladde.Desktop.Logic and tests it without a window, and W5.2.2 replaces the sample data with the Kladde's web API with sign-in. Commit Models, Pages, the shell and the changes to App.xaml to the repository, on the branch
feature/winui-app, with a message such as feat(desktop): pages for appointments and contacts with NavigationView.
What you can do now
- You read and write XAML with namespaces, names, events and resources, and you know the difference between
{StaticResource}and{ThemeResource}. - You build layouts with
Grid(Auto,*, fixed sizes),StackPanelandRelativePaneland show lists withListViewandItemsRepeater, with a data template andx:DataType. - You navigate with
Frame,PageandNavigationViewand know that theNavigationViewdoes not navigate by itself. - You show a
ContentDialogwithXamlRootset, with a safe button and without two dialogs at the same time. - You create resources for light, dark and high contrast, switch the theme at run time on an element and check the display scale.
- You make pages operable with the keyboard (tab order, access keys, accelerators) and readable for screen readers (
AutomationProperties).
Further reading
- Layout panels: Grid, StackPanel, RelativePanel and the other panels.
- Responsive layouts: flowing layouts, size properties.
- List view and grid view: selection, click mode, data templates.
- ItemsRepeater: the building block for custom collection displays.
- NavigationView: structure, display modes, the complete example with Frame.
- Dialog controls: ContentDialog, XamlRoot, buttons.
- XAML theme resources: theme dictionaries and the rules for custom resources.
- ResourceDictionary and XAML resource references: dictionaries and references.
- {x:Bind} markup extension: the compiled binding.
- Expose basic accessibility information: names, LabeledBy, HelpText.
- Keyboard accessibility: tab order, accelerators, access keys.
- Accessibility checklist: what you check before shipping.