In W2.1.1 you created Kladde.App. The app still shows the template’s page. This lesson replaces it with your first own pages: a contact list with a detail page and the appointments of the day, connected by a shell with two tabs. The data is hard-coded; the web API joins in W2.2.2. The code is also still kept simple, with code-behind. That’s on purpose: in W2.2.1 you rebuild exactly these pages on MVVM, and for that you first need them in this simple form.

For that, you learn seven things every MAUI app needs:

Section What you can do afterward
XAML read and write a page
Layouts use rows, columns and stacks correctly
Controls place the elements of this lesson
CollectionView show lists, select items, show an empty state
Shell tabs, routes, data for the next page
Styles and resources design in one place, light and dark appearance
Accessibility headings, labels, the system’s font sizes

Reading XAML

XAML is a description of objects in XML. An element is a class, an attribute is a property. Every page consists of two files that together make up a single class: the .xaml file with the user interface and the .xaml.cs file, the code-behind. This is what the head of a page looks like:

XML
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Kladde.App.Pages.ContactsPage">
    ...
</ContentPage>
C#
namespace Kladde.App.Pages;

public partial class ContactsPage : ContentPage
{
    public ContactsPage()
    {
        InitializeComponent();
    }
}

What’s there can be explained in a few points:

  • x:Class names the full name of a .NET class. The XAML file thereby defines the class ContactsPage, which derives from ContentPage; that’s why the C# class is partial. Everything else in the file is objects of existing classes that get created and configured. x:Class may appear only on the root element.
  • The two namespaces. The first one, without a prefix, means: elements without a prefix are classes of .NET MAUI, such as ContentPage or Label. The second has the prefix x and applies to things that belong to XAML itself, such as x:Class, x:Name and x:DataType. The addresses aren’t web pages; there’s nothing behind them. They serve as a version identifier.
  • InitializeComponent creates all the objects of the XAML file at run time, connects them into a tree, attaches event handlers from the code to the events, and sets the tree as the page’s content. Without the call, the page stays empty.
  • You bring in your own classes from your project with another namespace, for example xmlns:models="clr-namespace:Kladde.App.Models". After that you write models:ContactResponse.

Properties, converters, markup extensions

You write simple values as an attribute: <Label Text="Contacts" FontSize="24" />. For enumerations, the name of the member is enough, and for colors the name from Colors or a hexadecimal RGB value; type converters that come with MAUI take care of that. If the value is too complicated for an attribute, you write the property as an element of its own (property element syntax):

XML
<Label>
    <Label.FontSize>
        22
    </Label.FontSize>
</Label>

Markup extensions go in curly braces. They’re evaluated at run time. You need these in this lesson:

Expression Meaning
{Binding Name} the value of the property Name of the current BindingContext
{StaticResource Key} the value from a resource dictionary, looked up once
{DynamicResource Key} like StaticResource, but the connection stays: if the entry is replaced, the element follows
{AppThemeBinding Light=…, Dark=…} a value depending on the system theme
{DataTemplate pages:ContactsPage} a template that creates a page only when needed

You attach events with the name of a method in the code-behind: <Button Clicked="OnBackClicked" />. An element that your code needs to access gets x:Name="ContactList"; after that there’s a field of the same name in the code-behind.

A source generator for XAML

MAUI translates XAML at build time (XAML compilation, on by default). .NET 10 adds a source generator for XAML. At compile time it produces strongly typed code for your XAML files, which according to Learn improves build time and supports the tooling. You turn it on in the project file, in a PropertyGroup:

XML
<PropertyGroup>
  <MauiXamlInflator>SourceGen</MauiXamlInflator>
</PropertyGroup>

In this course you turn it on in exercise 1 and leave it on afterward. Learn also describes global and implicit namespaces, which spare you from repeating xmlns: lines; they’re marked as a preview feature and need a file GlobalXmlns.cs and a switch for preview features. The course doesn’t use them.

Layouts

A page has one child, usually a layout. A layout arranges its children. You need three of them constantly:

Layout Arranges When
Grid in rows and columns fixed grids, forms, list entries with several parts
VerticalStackLayout one below the other one column of elements
HorizontalStackLayout side by side one row of elements

There’s also StackLayout with an Orientation property. The two stack layouts with a fixed direction in their name are, according to Learn, the better-performing choice. All three have the property Spacing (default 0), the gap between the children.

Grid

A Grid has one row and one column by default. You define rows and columns with RowDefinitions and ColumnDefinitions. Every size is one of three kinds:

Notation Kind Meaning
50 Absolute exactly this number of device-independent units
Auto Auto as much as the content needs
*, 2* Star the rest, distributed in proportion to the numbers

The default of a row or column is *. For short, you write the definitions as a comma-separated string, for example RowDefinitions="Auto, *" and ColumnDefinitions="Auto, *". A child’s cell is determined by the attached properties Grid.Row and Grid.Column, counted from 0 (both default to 0). With Grid.RowSpan and Grid.ColumnSpan a child spans several cells.

XML
<Grid RowDefinitions="Auto, Auto" ColumnDefinitions="Auto, *" ColumnSpacing="12">
    <Label Text="09:00" />
    <Label Grid.Row="1" Text="until 10:00" />
    <Label Grid.Column="1" Grid.RowSpan="2" Text="Consultation" />
</Grid>

Learn gives two warnings. First: set as few rows and columns as possible to Auto, because each one forces additional calculations; fixed or * is cheaper. Second: a Grid is not a table layout for tabular data. Don’t imitate a grid with nested stacks either: that’s wasteful, use a Grid.

Stacks and spacing

The MAUI stacks don’t “divide up” the space: they stack their children in one direction until all are stacked, even beyond the available space. Options like FillAndExpand are unknown to VerticalStackLayout and HorizontalStackLayout; they treat them as Fill. That’s the difference from Xamarin.Forms that you’ll find in old examples.

There are three properties for spacing, each with a clearly separate meaning:

Property Meaning
Padding the space between the layout or page and its children
Margin the space between an element and its neighbors
Spacing the space between the children of a stack

A child’s alignment is controlled by HorizontalOptions and VerticalOptions with the values Start, Center, End and Fill. A stack respects them only across the stacking direction: in a vertical stack, HorizontalOptions has an effect, but VerticalOptions doesn’t.

Controls

This lesson needs only a few controls. They’re sorted here by what they can do:

Control Purpose What you use of it
Label display text Text, FontSize, FontAttributes, TextColor, LineBreakMode, MaxLines
Button trigger an action Text, Clicked
CollectionView display a collection ItemsSource, ItemTemplate, EmptyView, SelectionMode
ToolbarItem an entry in the title bar Text, Clicked, Order
ContentPage a page Title, Content, ToolbarItems

All controls with text have the same four properties for the font: FontFamily, FontAttributes, FontSize and FontAutoScalingEnabled. The font size is in device-independent units. The default of MAUI apps is the font Open Sans on every platform; it can be changed.

CollectionView instead of ListView

With .NET 10, ListView and its cells (TextCell, ImageCell, EntryCell, SwitchCell, ViewCell) are obsolete, and so is TableView. Learn names CollectionView for everything. That makes old tutorials that use ListView with TextCell wrong for this course. The CollectionView differs from the ListView in several ways:

Topic CollectionView
Separator lines not built in; you draw them in the template if you want them
Selection off by default (SelectionMode="None"); Single or Multiple turns it on
Row height determined by ItemSizingStrategy
Header and footer Header and Footer
Groups IsGrouped, GroupHeaderTemplate
Reusing rows takes over the virtualization of the native controls

Data and template

You set the collection with ItemsSource, and what an item looks like is determined by ItemTemplate, a DataTemplate. The root element of the template here is a Grid; the root node must never be a ViewCell, because the CollectionView doesn’t know cells. The example shows the pattern:

XML
<CollectionView ItemsSource="{Binding Monkeys}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="models:Monkey">
            <Grid Padding="10">
                <Label Text="{Binding Name}" FontAttributes="Bold" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

That’s the form from Learn, shortened. Your own list follows in the exercise. Two notes on the collection:

  • If the list should change when items are added, removed or changed, it needs a collection that reports changes, such as ObservableCollection<T>. For this lesson’s fixed data, an ordinary list is enough. In W2.2.1 you swap it out.
  • A CollectionView throws an exception if its ItemsSource is changed from a thread other than the UI thread. Remember that for W2.2.2, when data comes from the network.

Two traps concern the layout around the list: a CollectionView in a VerticalStackLayout or StackLayout can prevent scrolling and limit the number of items shown. Put it in a Grid. And if you use stack layouts to arrange the items (ItemsLayout), you turn off virtualization; the default arrangement is vertical and fits.

Empty and selection

If ItemsSource is null or empty, the list shows its empty view: the text in EmptyView, or a whole view with several elements. That’s the place for “No contacts yet.”:

XML
<CollectionView ItemsSource="{Binding Contacts}" EmptyView="No contacts yet." />

With SelectionMode="Single", an item is selected and highlighted; SelectedItem is then the item and the event SelectionChanged fires. Its arguments have PreviousSelection and CurrentSelection as read-only lists. When a user returns from a detail page, the item is still selected and can’t be tapped again. Learn recommends clearing the selection after it fires:

C#
void OnCollectionViewSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var cv = (CollectionView)sender;
    if (cv.SelectedItem == null)
        return;

    cv.SelectedItem = null;
}

Clearing fires SelectionChanged once more, with an empty CurrentSelection; your handler has to recognize this second call and skip it. The exercise builds that in.

Bound types and warnings

The bindings {Binding Name} resolve a property path at run time by reflection, unless XAML knows the type. Set x:DataType to the type that the element and its children bind to, and the bindings are compiled at build time: wrong names are build errors, and the run time is faster. If you leave out x:DataType, you get the warning XC0022; inside a DataTemplate you need it on the template itself, otherwise the build reports XC0024. Set it everywhere the type of the BindingContext changes. You can turn both warnings into errors:

XML
<WarningsAsErrors>$(WarningsAsErrors);XC0022;XC0023</WarningsAsErrors>

Shell: tabs, routes, data

The Shell is the structure of the app. The template creates it as AppShell. Its main building blocks:

Building block Meaning
TabBar the bottom tab bar; without a menu
FlyoutItem an entry in the menu that slides in from the side
Tab grouped content of a tab
ShellContent a page in the hierarchy, with Title, Icon, Route and ContentTemplate

The pages are created on demand, when someone navigates to them. For that you set the ContentTemplate of every ShellContent with the DataTemplate markup extension to the page class. Several ShellContent elements in a TabBar become bottom tabs; more than five tabs result in a “More” tab. ShellContent doesn’t need to be inside a Tab: MAUI wraps it itself.

XML
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:pages="clr-namespace:Kladde.App.Pages"
       x:Class="Kladde.App.AppShell">
    <TabBar>
        <ShellContent Title="Contacts" Route="contacts" ContentTemplate="{DataTemplate pages:ContactsPage}" />
        <ShellContent Title="Today" Route="today" ContentTemplate="{DataTemplate pages:AppointmentsPage}" />
    </TabBar>
</Shell>

Routes

You navigate with URIs made of routes. Every element of the Shell hierarchy has one; if one is missing, MAUI creates it at run time, but not persistently from session to session. Two identical routes on the same level throw an ArgumentException at startup. Pages that aren’t in the hierarchy, such as detail pages, you register with Routing.RegisterRoute, before first use. Their route is called a global route:

C#
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute("contactdetails", typeof(ContactDetailPage));
    }
}

You navigate with Shell.Current.GoToAsync. The method returns a Task that completes when the animation is done, and must always be called with await: a forgotten await leads to race conditions, missing parameters and silent errors.

URI Effect
//today absolute route to an element of the hierarchy
contactdetails relative route: a registered page is pushed onto the stack
.. back one page
../route back, and then to a route

Two limits from Learn: absolute routes don’t work with pages registered with RegisterRoute; and relative routes don’t work for pages that are in the Shell class itself. If you navigate to a route that doesn’t exist, you get an ArgumentException. When a page from the hierarchy is navigated to, no navigation stack is created; when a page is navigated to that isn’t in the hierarchy, one is created, and you can go back.

Data for the target page

Simple values go to the URI as a string: await Shell.Current.GoToAsync($"details?id={item.Id}");. You pass whole objects as the second argument. For data that applies only once and is deleted after the navigation, there’s ShellNavigationQueryParameters:

C#
var parameters = new ShellNavigationQueryParameters
{
    { "Contact", contact }
};
await Shell.Current.GoToAsync("contactdetails", parameters);

The target page receives them in one of two ways: with QueryProperty attributes or through the interface IQueryAttributable with the method ApplyQueryAttributes. Learn warns that the attribute isn’t safe for trimming and for Native AOT, and recommends IQueryAttributable. The course therefore uses the interface. Note one difference: strings that arrive through IQueryAttributable aren’t automatically URL-decoded; the ones that arrive through the attribute are.

Styles and resources

Everything you set on more than one element belongs in a style or a resource. A resource dictionary (ResourceDictionary) contains colors, styles, templates, converters and other values. It can be attached to a control, a page or the app; the higher up, the wider it applies. A style (Style) bundles property values into Setters, with a TargetType:

  • Implicit style: without x:Key. It applies to every element in range whose type is exactly the TargetType, but not to derived types. With ApplyToDerivedTypes="True" it applies to them as well.
  • Explicit style: with x:Key. It applies only to elements that use it with Style="{StaticResource Key}".
  • Inheritance: BasedOn builds a style on another one; it may inherit only from styles on the same or a higher level.

You bring in your own files as part of the app’s resources. App.xaml already holds a dictionary with merged dictionaries from the template; add a line there:

XML
<ResourceDictionary.MergedDictionaries>
    <ResourceDictionary Source="Resources/Styles/KladdeStyles.xaml" />
</ResourceDictionary.MergedDictionaries>

The example shows the form; the path depends on where you put the file. A change to a resource dictionary reloads all XAML files with Hot Reload.

Light and dark

The systems have a light and a dark theme, and an app should respect both. MAUI apps react to this on iOS 13 and later, Android 10 (API 29) and later, macOS 10.14 and later, and Windows 10 and later. The AppThemeBinding markup extension picks a value based on the theme; if it changes while the app is running, all elements adapt. You bring in values from a dictionary with StaticResource:

XML
<Color x:Key="CardLight">#F2F2F5</Color>
<Color x:Key="CardDark">#2A2A30</Color>

<Style x:Key="CardGrid" TargetType="Grid">
    <Setter Property="BackgroundColor"
            Value="{AppThemeBinding Light={StaticResource CardLight}, Dark={StaticResource CardDark}}" />
</Style>

The current system theme is provided by Application.Current.RequestedTheme (Light, Dark or Unspecified). With Application.Current.UserAppTheme you decide which theme the app uses, independent of the system; AppTheme.Unspecified restores the system’s behavior. You need that in the exercise to check both appearances without switching your phone.

Accessibility

An app is for everyone, including people who use it with a screen reader, with enlarged text or without touch. What K2 taught in lesson 2.1.7 for web pages applies here again in spirit; the technique is different. Three tools belong in every page.

Semantic properties. SemanticProperties are attached properties that feed the platform’s accessibility features. They don’t force a uniform effect, but use what the platform can do:

Property Purpose
Description a short text that the screen reader says for the element
Hint additional context, such as what the element is for
HeadingLevel marks a heading, Level1 to Level9, so people can jump between headings
XML
<Image Source="like.png"
       SemanticProperties.Description="Like"
       SemanticProperties.Hint="Like this post." />

The example is from Learn. On Windows there are nine heading levels; Android and iOS know only one, and any level set there turns into a heading. Learn names several traps. Don’t set Description on a Label: the text would no longer be read aloud, and visible and spoken text should be the same. Don’t set Description on Entry and Editor on Android; it interferes with TalkBack; use Placeholder or Hint there. And don’t set Hint differently from Placeholder: on Android both end up in the same platform property. On iOS the screen reader can’t reach the children of an element that has a Description itself.

Reading order. By default it’s the order in XAML, and in a Grid additionally by row and column. Build the layout so that the order in the document matches the reading order.

System font. All controls with text scale their font with the operating system’s setting (FontAutoScalingEnabled is true). Turn that off only if the size must be exact. More important is that your layouts can handle a larger font: rows with Auto and * grow along, rows with a fixed height cut off text.

Exercise 1

Contact list with Shell. Replace the template’s page with a contact list. The data is hard-coded.

  1. In src/Kladde.App, create the folders Models and Pages. Delete MainPage.xaml and MainPage.xaml.cs.
  2. In Models, create the classes ContactResponse, AppointmentResponse and SampleData (source code in the solution). The two records have the same names and fields as the web API’s responses from K2; in W2.2.2 the API fills them.
  3. In Pages, create the page ContactsPage (XAML plus code-behind) with a CollectionView that shows the contacts, with name and company per item and the empty view “No contacts yet.”.
  4. Change AppShell.xaml so that it has a TabBar with the contacts page. The second page follows in exercise 3.
  5. Turn on the source generator (MauiXamlInflator) and build: no XC0022 warning.
  6. Start the app on the Android emulator and on Windows.

How to check: the app shows the list with six contacts. As a test, put ContactList.ItemsSource = Array.Empty<ContactResponse>(); in the constructor: the text of the empty view appears. Remove the line again. While the app is running, change the font size of the name in the XAML file and watch Hot Reload. After switching to the source generator, check whether Hot Reload still works; what Learn says about how the two interact isn’t documented in this lesson.

Solution 1

The models. The fields match the web API’s responses (ContactResponse and AppointmentResponse from K2):

C#
namespace Kladde.App.Models;

public sealed record ContactResponse(
    int Id, string Name, string? Email, string? Company, IReadOnlyList<string> Tags);

public sealed record AppointmentResponse(
    string Title, DateTimeOffset Start, DateTimeOffset End, IReadOnlyList<int> ContactIds);

The fixed data. The appointments fall on today’s date; the helper method Today builds a point in time in the local time zone:

C#
namespace Kladde.App.Models;

/// <summary>Fixed sample data for W2.1; the web API replaces it in W2.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("Consultation Hoffmann", Today(9, 0), Today(10, 0), [1]),
        new("Callback Weber", Today(10, 30), Today(11, 0), [2]),
        new("Workshop Aydin", 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));
}

The page Pages/ContactsPage.xaml. The namespace models brings in the Models folder; x:DataType sits on the DataTemplate:

XML
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:Kladde.App.Models"
             x:Class="Kladde.App.Pages.ContactsPage"
             Title="Contacts">
    <CollectionView x:Name="ContactList"
                    EmptyView="No contacts yet.">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="models:ContactResponse">
                <Grid Padding="12" RowDefinitions="Auto, Auto">
                    <Label Text="{Binding Name}" FontAttributes="Bold" />
                    <Label Grid.Row="1" Text="{Binding Company}" />
                </Grid>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</ContentPage>

Pages/ContactsPage.xaml.cs:

C#
using Kladde.App.Models;

namespace Kladde.App.Pages;

public partial class ContactsPage : ContentPage
{
    public ContactsPage()
    {
        InitializeComponent();
        ContactList.ItemsSource = SampleData.Contacts;
    }
}

AppShell.xaml. The namespace pages points to the Pages folder:

XML
<?xml version="1.0" encoding="UTF-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:pages="clr-namespace:Kladde.App.Pages"
       x:Class="Kladde.App.AppShell">
    <TabBar>
        <ShellContent Title="Contacts"
                      Route="contacts"
                      ContentTemplate="{DataTemplate pages:ContactsPage}" />
    </TabBar>
</Shell>

The source generator goes into the PropertyGroup of Kladde.App.csproj:

XML
<MauiXamlInflator>SourceGen</MauiXamlInflator>

You keep the template’s AppShell.xaml.cs file as it is until exercise 2. If the build gives the warning XC0022, x:DataType is missing on the template. If the list is empty even though the data is there, the assignment of ItemsSource in the constructor is missing, after InitializeComponent. Cleaning up: when you’re done, quit the app and the emulator.

Exercise 2

Detail page and navigation. Tapping a contact opens a detail page.

  1. Create Pages/ContactDetailPage (XAML plus code-behind) that shows name, company and email address, with the name as a level 1 heading (SemanticProperties.HeadingLevel) and a “Back” button.
  2. Register the page in AppShell.xaml.cs as the route contactdetails.
  3. Turn on single selection in the list and navigate to the detail page on selection. Pass the contact as data that applies only once. Clear the selection so the item can be tapped again.
  4. Receive the data on the detail page with IQueryAttributable.

How to check: tapping a contact shows the detail page with that contact’s data, and the title bar’s back button and your button lead back to the list. Tap the same contact again: the detail page opens again. As a test, change the route in GoToAsync to a name that doesn’t exist: the navigation throws an ArgumentException. Change it back.

Solution 2

Pages/ContactDetailPage.xaml:

XML
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:Kladde.App.Models"
             x:Class="Kladde.App.Pages.ContactDetailPage"
             x:DataType="models:ContactResponse"
             Title="Contact">
    <VerticalStackLayout Padding="20" Spacing="8">
        <Label Text="{Binding Name}"
               FontSize="24"
               SemanticProperties.HeadingLevel="Level1" />
        <Label Text="{Binding Company}" />
        <Label Text="{Binding Email}" />
        <Button Text="Back" Clicked="OnBackClicked" />
    </VerticalStackLayout>
</ContentPage>

Pages/ContactDetailPage.xaml.cs. The page gets the contact from the dictionary and makes it its BindingContext:

C#
using Kladde.App.Models;

namespace Kladde.App.Pages;

public partial class ContactDetailPage : ContentPage, IQueryAttributable
{
    public ContactDetailPage()
    {
        InitializeComponent();
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("Contact", out object? value) && value is ContactResponse contact)
        {
            BindingContext = contact;
        }
    }

    private async void OnBackClicked(object? sender, EventArgs e)
    {
        await Shell.Current.GoToAsync("..");
    }
}

AppShell.xaml.cs registers the route:

C#
using Kladde.App.Pages;

namespace Kladde.App;

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute("contactdetails", typeof(ContactDetailPage));
    }
}

In ContactsPage.xaml, the CollectionView gets two attributes, SelectionMode="Single" and SelectionChanged="OnContactSelected". The handler is added in the code-behind. The method is async void because it handles an event; event handlers are the exception to the rule of avoiding async void:

C#
private async void OnContactSelected(object? sender, SelectionChangedEventArgs e)
{
    if (e.CurrentSelection.FirstOrDefault() is not ContactResponse contact)
    {
        return;
    }

    ((CollectionView)sender!).SelectedItem = null;

    var parameters = new ShellNavigationQueryParameters { { "Contact", contact } };
    await Shell.Current.GoToAsync("contactdetails", parameters);
}

The first condition catches the second call that clearing the selection triggers: then CurrentSelection is empty. If the line with SelectedItem = null is missing, the item stays highlighted after returning and no longer responds. If await is missing, the error of a wrong route can disappear instead of becoming visible. The title bar’s back button is part of the Shell and needs no code.

Exercise 3

The appointments of the day. The second page shows today’s appointments.

  1. Create Pages/AppointmentsPage. At the top, the page has a level 1 heading with today’s date, spelled out (“Monday, November 9”), and below it a CollectionView with the appointments from SampleData.TodaysAppointments.
  2. An item shows the start on the left and, below it, the end as “until 10:00”, and the title on the right. Use a Grid with ColumnDefinitions and StringFormat for that.
  3. The list sits in a Grid with the rows Auto and *, not in a VerticalStackLayout.
  4. Add the second tab “Today” to AppShell.xaml.

How to check: the second tab shows today’s date and four appointments with their times; the list scrolls when you give it very little height (shrink the window on Windows). As a test, replace the outer Grid with a VerticalStackLayout and watch what changes; then put it back.

Solution 3

Pages/AppointmentsPage.xaml:

XML
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:Kladde.App.Models"
             x:Class="Kladde.App.Pages.AppointmentsPage"
             Title="Today">
    <Grid RowDefinitions="Auto, *" Padding="12">
        <Label x:Name="DayLabel"
               FontSize="20"
               SemanticProperties.HeadingLevel="Level1" />
        <CollectionView x:Name="AppointmentList"
                        Grid.Row="1"
                        EmptyView="No appointments today.">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:AppointmentResponse">
                    <Grid Padding="0, 8" ColumnDefinitions="Auto, *" RowDefinitions="Auto, Auto" ColumnSpacing="16">
                        <Label Text="{Binding Start, StringFormat='{0:HH:mm}'}" FontAttributes="Bold" />
                        <Label Grid.Row="1" Text="{Binding End, StringFormat='until {0:HH:mm}'}" />
                        <Label Grid.Column="1" Grid.RowSpan="2" Text="{Binding Title}" VerticalOptions="Center" />
                    </Grid>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </Grid>
</ContentPage>

Pages/AppointmentsPage.xaml.cs:

C#
using System.Globalization;
using Kladde.App.Models;

namespace Kladde.App.Pages;

public partial class AppointmentsPage : ContentPage
{
    public AppointmentsPage()
    {
        InitializeComponent();
        DayLabel.Text = DateTime.Today.ToString("dddd, MMMM d", CultureInfo.GetCultureInfo("en-US"));
        AppointmentList.ItemsSource = SampleData.TodaysAppointments;
    }
}

In AppShell.xaml, a second ShellContent is added inside the TabBar:

XML
<ShellContent Title="Today"
              Route="today"
              ContentTemplate="{DataTemplate pages:AppointmentsPage}" />

The times in the list arrive as DateTimeOffset; the format HH:mm shows them on a 24-hour clock. The names of the weekdays and months depend on the culture, which is why the code names en-US explicitly. In the test with the VerticalStackLayout, the list gets as much height as its items need, and scrolling in the list is no longer reliable; that’s the trap Learn warns about.

Exercise 4

Styles, dark appearance, font and accessibility features. Make the two pages consistent and more accessible.

  1. Create Resources/Styles/KladdeStyles.xaml, with two colors for light and dark, a style CardGrid for an item’s row and a style CardTitle for the name or title. Bring the file into App.xaml.
  2. Apply both styles in the templates of both lists. The row’s background color follows the theme (AppThemeBinding).
  3. Add a ToolbarItem to the contacts page that switches between light and dark (UserAppTheme).
  4. Set the font on the device to the largest level and check both pages.
  5. If your platform has a screen reader, turn it on and have a page read to you.

How to check: in both themes, text and background are easy to read; switching via the title bar changes both pages immediately. With a large font, every name and every title stays fully visible and isn’t cut off. The screen reader reads the name or the date as a heading and then the items in order from top to bottom. While the app is running, change a color in KladdeStyles.xaml: all pages reload, which is how Hot Reload behaves with resource dictionaries.

Solution 4

Resources/Styles/KladdeStyles.xaml. The file has no class of its own (no x:Class):

XML
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">

    <Color x:Key="CardLight">#F2F2F5</Color>
    <Color x:Key="CardDark">#2A2A30</Color>

    <Style x:Key="CardGrid" TargetType="Grid">
        <Setter Property="Padding" Value="12" />
        <Setter Property="BackgroundColor"
                Value="{AppThemeBinding Light={StaticResource CardLight}, Dark={StaticResource CardDark}}" />
    </Style>

    <Style x:Key="CardTitle" TargetType="Label">
        <Setter Property="FontAttributes" Value="Bold" />
        <Setter Property="FontSize" Value="18" />
    </Style>
</ResourceDictionary>

In App.xaml, the line with Source="Resources/Styles/KladdeStyles.xaml" goes between the already existing merged dictionaries (see above). In ContactsPage.xaml, the template’s Grid gets Style="{StaticResource CardGrid}" and the name Label gets Style="{StaticResource CardTitle}"; Padding and FontAttributes, which the style sets, are dropped there. In AppointmentsPage.xaml the Grid keeps its columns and rows and gets Style="{StaticResource CardGrid}" (the Padding="0, 8" is dropped), and the label with the title gets CardTitle. For the contacts, the template then looks like this:

XML
<Grid Style="{StaticResource CardGrid}" RowDefinitions="Auto, Auto">
    <Label Text="{Binding Name}" Style="{StaticResource CardTitle}" />
    <Label Grid.Row="1" Text="{Binding Company}" />
</Grid>

The switcher. In ContactsPage.xaml, between ContentPage and CollectionView:

XML
<ContentPage.ToolbarItems>
    <ToolbarItem Text="Light or dark" Clicked="OnThemeClicked" />
</ContentPage.ToolbarItems>

In the page’s code-behind:

C#
private void OnThemeClicked(object? sender, EventArgs e)
{
    if (Application.Current is { } app)
    {
        app.UserAppTheme = app.UserAppTheme == AppTheme.Dark ? AppTheme.Light : AppTheme.Dark;
    }
}

On the first click, UserAppTheme is still Unspecified, so the branch gives Dark. If you want to go back to the system, set AppTheme.Unspecified. This switcher is a tool for checking; the finished Kladde app follows the system.

Font: on Android you find the setting in the device’s or emulator’s settings under Display; on Windows under Accessibility, Text size. The labels depend on the version. The font sizes in the solution are in FontSize; they scale along because FontAutoScalingEnabled stays true. Because the rows are Auto, the items grow along. If a row had HeightRequest="40", the text would be cut off at a large font.

Screen reader: on Windows it’s Narrator, on Android TalkBack, provided the device or image has it; without it, this step can’t be checked, and you record it as open. A heading is announced by the reader because HeadingLevel is set. A Description on a Label would be a mistake (see the Accessibility section).

The Kladde: contacts and appointments in the app

After this lesson, Kladde.App has two pages in a shell: the contacts with a detail page and the appointments of the day, with fixed sample data from SampleData, styled with a resource dictionary of its own, in light and dark theme, with headings for the screen reader and a font that scales along. The two records ContactResponse and AppointmentResponse have the names and fields of the web API’s responses from K2, so that W2.2.2 can fill them without a rebuild. The pages still work with code-behind: the constructor assigns the data, the handler navigates. In W2.2.1 you pull the logic out of the pages and rebuild the contact list, detail page and appointments on view models that you test without a device.

What you can do now

  • You read XAML: root element, x:Class, the two namespaces, properties, markup extensions and the role of InitializeComponent.
  • You build pages with Grid, VerticalStackLayout and HorizontalStackLayout and tell Padding, Margin and Spacing apart.
  • You show lists with CollectionView, with a template, x:DataType, an empty view and selection, and no longer use ListView.
  • You navigate with Shell: tabs, routes, RegisterRoute, GoToAsync with await, one-time data and IQueryAttributable.
  • You collect design in styles and resources, support light and dark appearance, and use SemanticProperties and a font that scales along.

Read more