skills/davidortinau/maui-skills/maui-shell-navigation

maui-shell-navigation

SKILL.md

.NET MAUI Shell Navigation

Shell Visual Hierarchy

Shell uses a four-level hierarchy. Each level wraps the one below it:

Shell
 ├── FlyoutItem / TabBar          (top-level navigation grouping)
 │    ├── Tab                     (bottom-tab grouping)
 │    │    ├── ShellContent        (page slot; points to a ContentPage)
 │    │    └── ShellContent        (creates top tabs within a bottom tab)
 │    └── Tab
 └── FlyoutItem / TabBar
  • FlyoutItem – appears in the flyout menu. Contains one or more Tab children.
  • TabBar – bottom tab bar with no flyout entry. Use when the app has no flyout.
  • Tab – groups ShellContent objects. Multiple ShellContent in one Tab produces top tabs.
  • ShellContent – each represents a ContentPage.

Implicit Conversion

You can omit intermediate wrappers. Shell auto-wraps:

You write Shell creates
ShellContent only FlyoutItem > Tab > ShellContent
Tab only FlyoutItem > Tab
ShellContent in TabBar TabBar > Tab > ShellContent

This keeps simple apps concise while allowing full control when needed.

AppShell.xaml Setup

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MyApp.Views"
       x:Class="MyApp.AppShell"
       FlyoutBehavior="Flyout">

    <FlyoutItem Title="Animals" Icon="animals.png">
        <Tab Title="Cats">
            <ShellContent Title="Domestic"
                          ContentTemplate="{DataTemplate views:DomesticCatsPage}" />
            <ShellContent Title="Wild"
                          ContentTemplate="{DataTemplate views:WildCatsPage}" />
        </Tab>
        <Tab Title="Dogs" Icon="dogs.png">
            <ShellContent ContentTemplate="{DataTemplate views:DogsPage}" />
        </Tab>
    </FlyoutItem>

    <TabBar>
        <ShellContent Title="Home" Icon="home.png"
                      ContentTemplate="{DataTemplate views:HomePage}" />
        <ShellContent Title="Settings" Icon="settings.png"
                      ContentTemplate="{DataTemplate views:SettingsPage}" />
    </TabBar>
</Shell>

ContentTemplate and Lazy Loading

Always use ContentTemplate with DataTemplate so pages are created on demand. Using Content directly creates all pages during Shell init, hurting startup time.

Tab Configuration

Bottom Tabs

Multiple ShellContent (or Tab) children inside a TabBar or FlyoutItem produce bottom tabs.

Top Tabs

Multiple ShellContent children inside a single Tab produce top tabs within that bottom tab:

<Tab Title="Photos">
    <ShellContent Title="Recent"  ContentTemplate="{DataTemplate views:RecentPage}" />
    <ShellContent Title="Favorites" ContentTemplate="{DataTemplate views:FavoritesPage}" />
</Tab>

TabBar Appearance (Attached Properties)

Set these on any page or Shell element:

Attached Property Type Purpose
Shell.TabBarBackgroundColor Color Tab bar background
Shell.TabBarForegroundColor Color Foreground / selected icon color
Shell.TabBarTitleColor Color Selected tab title color
Shell.TabBarUnselectedColor Color Unselected tab icon/title color
Shell.TabBarDisabledColor Color Disabled tab color
Shell.TabBarIsVisible bool Show/hide the tab bar
<ContentPage Shell.TabBarIsVisible="False" ... />

Flyout Configuration

FlyoutBehavior

Set on Shell:

<Shell FlyoutBehavior="Flyout"> ... </Shell>

Values: Disabled, Flyout, Locked.

FlyoutDisplayOptions

Controls how a FlyoutItem's children appear in the flyout:

<FlyoutItem Title="Animals" FlyoutDisplayOptions="AsMultipleItems">
    <Tab Title="Cats" ... />
    <Tab Title="Dogs" ... />
</FlyoutItem>
  • AsSingleItem (default) – one flyout entry for the group.
  • AsMultipleItems – each child Tab gets its own flyout entry.

Flyout Item Template

Customize appearance with Shell.ItemTemplate. BindingContext exposes Title and FlyoutIcon (FlyoutItem) or Text and IconImageSource (MenuItem):

<Shell.ItemTemplate>
    <DataTemplate>
        <Grid ColumnDefinitions="Auto,*" Padding="10">
            <Image Source="{Binding FlyoutIcon}" HeightRequest="24" />
            <Label Grid.Column="1" Text="{Binding Title}" VerticalTextAlignment="Center" />
        </Grid>
    </DataTemplate>
</Shell.ItemTemplate>

Replacing Flyout Content

<Shell.FlyoutContent>
    <CollectionView BindingContext="{x:Reference shell}"
                    ItemsSource="{Binding FlyoutItems}" />
</Shell.FlyoutContent>

MenuItem (non-navigation flyout entries)

<MenuItem Text="Log Out"
          Command="{Binding LogOutCommand}"
          IconImageSource="logout.png" />

Route Registration

Shell visual hierarchy items have implicit routes derived from their Route property (or type name). Detail pages not in the hierarchy must be registered:

// In AppShell constructor or MauiProgram
Routing.RegisterRoute("animaldetails", typeof(AnimalDetailsPage));
Routing.RegisterRoute("editanimal", typeof(EditAnimalPage));

Gotcha: Duplicate route names throw ArgumentException at registration time. Every route must be unique across the entire app.

Navigation with GoToAsync

All programmatic navigation goes through Shell.Current.GoToAsync:

// Absolute – navigate to a specific place in the hierarchy
await Shell.Current.GoToAsync("//animals/cats/domestic");

// Relative – push a registered page onto the navigation stack
await Shell.Current.GoToAsync("animaldetails");

// With query string
await Shell.Current.GoToAsync($"animaldetails?id={animal.Id}");

Absolute vs Relative Routes

Prefix Meaning
// Absolute route from Shell root
(none) Relative; pushes onto the current nav stack
.. Go back one level in the navigation stack
../ Go back then navigate forward
// Go back one page
await Shell.Current.GoToAsync("..");

// Go back two pages
await Shell.Current.GoToAsync("../..");

// Go back one page, then navigate to edit
await Shell.Current.GoToAsync("../editanimal");

Gotcha: Relative routes work only for pages registered with Routing.RegisterRoute. You cannot push visual-hierarchy pages as relative routes.

Query Parameters

QueryProperty Attribute

[QueryProperty(nameof(AnimalId), "id")]
public partial class AnimalDetailsPage : ContentPage
{
    public string AnimalId { get; set; }
}

// Navigate with query string:
await Shell.Current.GoToAsync($"animaldetails?id={animal.Id}");

IQueryAttributable Interface

Preferred for ViewModels — gives you all parameters in one call:

public class AnimalDetailsViewModel : ObservableObject, IQueryAttributable
{
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var id))
            AnimalId = id.ToString();
    }
}

The interface works on the page itself or on any object set as the page's BindingContext.

Passing Complex Objects

Use ShellNavigationQueryParameters (dictionary of stringobject) to pass objects without serializing to strings:

var parameters = new ShellNavigationQueryParameters
{
    { "animal", selectedAnimal }  // pass the object directly
};
await Shell.Current.GoToAsync("animaldetails", parameters);

Receive via IQueryAttributable:

public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    Animal = query["animal"] as Animal;
}

Navigation Events

Override in your AppShell:

protected override void OnNavigating(ShellNavigatingEventArgs args)
{
    base.OnNavigating(args);
    if (hasUnsavedChanges && args.Source == ShellNavigationSource.Pop)
        args.Cancel();  // prevent leaving
}

protected override void OnNavigated(ShellNavigatedEventArgs args)
{
    base.OnNavigated(args);
    // args.Current, args.Previous, args.Source
}

For async checks, use args.GetDeferral() → do work → deferral.Complete().

ShellNavigationSource values: Push, Pop, PopToRoot, Insert, Remove, ShellItemChanged, ShellSectionChanged, ShellContentChanged, Unknown.

Inspecting Navigation State

// Current URI location
ShellNavigationState state = Shell.Current.CurrentState;
string location = state.Location.ToString();  // e.g. "//animals/cats/domestic"

// Current page
Page page = Shell.Current.CurrentPage;

// Navigation stack of the current tab
IReadOnlyList<Page> stack = Shell.Current.Navigation.NavigationStack;

Back Button Behavior

Customize the back button per page:

<Shell.BackButtonBehavior>
    <BackButtonBehavior Command="{Binding BackCommand}"
                       IconOverride="back_arrow.png"
                       TextOverride="Cancel" />
</Shell.BackButtonBehavior>

Properties: Command, CommandParameter, IconOverride, TextOverride, IsVisible, IsEnabled.

Common Gotchas

  1. Duplicate route namesRouting.RegisterRoute throws ArgumentException if a route name is already registered or matches a visual hierarchy route.
  2. Relative routes require registration – you cannot GoToAsync("somepage") unless somepage was registered with Routing.RegisterRoute. Visual hierarchy pages use absolute // routes.
  3. Pages are created on demand – when using ContentTemplate, the page constructor runs only on first navigation. Don't assume pages exist at startup.
  4. Tab.Stack is read-only – you cannot manipulate the navigation stack directly; use GoToAsync for all navigation changes.
  5. GoToAsync is async – always await it. Fire-and-forget navigation causes race conditions and can silently fail.
  6. Route hierarchy matters – absolute routes must match the full path through the visual hierarchy (//FlyoutItem/Tab/ShellContent).
Weekly Installs
13
GitHub Stars
71
First Seen
Feb 17, 2026
Installed on
opencode13
gemini-cli13
github-copilot13
codex13
kimi-cli13
amp13