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:
<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>
namespace Kladde.App.Pages;
public partial class ContactsPage : ContentPage
{
public ContactsPage()
{
InitializeComponent();
}
}
What’s there can be explained in a few points:
x:Classnames the full name of a .NET class. The XAML file thereby defines the classContactsPage, which derives fromContentPage; that’s why the C# class ispartial. Everything else in the file is objects of existing classes that get created and configured.x:Classmay 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
ContentPageorLabel. The second has the prefixxand applies to things that belong to XAML itself, such asx:Class,x:Nameandx:DataType. The addresses aren’t web pages; there’s nothing behind them. They serve as a version identifier. InitializeComponentcreates 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 writemodels: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):
<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:
<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.
<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:
<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
CollectionViewthrows an exception if itsItemsSourceis 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.”:
<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:
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:
<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.
<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:
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:
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 theTargetType, but not to derived types. WithApplyToDerivedTypes="True"it applies to them as well. - Explicit style: with
x:Key. It applies only to elements that use it withStyle="{StaticResource Key}". - Inheritance:
BasedOnbuilds 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:
<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:
<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 |
<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.
- In
src/Kladde.App, create the foldersModelsandPages. DeleteMainPage.xamlandMainPage.xaml.cs. - In
Models, create the classesContactResponse,AppointmentResponseandSampleData(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. - In
Pages, create the pageContactsPage(XAML plus code-behind) with aCollectionViewthat shows the contacts, with name and company per item and the empty view “No contacts yet.”. - Change
AppShell.xamlso that it has aTabBarwith the contacts page. The second page follows in exercise 3. - Turn on the source generator (
MauiXamlInflator) and build: noXC0022warning. - 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):
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:
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 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:
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 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:
<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.
- 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. - Register the page in
AppShell.xaml.csas the routecontactdetails. - 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.
- 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 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:
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:
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:
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.
- 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 aCollectionViewwith the appointments fromSampleData.TodaysAppointments. - 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
GridwithColumnDefinitionsandStringFormatfor that. - The list sits in a
Gridwith the rowsAutoand*, not in aVerticalStackLayout. - 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 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:
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:
<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.
- Create
Resources/Styles/KladdeStyles.xaml, with two colors for light and dark, a styleCardGridfor an item’s row and a styleCardTitlefor the name or title. Bring the file intoApp.xaml. - Apply both styles in the templates of both lists. The row’s background color follows the theme (
AppThemeBinding). - Add a
ToolbarItemto the contacts page that switches between light and dark (UserAppTheme). - Set the font on the device to the largest level and check both pages.
- 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 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:
<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:
<ContentPage.ToolbarItems>
<ToolbarItem Text="Light or dark" Clicked="OnThemeClicked" />
</ContentPage.ToolbarItems>
In the page’s code-behind:
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 ofInitializeComponent. - You build pages with
Grid,VerticalStackLayoutandHorizontalStackLayoutand tellPadding,MarginandSpacingapart. - You show lists with
CollectionView, with a template,x:DataType, an empty view and selection, and no longer useListView. - You navigate with Shell: tabs, routes,
RegisterRoute,GoToAsyncwithawait, one-time data andIQueryAttributable. - You collect design in styles and resources, support light and dark appearance, and use
SemanticPropertiesand a font that scales along.
Read more
- Get started with XAML: structure of a XAML file, namespaces, converters.
- What’s new in .NET MAUI for .NET 10: source generator, global namespaces, deprecations.
- Grid: rows, columns, size kinds.
- StackLayout: stack layouts, spacing and alignment.
- CollectionView: data, templates, selection and empty view.
- Compiled bindings:
x:DataTypeand the warnings XC0022 to XC0025. - .NET MAUI Shell navigation: routes,
GoToAsync, passing data. - .NET MAUI Shell tabs: tabs and their appearance.
- Style apps using XAML: implicit and explicit styles, inheritance.
- Respond to system theme changes:
AppThemeBindingandUserAppTheme. - Build accessible apps with semantic properties: description, hint and headings.
- Fonts in .NET MAUI: font sizes and automatic scaling.