L14 — .NET MAUI pt.2: UI & Navigation
We build real screens. Layouts (VerticalStackLayout, Grid), core controls (Label, Entry, Button, Image), and how to move between pages using Shell navigation.
🎯Learning Objectives
- Arrange controls using
VerticalStackLayout,HorizontalStackLayout, andGrid - Use the core input controls:
Label,Entry,Editor,Button,Image - Read user input from an
Entryin code-behind - Add a second page and navigate to it with MAUI Shell
- Pass simple data between pages via a constructor parameter
📖Theory
1. How MAUI Thinks About a Page
Every MAUI page is a tree of elements:
- The root is a Page (
ContentPageis the most common one). - Inside the page goes one root Layout — a container that decides where its children appear.
- Inside the layout go Views — the actual visible controls (labels, buttons, entries, images).
Code
ContentPage
└── VerticalStackLayout ← layout
├── Label ← view
├── Entry ← view
└── Button ← viewA page can only have one child — the root layout. That's why you almost always wrap everything in a layout.
2. Layouts — Where Things Go
VerticalStackLayout — stacks children top to bottom.
XML
<VerticalStackLayout Spacing="10" Padding="20">
<Label Text="Name:" />
<Entry Placeholder="Enter your name" />
<Button Text="Save" />
</VerticalStackLayout>HorizontalStackLayout — same idea, but left to right.
XML
<HorizontalStackLayout Spacing="5">
<Button Text="OK" />
<Button Text="Cancel" />
</HorizontalStackLayout>Grid — places children into rows and columns. The most powerful and the most common layout in production MAUI apps.
XML
<Grid RowDefinitions="Auto,*,Auto"
ColumnDefinitions="*,*"
RowSpacing="10"
ColumnSpacing="10"
Padding="20">
<Label Text="Login"
Grid.Row="0" Grid.ColumnSpan="2"
FontSize="24" />
<Entry Placeholder="Username"
Grid.Row="1" Grid.Column="0" />
<Entry Placeholder="Password"
Grid.Row="1" Grid.Column="1"
IsPassword="True" />
<Button Text="Sign in"
Grid.Row="2" Grid.ColumnSpan="2" />
</Grid>Row/column sizing grammar:
*— take remaining space (proportional)Auto— only as tall/wide as the child needs100— exactly 100 device-independent pixels
3. Common Controls
| Control | Purpose | Key properties |
|---|---|---|
Label | Display text (read-only to the user) | Text, FontSize, TextColor, HorizontalOptions |
Entry | Single-line text input | Text, Placeholder, Keyboard, IsPassword |
Editor | Multi-line text input | Text, Placeholder, AutoSize |
Button | Clickable button | Text, Clicked, IsEnabled |
Image | Display an image from Resources/Images | Source, HeightRequest, Aspect |
CheckBox | Boolean toggle | IsChecked, CheckedChanged |
DatePicker | Date picker | Date, DateSelected |
XML
<VerticalStackLayout Spacing="10" Padding="20">
<Image Source="dotnet_bot.png" HeightRequest="120" />
<Entry x:Name="NameEntry" Placeholder="Your name" />
<Editor x:Name="BioEditor" Placeholder="About you" HeightRequest="100" />
<Button Text="Save" Clicked="OnSaveClicked" />
<Label x:Name="StatusLabel" />
</VerticalStackLayout>Code-behind reading the entry:
C#
private void OnSaveClicked(object sender, EventArgs e)
{
string name = NameEntry.Text ?? "";
string bio = BioEditor.Text ?? "";
StatusLabel.Text = $"Saved: {name} — {bio.Length} chars in bio";
}Note: Text on a fresh Entry is null, not "". The ?? "" operator replaces null with an empty string so later code doesn't crash. You saw ?? briefly in L04.
4. Alignment and Sizing — HorizontalOptions and VerticalOptions
Every view has two layout options:
HorizontalOptions—Start,Center,End,Fill(default depends on parent)VerticalOptions— same values, for the vertical axis
XML
<Label Text="Centered title"
FontSize="24"
HorizontalOptions="Center"
VerticalOptions="Start" />Sizing:
WidthRequest/HeightRequest— ask for an exact size (in device-independent pixels)- Leave both unset and the view takes its natural size
5. Adding a Second Page
Right-click your project → Add → New Item → .NET MAUI → .NET MAUI ContentPage (XAML). Name it DetailsPage.xaml. Visual Studio creates the .xaml + .xaml.cs pair, just like MainPage.
The new page is almost empty:
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"
x:Class="HelloMaui.DetailsPage"
Title="Details">
<VerticalStackLayout Padding="20" Spacing="10">
<Label x:Name="GreetingLabel" FontSize="24" />
</VerticalStackLayout>
</ContentPage>6. Shell Navigation
MAUI uses a navigation container called Shell. In a fresh project it lives in AppShell.xaml. It already registers MainPage as a route. We just need to register DetailsPage.
Step 1 — open AppShell.xaml.cs and register the route:
C#
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(DetailsPage), typeof(DetailsPage));
}Step 2 — navigate from MainPage when a button is clicked:
C#
private async void OnGoToDetailsClicked(object sender, EventArgs e)
{
await Shell.Current.GoToAsync(nameof(DetailsPage));
}async/await is new — for now just remember: navigation is async, and the method handling the click must be marked async.
Step 3 — the back button appears automatically in the top bar. Users can also swipe back on Android.
7. Passing Data Between Pages
The cleanest way to pass data is through the destination page's constructor.
In DetailsPage.xaml.cs:
C#
public partial class DetailsPage : ContentPage
{
public DetailsPage(string userName)
{
InitializeComponent();
GreetingLabel.Text = $"Hello, {userName}!";
}
}But Shell navigation creates pages by type, so it needs a parameterless constructor. To pass data, we use query parameters on the navigation URL:
C#
// On MainPage
private async void OnGoToDetailsClicked(object sender, EventArgs e)
{
string name = NameEntry.Text ?? "friend";
await Shell.Current.GoToAsync($"{nameof(DetailsPage)}?name={Uri.EscapeDataString(name)}");
}C#
// On DetailsPage — receive the parameter
[QueryProperty(nameof(UserName), "name")]
public partial class DetailsPage : ContentPage
{
public string UserName
{
set => GreetingLabel.Text = $"Hello, {value}!";
}
public DetailsPage() { InitializeComponent(); }
}The [QueryProperty] attribute tells Shell: "when the URL has ?name=..., put that value into the UserName property." The setter updates the label.
We'll cover a cleaner way to pass complex data (objects) in L15 using data binding.
💻Code Examples
Example A — Simple form with Grid
XML
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="HelloMaui.MainPage"
Title="Profile">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
ColumnDefinitions="120,*"
RowSpacing="10"
ColumnSpacing="10"
Padding="20">
<Label Text="Name:" Grid.Row="0" Grid.Column="0" VerticalOptions="Center"/>
<Entry x:Name="NameEntry" Grid.Row="0" Grid.Column="1"/>
<Label Text="Age:" Grid.Row="1" Grid.Column="0" VerticalOptions="Center"/>
<Entry x:Name="AgeEntry" Grid.Row="1" Grid.Column="1" Keyboard="Numeric"/>
<Label Text="Bio:" Grid.Row="2" Grid.Column="0" VerticalOptions="Start"/>
<Editor x:Name="BioEditor" Grid.Row="2" Grid.Column="1" HeightRequest="100"/>
<Button Text="Submit"
Grid.Row="3" Grid.ColumnSpan="2"
Clicked="OnSubmitClicked"/>
<Label x:Name="ResultLabel"
Grid.Row="4" Grid.ColumnSpan="2"
FontSize="16"/>
</Grid>
</ContentPage>C#
private void OnSubmitClicked(object sender, EventArgs e)
{
string name = NameEntry.Text ?? "";
string ageText = AgeEntry.Text ?? "0";
string bio = BioEditor.Text ?? "";
if (!int.TryParse(ageText, out int age))
{
ResultLabel.Text = "Age must be a number.";
return;
}
ResultLabel.Text = $"Saved: {name}, {age}y. Bio length: {bio.Length}";
}Example B — Two-page navigation
MainPage.xaml:
XML
<VerticalStackLayout Padding="20" Spacing="10">
<Entry x:Name="NameEntry" Placeholder="Your name" />
<Button Text="Say hello" Clicked="OnSayHelloClicked" />
</VerticalStackLayout>MainPage.xaml.cs:
C#
private async void OnSayHelloClicked(object sender, EventArgs e)
{
string name = NameEntry.Text ?? "friend";
await Shell.Current.GoToAsync($"{nameof(DetailsPage)}?name={Uri.EscapeDataString(name)}");
}DetailsPage.xaml:
XML
<VerticalStackLayout Padding="20" Spacing="10">
<Label x:Name="GreetingLabel" FontSize="28" HorizontalOptions="Center"/>
<Button Text="Back" Clicked="OnBackClicked"/>
</VerticalStackLayout>DetailsPage.xaml.cs:
C#
[QueryProperty(nameof(UserName), "name")]
public partial class DetailsPage : ContentPage
{
public string UserName
{
set => GreetingLabel.Text = $"Hello, {value}!";
}
public DetailsPage() { InitializeComponent(); }
private async void OnBackClicked(object sender, EventArgs e)
{
await Shell.Current.GoToAsync(".."); // ".." goes back one step
}
}Don't forget to register the route in AppShell.xaml.cs:
C#
Routing.RegisterRoute(nameof(DetailsPage), typeof(DetailsPage));✏️Practice Tasks
Build a single page that calculates Body Mass Index:
- Two
Entrycontrols:WeightEntry(kg) andHeightEntry(cm). Both useKeyboard="Numeric". - A
Buttonlabeled "Calculate". - A
Labelbelow that shows the BMI with two decimals, or"Please enter valid numbers"on parse failure.
Formula: bmi = weight / (heightMeters * heightMeters), where heightMeters = heightCm / 100.0.
Use a Grid for the layout.
💡 Hint
double.TryParse, not double.Parse, to avoid a crash when the user enters letters. The out pattern: if (!double.TryParse(WeightEntry.Text, out double weight)) { ... return; }.Based on Example B:
- On
MainPage, add anEntryfor the user's name and aButton"Continue" - Add a
DetailsPagethat greets the user and shows the length of their name (e.g."Hello, Alice! Your name is 5 letters long.") - On
DetailsPageadd a "Back" button - Extra: if the user taps "Continue" with an empty name, show the
Label"Please enter your name"onMainPageinstead of navigating
💡 Hint
GoToAsync: if (string.IsNullOrWhiteSpace(NameEntry.Text)) { ErrorLabel.Text = "Please enter your name"; return; }.Build a temperature converter with a clean layout:
- Two
Entryfields:CelsiusEntryandFahrenheitEntry - Two
Buttons: "C → F" and "F → C" - Each button reads from one entry, converts, and writes into the other
- If the source entry is empty or non-numeric, put the text
"—"into the destination
Formulas: F = C * 9 / 5 + 32, C = (F - 32) * 5 / 9.
Use a Grid with 3 columns: label | entry | button.
💡 Hint
double? TryReadCelsius() and double? TryReadFahrenheit() that return null on parse failure — then the button handlers stay short: if null, set "—"; otherwise compute and write.⚠️Common Mistakes
Wrapping multiple views directly inside ContentPage
A page can only have one child. Writing <ContentPage><Label/><Button/></ContentPage> is a compile error. Always put a layout between the page and your controls.
Using StackLayout in new code
Older tutorials use <StackLayout>. In MAUI it's been replaced by VerticalStackLayout and HorizontalStackLayout, which are faster. Prefer the newer names.
Forgetting x:Name on controls you read in code-behind
Without x:Name, the control doesn't exist as a C# field and you can't reference it from the code-behind. Add x:Name="MyLabel" and Visual Studio generates the field for you when you rebuild.
Not registering the route in AppShell
If you write await Shell.Current.GoToAsync(nameof(DetailsPage)) without Routing.RegisterRoute(...), you get a runtime error saying "route not found". Register every non-Shell page you want to navigate to.
Forgetting async on the handler
GoToAsync returns a Task. Calling it without await silently swallows errors. The handler must be marked async, and you must await the call.
🎓Instructor Notes
⚡ How to run this lesson (~80 min)
- [5 min] Recap L13 + where we are going. The counter app is boring. Today we build a real form and a two-page app.
- [15 min] Live code: layouts. Start with VerticalStackLayout, add a Label + Entry + Button. Then rebuild the same form with a Grid, side-by-side on the projector. Show why Grid wins for alignment.
- [10 min] Common controls tour. Demo
EntrywithKeyboard="Numeric",Editor,Image, andDatePicker. Quick — just familiarity. - [10 min] Live code: reading Entry values in code-behind. Include the
??andTryParsepattern. Emphasise: the Text property is nullable. - [15 min] Live code: second page and Shell navigation. Register the route, navigate with
GoToAsync, pass the name via query parameter. Students often struggle here — go slow. - [20 min] Task 1 in class. Watch for: empty layout (ContentPage with >1 child), missing
x:Name, forgettingKeyboard="Numeric". - [5 min] Wrap up + assign homework. Preview L15: stop putting code in the code-behind, start using MVVM and data binding. Assign Tasks 2 and 3.
💬 Discussion questions
- "When would you pick
GridoverVerticalStackLayout? When would you pick the opposite?" - "What's the trade-off of putting all logic in
MainPage.xaml.csas we did today?" - "Why does
GoToAsynchave to be asynchronous — what could it be waiting for?"