MOO WebTech
.NET MAUItheory-practiceintermediate

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.

80 min08.05.2026L14

🎯Learning Objectives

  • Arrange controls using VerticalStackLayout, HorizontalStackLayout, and Grid
  • Use the core input controls: Label, Entry, Editor, Button, Image
  • Read user input from an Entry in 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 (ContentPage is 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                ← view

A 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 needs
  • 100 — exactly 100 device-independent pixels

3. Common Controls

ControlPurposeKey properties
LabelDisplay text (read-only to the user)Text, FontSize, TextColor, HorizontalOptions
EntrySingle-line text inputText, Placeholder, Keyboard, IsPassword
EditorMulti-line text inputText, Placeholder, AutoSize
ButtonClickable buttonText, Clicked, IsEnabled
ImageDisplay an image from Resources/ImagesSource, HeightRequest, Aspect
CheckBoxBoolean toggleIsChecked, CheckedChanged
DatePickerDate pickerDate, 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:

  • HorizontalOptionsStart, 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

Task 1BMI calculator
EASY — IN CLASS

Build a single page that calculates Body Mass Index:

  • Two Entry controls: WeightEntry (kg) and HeightEntry (cm). Both use Keyboard="Numeric".
  • A Button labeled "Calculate".
  • A Label below 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
Use 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; }.
Task 2Two-page welcome app
MEDIUM — HOMEWORK

Based on Example B:

  1. On MainPage, add an Entry for the user's name and a Button "Continue"
  2. Add a DetailsPage that greets the user and shows the length of their name (e.g. "Hello, Alice! Your name is 5 letters long.")
  3. On DetailsPage add a "Back" button
  4. Extra: if the user taps "Continue" with an empty name, show the Label "Please enter your name" on MainPage instead of navigating
💡 Hint
For the empty-name check, do it in the click handler before calling GoToAsync: if (string.IsNullOrWhiteSpace(NameEntry.Text)) { ErrorLabel.Text = "Please enter your name"; return; }.
Task 3Unit converter
HARD — HOMEWORK

Build a temperature converter with a clean layout:

  • Two Entry fields: CelsiusEntry and FahrenheitEntry
  • 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
Keep both conversions symmetric. Write two small private methods 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 Entry with Keyboard="Numeric", Editor, Image, and DatePicker. Quick — just familiarity.
  • [10 min] Live code: reading Entry values in code-behind. Include the ?? and TryParse pattern. 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, forgetting Keyboard="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 Grid over VerticalStackLayout? When would you pick the opposite?"
  • "What's the trade-off of putting all logic in MainPage.xaml.cs as we did today?"
  • "Why does GoToAsync have to be asynchronous — what could it be waiting for?"