.NET MAUI Shell -navigointi: täydellinen opas välilehtiin, flyout-valikkoon ja deep linkingiin

.NET MAUI Shell -navigointi yhdistää välilehdet, flyout-valikon ja URI-pohjaiset reitit. Käytännön opas tuotantokelpoisiin Shell-sovelluksiin .NET MAUI 9 -versiossa.

.NET MAUI Shell -opas 2026

Päivitetty: 12. kesäkuuta 2026

.NET MAUI Shell on yhden tiedoston navigointimalli, joka kokoaa sovelluksen koko rakenteen (välilehdet, flyout-valikon, reitit ja URI-pohjaisen navigoinnin) yhden AppShell.xaml-tiedoston alle. Tämä opas näyttää, miten rakennat tuotantokelpoisen Shell-pohjaisen .NET MAUI 9 -sovelluksen vuonna 2026: TabBar- ja Flyout-rakenteet, reittien rekisteröinti, parametrien välitys IQueryAttributable-rajapinnalla, deep linking sekä yleisimmät virhetilanteet ratkaisuineen.

Olen rakentanut viimeisen kahden vuoden aikana kolme tuotantosovellusta Shell-rakenteella, ja honestly, ensimmäinen niistä oli pieni katastrofi. Sekoitin absoluuttiset ja suhteelliset reitit ja vietin yhden iltapäivän miettien, miksi takaisin-painike vei käyttäjän aina väärälle välilehdelle. Tässä oppaassa käyn läpi ne kompastuskivet, joista olisin halunnut kuulla etukäteen.

  • Shell yhdistää välilehdet, flyout-valikon ja reitityksen yhteen XAML-tiedostoon, ja vähentää navigointikoodin määrää 60–80 % verrattuna NavigationPage-pohjaiseen ratkaisuun.
  • URI-pohjainen navigointi Shell.Current.GoToAsync("//main/details?id=42") tukee sekä absoluuttisia että suhteellisia polkuja ja toimii myös deep link -lähteenä.
  • Parametrit välitetään IQueryAttributable-rajapinnan kautta tai [QueryProperty]-attribuutilla. Tietokohtaiset objektit kannattaa lähettää sanakirjana, eivät JSON-merkkijonoina.
  • Modaali- ja työntövaiheen valinta tehdään Routing.RegisterRoute-kutsussa eikä navigointihetkellä. Tämä on yleisin sudenkuoppa migrointivaiheessa.
  • Deep linking iOS- ja Android-puolella vaatii AppLinks-konfiguraation ja Shell.Current.GoToAsync-kutsun käsittelyn OnAppLinkRequestReceived-metodissa.
  • .NET MAUI 9 -version Shell tukee nyt sisäänrakennettua BackButtonBehavior-yliajoa ja parannettua Flyout-renderöintiä Androidilla.

Mikä .NET MAUI Shell on ja milloin sitä kannattaa käyttää?

.NET MAUI Shell on Microsoftin tarjoama korkean tason navigointiabstraktio, joka korvaa perinteisen NavigationPage- ja TabbedPage-yhdistelmän yhdellä deklaratiivisella mallilla. Shell vastaa sovelluksen visuaalisesta rakenteesta (flyout-valikosta, alapalkin välilehdistä ja sivujen pinoamisesta) sekä URI-pohjaisesta navigoinnista, joka mahdollistaa deep linking -toiminnallisuuden ilman erillistä reititysmoottoria.

Käytä Shelliä, kun:

  • Sovelluksessa on 2–5 päänäkymää, jotka kuuluvat luonnollisesti välilehtien tai flyout-valikon alle.
  • Tarvitset deep linkingia (esim. push-ilmoituksesta tiettyyn sivuun).
  • Haluat keskittää navigointilogiikan yhteen tiedostoon sen sijaan, että käsittelet pinoja manuaalisesti.
  • Sovellus käyttää MVVM-mallia. Shell tukee sitä natiivisti, kuten näytimme aiemmassa .NET MAUI REST API- ja MVVM-oppaassa.

Älä käytä Shelliä, jos sovelluksesi on yhden ruudun työkalu (esim. laskin), tai jos tarvitset täysin mukautetun navigointianimaation, jota Shellin elinkaari ei salli. Tällöin NavigationPage tai oma INavigation-toteutus on parempi valinta. Lisää tietoa virallisesta Microsoftin .NET MAUI Shell -dokumentaatiosta.

Shell-perusrakenne: AppShell.xaml

Jokainen Shell-pohjainen sovellus alkaa AppShell.xaml-tiedostosta. Tämä on juurielementti, joka määrittää sovelluksen rakenteen, eli välilehdet, flyout-kohteet ja niihin liittyvät sivut. Alla on minimaalinen mutta tuotantokäyttöön sopiva runko, joka käyttää sekä TabBar- että Flyout-rakennetta:

<?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:MyApp.Views"
       x:Class="MyApp.AppShell"
       Title="MyApp"
       FlyoutBehavior="Flyout">

    <!-- Flyout-otsake -->
    <Shell.FlyoutHeader>
        <Grid HeightRequest="120" BackgroundColor="{StaticResource Primary}">
            <Label Text="MyApp" TextColor="White"
                   FontSize="24" VerticalOptions="Center"
                   HorizontalOptions="Center" />
        </Grid>
    </Shell.FlyoutHeader>

    <!-- Alavälilehdet -->
    <TabBar>
        <ShellContent Title="Etusivu" Icon="home.png"
                      ContentTemplate="{DataTemplate pages:HomePage}"
                      Route="home" />
        <ShellContent Title="Haku" Icon="search.png"
                      ContentTemplate="{DataTemplate pages:SearchPage}"
                      Route="search" />
        <ShellContent Title="Profiili" Icon="user.png"
                      ContentTemplate="{DataTemplate pages:ProfilePage}"
                      Route="profile" />
    </TabBar>

    <!-- Flyout-valikon erilliset kohteet -->
    <FlyoutItem Title="Asetukset" Icon="settings.png">
        <ShellContent ContentTemplate="{DataTemplate pages:SettingsPage}"
                      Route="settings" />
    </FlyoutItem>
</Shell>

Huomaa kolme avainasiaa. FlyoutBehavior="Flyout" aktivoi sivuvalikon, TabBar rakentaa alapalkin välilehdet, ja jokainen Route-attribuutti määrittää URI-fragmentin, jota käytetään myöhemmin GoToAsync-kutsuissa. Jos jätät Route-arvon pois, Shell luo sen automaattisesti tyypin nimestä, mutta älä luota tähän tuotannossa. Refaktorointi rikkoo navigoinnin hiljaisesti.

TabBar ja välilehtinavigointi käytännössä

TabBar määrittää sovelluksen alapalkin välilehdet, jotka ovat aina näkyvissä. Toisin kuin FlyoutItem, TabBar ei näy flyout-valikossa. Se on erillinen rakenne, joka käytännössä piilottaa flyoutin sen alaisilta sivuilta. Tämä on ideaali rakenne sovelluksille, joissa on 3–5 päänäkymää ja erillinen "asetukset"-valikko.

Voit lisätä välilehdille badgeja (esim. lukemattomien viestien määrä) seuraavasti:

// AppShell.xaml.cs
public AppShell()
{
    InitializeComponent();
    Routing.RegisterRoute("details", typeof(ProductDetailPage));
    Routing.RegisterRoute("cart", typeof(CartPage));
}

// ViewModelissa
public void UpdateCartBadge(int count)
{
    var cartTab = Shell.Current.Items
        .SelectMany(i => i.Items)
        .FirstOrDefault(s => s.Route == "cart");

    if (cartTab is not null)
    {
        Shell.SetTabBarIsVisible(cartTab, count > 0);
    }
}

Välilehtien välillä navigointi tapahtuu absoluuttisella URI:lla:

// Vaihda toiseen välilehteen (ei pinoa)
await Shell.Current.GoToAsync("//search");

// Avaa yksityiskohtanäkymä haku-välilehden pinon päälle
await Shell.Current.GoToAsync("//search/details?productId=42");

// Palaa edelliseen
await Shell.Current.GoToAsync("..");

Kaksoiskauttaviivat (//) tarkoittavat absoluuttista reittiä, eli tämä pyyhkii nykyisen pinon ja siirtyy juurivälilehteen. Yhden kauttaviivan reitti (/details) työntää sivun nykyisen pinon päälle. Tämä ero on Shellin tärkein konsepti, eikä se ole intuitiivinen. Kirjoita se muistiin, koska kompastuin siihen itse useita kertoja ensimmäisessä projektissani.

Flyout-valikon rakentaminen ja muokkaaminen

Flyout-valikko on perinteinen "hamburger menu" -tyylinen sivuvalikko, joka tukee otsaketta, sisältöä ja alatunnistetta. Yksinkertaisimmillaan riittää FlyoutItem-elementtien lisääminen suoraan Shelliin, mutta tuotantosovellukset käyttävät yleensä mukautettua FlyoutItemTemplate-mallia visuaalista yhtenäisyyttä varten:

<Shell.ItemTemplate>
    <DataTemplate>
        <Grid ColumnDefinitions="60,*" Padding="16,12">
            <Image Source="{Binding FlyoutIcon}"
                   HeightRequest="24" WidthRequest="24"
                   VerticalOptions="Center" />
            <Label Grid.Column="1"
                   Text="{Binding Title}"
                   FontSize="16"
                   VerticalOptions="Center" />
        </Grid>
    </DataTemplate>
</Shell.ItemTemplate>

Voit myös rakentaa dynaamisen Flyoutin BindableLayout-sidoksella, joka näyttää käyttäjäkohtaisia kohteita (esim. suosikit). Sulje Flyout ohjelmallisesti asettamalla Shell.Current.FlyoutIsPresented = false heti navigoinnin jälkeen, muuten valikko jää auki Androidilla. Tämän opin kantapään kautta, kun testaajat raportoivat "viallisesta" valikosta, joka oikeasti oli oma huolimattomuuteni.

Reittien rekisteröinti ja Routing.RegisterRoute

Kaikki XAML:ssa näkymättömät sivut (yksityiskohtanäkymät, modaalit ja sekundaarisivut) täytyy rekisteröidä Routing.RegisterRoute-kutsulla ennen ensimmäistä navigointia. Tämä tehdään tyypillisesti AppShell-luokan konstruktorissa:

public AppShell()
{
    InitializeComponent();

    // Perussivu (työntyy pinon päälle)
    Routing.RegisterRoute("product-details", typeof(ProductDetailPage));

    // Sisäkkäinen reitti, käytä kun haluat ryhmitellä
    Routing.RegisterRoute("order/confirmation", typeof(OrderConfirmationPage));
    Routing.RegisterRoute("order/payment", typeof(PaymentPage));

    // Modaaliesitys (esim. lomake): huom, ei "modal"-prefiksiä reitin nimessä,
    // vaan käytetään PresentationMode-attribuuttia XAML:ssa
}

Reittien nimet ovat globaaleja sovelluksen sisällä, eli kaksi eri sivua eivät voi käyttää samaa reittinimeä. Iso projekti hyötyy reittien organisoinnista staattisiin vakioihin:

public static class Routes
{
    public const string ProductDetails = "product-details";
    public const string OrderConfirmation = "order/confirmation";
    public const string Payment = "order/payment";
}

// Käyttö:
await Shell.Current.GoToAsync(Routes.ProductDetails);

Tämä estää kirjoitusvirheet ja tekee refaktoroinnin Visual Studion "Rename"-toiminnolla turvalliseksi. Jos käytät Dependency Injectionia, kuten esitimme SQLite ja MVVM -oppaassa, rekisteröi sivut myös DI-säiliöön MauiProgram.cs-tiedostossa.

Parametrien välitys IQueryAttributable-rajapinnalla

Shellin URI-pohjainen navigointi tukee parametrien välitystä kahdella tavalla: kyselymerkkijonoilla (?id=42) yksinkertaisille arvoille ja sanakirjoilla monimutkaisemmille objekteille. Suositeltu ja modernein tapa on toteuttaa IQueryAttributable-rajapinta vastaanottavassa ViewModelissa:

public partial class ProductDetailViewModel : ObservableObject, IQueryAttributable
{
    [ObservableProperty]
    private int productId;

    [ObservableProperty]
    private Product? product;

    private readonly IProductService _service;

    public ProductDetailViewModel(IProductService service)
    {
        _service = service;
    }

    public async void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("productId", out var idValue)
            && int.TryParse(idValue?.ToString(), out var id))
        {
            ProductId = id;
            Product = await _service.GetByIdAsync(id);
        }
    }
}

Lähettävältä puolelta parametrit välitetään kyselymerkkijonona tai sanakirjana:

// Yksinkertainen tapa, kyselymerkkijono
await Shell.Current.GoToAsync($"product-details?productId={product.Id}");

// Monimutkainen objekti, sanakirja (suositus)
var navigationParameter = new Dictionary<string, object>
{
    { "product", product },
    { "openedFrom", "search-results" }
};
await Shell.Current.GoToAsync("product-details", navigationParameter);

Sanakirjamenetelmä on parempi, koska se välittää oikeat oliotyypit eikä vain merkkijonoja, ja vältät JSON-sarjallistamisen sivujen välillä. Vältä myös [QueryProperty]-attribuuttia uusissa projekteissa. Se toimii edelleen, mutta IQueryAttributable on testattavampi ja tukee paremmin riippuvuusinjektiota.

Deep linking iOS- ja Android-sovelluksissa

Deep linking tarkoittaa sitä, että käyttäjä voi avata tietyn sivun sovelluksessasi suoraan ulkoisesta lähteestä, kuten verkkosivulta, push-ilmoituksesta tai sähköpostilinkistä. Shell tekee tästä yksinkertaista, koska sen URI-rakenne on jo valmis reititysmuoto.

Androidilla deep linking konfiguroidaan AndroidManifest.xml-tiedostossa lisäämällä intent-filter MainActivity-luokkaan:

<activity android:name="crc64.MainActivity" android:exported="true">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https"
              android:host="myapp.example.com"
              android:pathPrefix="/product" />
    </intent-filter>
</activity>

iOS:llä lisätään Entitlements.plist-tiedostoon com.apple.developer.associated-domains -avain ja serveröidään apple-app-site-association-tiedosto sivustosi juuresta.

Linkin saapuessa Shell ohjaa sen automaattisesti, jos käsittelet App.xaml.cs-tiedostossa OnAppLinkRequestReceived-metodin:

protected override async void OnAppLinkRequestReceived(Uri uri)
{
    // https://myapp.example.com/product/42 -> //home/product-details?productId=42
    var segments = uri.Segments;
    if (segments.Length >= 3 && segments[1].TrimEnd('/') == "product")
    {
        var productId = segments[2].TrimEnd('/');
        await Shell.Current.GoToAsync(
            $"//home/product-details?productId={productId}");
    }

    base.OnAppLinkRequestReceived(uri);
}

Testaa Android-puolen deep link adb shell-komennolla: adb shell am start -W -a android.intent.action.VIEW -d "https://myapp.example.com/product/42". iOS:llä toimii Simulatorin xcrun simctl openurl booted <url>. Tarkemmat ohjeet löydät Androidin App Links -dokumentaatiosta.

Suora Shell.Current.GoToAsync-kutsu ViewModelin sisältä rikkoo MVVM-mallin testattavuuden, koska Shell.Current on staattinen riippuvuus, jota et voi mockata. Ratkaisu on tuotantosovelluksissa käytännössä pakollinen: abstrahoi navigointi INavigationService-rajapinnan taakse ja injektoi se ViewModeleihin Dependency Injection -säiliön kautta.

public interface INavigationService
{
    Task GoToAsync(string route);
    Task GoToAsync(string route, IDictionary<string, object> parameters);
    Task GoBackAsync();
}

public class ShellNavigationService : INavigationService
{
    public Task GoToAsync(string route)
        => Shell.Current.GoToAsync(route);

    public Task GoToAsync(string route, IDictionary<string, object> parameters)
        => Shell.Current.GoToAsync(route, parameters);

    public Task GoBackAsync()
        => Shell.Current.GoToAsync("..");
}

// MauiProgram.cs
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
builder.Services.AddTransient<ProductListViewModel>();

ViewModelissa käytetään nyt vain rajapintaa:

public partial class ProductListViewModel : ObservableObject
{
    private readonly INavigationService _navigation;

    public ProductListViewModel(INavigationService navigation)
    {
        _navigation = navigation;
    }

    [RelayCommand]
    private async Task OpenDetailsAsync(Product product)
    {
        await _navigation.GoToAsync("product-details",
            new Dictionary<string, object> { { "product", product } });
    }
}

Yksikkötestissä voit nyt korvata INavigationService-toteutuksen Moq- tai NSubstitute-mockilla ja varmistaa, että ViewModel kutsuu navigointia oikealla reitillä ja parametreilla, ilman että XAML-sivuja tarvitsee renderöidä. Tämä on suora jatko ViewModelin elinkaarelle, jota käsittelimme aiemmin REST API ja MVVM -oppaassa.

Yleisimmät virheet ja niiden korjaaminen

Shell-navigoinnissa on muutamia toistuvia sudenkuoppia, jotka iskevät erityisesti Xamarin.Forms-pohjaisten projektien migroinnissa. Tunnista nämä etukäteen, niin säästät tunteja debuggaukselta. Itse törmäsin näistä neljään ensimmäisen viikon aikana, kun siirsin vanhan Xamarin-sovelluksen MAUI:in.

VirheSyyKorjaus
System.ArgumentException: Route ... not foundSivua ei ole rekisteröityLisää Routing.RegisterRoute AppShell-konstruktoriin
Flyout aukeaa väärin AndroidillaVanhentunut MAUI Essentials -versioPäivitä .NET MAUI 9.0.30+ -versioon
Parametrit eivät päivity uudelleen samalle sivulleSivu cachetetaan, ApplyQueryAttributes ei laukea uudelleenKäytä OnNavigatedTo-tapahtumaa tai pakota uusi instanssi
Takaisin-painike sulkee sovelluksen juurivälilehdelläOletuskäyttäytyminenYliaja OnBackButtonPressed tai käytä BackButtonBehavior-elementtiä
GoToAsync ei tee mitäänReitti on suhteellinen, mutta tarkoititit absoluuttistaLisää // reitin eteen

Jos käytät MVVM-mallia ja navigointi ei toimi testeissä, abstrahoi Shell.Current.GoToAsync INavigationService-rajapinnan taakse. Silloin voit mockata sen yksikkötesteissä. Tämä on myös Microsoftin suositus virallisessa MAUI Enterprise Application Patterns -oppaassa.

Usein kysytyt kysymykset

Mikä on ero Shellin ja NavigationPagen välillä?

Shell on korkeamman tason abstraktio, joka sisältää sisäänrakennetun flyout-valikon, välilehdet ja URI-pohjaisen reitityksen. NavigationPage on matalan tason pino, jolla on vain Push/Pop-operaatiot. Shell tuottaa 60–80 % vähemmän koodia keskikokoisessa sovelluksessa ja on Microsoftin suositus uusiin .NET MAUI -projekteihin.

Voiko Shell-sovelluksessa käyttää modaaleja sivuja?

Kyllä. Rekisteröi sivu normaalisti Routing.RegisterRoute-kutsulla ja aseta XAML:ssa Shell.PresentationMode="ModalAnimated". Navigointi tapahtuu samalla GoToAsync-kutsulla, mutta sivu liukuu ylhäältä alas modaalina natiivin alustan tyyliin.

Miten siirrän Xamarin.Forms-sovelluksen Shell-rakenteeseen?

Korvaa App.xaml:n juuri NavigationPage uudella AppShell-luokalla, siirrä TabbedPage-välilehdet TabBar-elementin sisään ja rekisteröi yksityiskohtanäkymät Routing.RegisterRoute-kutsuilla. Vaihda kaikki Navigation.PushAsync-kutsut Shell.Current.GoToAsync-kutsuiksi käyttäen rekisteröityjä reittinimiä.

Tukeeko Shell deep linkingia ilman lisäkirjastoja?

Kyllä, Shellin URI-rakenne toimii suoraan deep link -lähteenä. Käsittele saapuva linkki App.OnAppLinkRequestReceived-metodissa ja ohjaa se Shell.Current.GoToAsync-kutsuun. Alusta-spesifiset konfiguraatiot (Android intent-filter, iOS associated-domains) ovat silti pakollisia.

Miksi sivuni ei avaudu, kun kutsun GoToAsync-metodia?

Yleisin syy on rekisteröimätön reitti. Varmista, että Routing.RegisterRoute on kutsuttu AppShell-konstruktorissa. Toinen yleinen syy on absoluuttisen ja suhteellisen reitin sekoittaminen: //home vaihtaa juurivälilehteä, kun taas home yrittää työntää sivun nykyisen pinon päälle.

Editorial Team
Tietoa Kirjoittajasta Editorial Team

Our team of expert writers and editors.