.NET MAUI Shell -navigointi: Opas reitteihin, parametreihin ja navigointimalleihin

Kattava opas .NET MAUI Shell -navigointiin: reitit, GoToAsync, parametrien välitys IQueryAttributable-rajapinnalla, flyout- ja välilehtinavigointi, SearchHandler sekä MVVM-integraatio käytännön esimerkein.

Shell-navigointi on rehellisesti sanottuna yksi niistä .NET MAUI:n ominaisuuksista, jotka todella muuttavat tapaa rakentaa sovelluksia. Se tarjoaa URI-pohjaisen reitityksen, sisäänrakennetun tuen flyout-valikoille ja välilehdille sekä selkeän rakenteen, joka skaalautuu pienistä projekteista monimutkaisiin yrityssovelluksiin. Toisin kuin perinteinen NavigationPage-pohjainen lähestymistapa, Shell tekee navigoinnista paljon hallitumpaa.

Tässä oppaassa käyn läpi kaikki Shell-navigoinnin keskeiset osa-alueet käytännön esimerkkien avulla. Joten, aloitetaan.

Shell-navigoinnin suurin etu on sen deklaratiivinen luonne. Sovelluksen visuaalinen hierarkia määritellään XAML-tiedostossa, ja navigointi tapahtuu URI-reittien avulla. Tämä tekee rakenteesta helposti ymmärrettävän, testattavan ja ylläpidettävän. Lisäksi Shell hoitaa automaattisesti paljon sellaista, mitä joutuisit muuten käsin koodaamaan — navigointipalkin hallinnan, taaksepäin-navigoinnin ja sivuhistorian ylläpidon.

Shell-rakenteen perusteet

Shell-sovelluksen visuaalinen hierarkia koostuu kolmesta päätasosta: FlyoutItem, Tab ja ShellContent. Nämä elementit eivät itsessään edusta käyttöliittymäkomponentteja, vaan ne määrittelevät sovelluksen navigointirakenteen, jonka Shell renderöi alustakohtaisesti.

Hierarkian tasot

  • FlyoutItem – Edustaa yhtä tai useampaa kohdetta flyout-valikossa. Jokainen FlyoutItem sisältää yhden tai useamman Tab-elementin.
  • Tab – Edustaa välilehteä alanavigointipalkissa. Jokainen Tab sisältää yhden tai useamman ShellContent-elementin.
  • ShellContent – Edustaa yksittäistä ContentPage-sivua välilehden sisällä. Kun Tabissa on useampi ShellContent, sivujen välillä navigoidaan ylänavigointipalkin välilehtien avulla.
<?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:views="clr-namespace:MyApp.Views"
       x:Class="MyApp.AppShell"
       Title="Oma Sovellus"
       FlyoutBehavior="Flyout">

    <!-- Etusivu flyout-valikossa -->
    <FlyoutItem Title="Etusivu" Icon="home.png">
        <ShellContent
            Title="Etusivu"
            ContentTemplate="{DataTemplate views:HomePage}"
            Route="home" />
    </FlyoutItem>

    <!-- Tuotteet-osio välilehdillä -->
    <FlyoutItem Title="Tuotteet" Icon="products.png">
        <Tab Title="Kaikki tuotteet">
            <ShellContent
                Title="Listaus"
                ContentTemplate="{DataTemplate views:ProductListPage}"
                Route="productlist" />
            <ShellContent
                Title="Suosikit"
                ContentTemplate="{DataTemplate views:FavoritesPage}"
                Route="favorites" />
        </Tab>
        <Tab Title="Kategoriat">
            <ShellContent
                Title="Kategoriat"
                ContentTemplate="{DataTemplate views:CategoriesPage}"
                Route="categories" />
        </Tab>
    </FlyoutItem>

    <!-- Asetukset -->
    <FlyoutItem Title="Asetukset" Icon="settings.png">
        <ShellContent
            Title="Asetukset"
            ContentTemplate="{DataTemplate views:SettingsPage}"
            Route="settings" />
    </FlyoutItem>

</Shell>

Tärkeä huomio: käytä ContentTemplate-attribuuttia ContentPage-attribuutin sijaan. Tämä mahdollistaa sivujen laiskakuormauksen eli lazy loadingin — sivu luodaan vasta kun käyttäjä navigoi sille ensimmäisen kerran. Olen itse huomannut, että tällä on iso vaikutus käynnistymisnopeuteen erityisesti isommissa sovelluksissa.

Yksinkertaistettu syntaksi

Shell tukee myös implisiittistä muuntamista, joka vähentää XAML-koodin määrää merkittävästi. Yksittäinen ShellContent voidaan kääriä automaattisesti Tab- ja FlyoutItem-elementteihin:

<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="Disabled">

    <!-- TabBar-elementti luo välilehtipohjaisen navigoinnin ilman flyout-valikkoa -->
    <TabBar>
        <ShellContent Title="Etusivu" Icon="home.png"
                      ContentTemplate="{DataTemplate views:HomePage}"
                      Route="home" />
        <ShellContent Title="Haku" Icon="search.png"
                      ContentTemplate="{DataTemplate views:SearchPage}"
                      Route="search" />
        <ShellContent Title="Profiili" Icon="profile.png"
                      ContentTemplate="{DataTemplate views:ProfilePage}"
                      Route="profile" />
    </TabBar>

</Shell>

TabBar-elementti estää flyout-valikon näyttämisen ja tarjoaa pelkän välilehtipohjaisen navigoinnin. Tämä on käytännössä yleisin malli mobiilisovelluksissa — ajattele vaikka Instagramia tai Spotifyta.

Reittien rekisteröinti

Shell-navigointi perustuu URI-pohjaiseen reitityssysteemiin. Jokaisella sivulla on reitti, jonka avulla sille voidaan navigoida. Reittejä on kolmea tyyppiä, ja jokainen niistä palvelee eri tarkoitusta.

Implisiittiset reitit

Implisiittiset reitit luodaan automaattisesti Shell-hierarkiasta. Jokainen FlyoutItem, Tab ja ShellContent saa oman reitin, joka perustuu niiden Route-attribuuttiin tai luokan nimeen. Kätevää, mutta ei aina ennustettavaa.

Eksplisiittiset reitit

Eksplisiittisiä reittejä käytetään, kun haluat määrittää reitit manuaalisesti Route-attribuutin avulla. Tämä on ehdottomasti suositeltava käytäntö — reitit pysyvät ennustettavina ja helposti ylläpidettävinä.

<FlyoutItem Route="dashboard" Title="Kojelauta">
    <Tab Route="overview">
        <ShellContent Route="summary"
                      Title="Yhteenveto"
                      ContentTemplate="{DataTemplate views:SummaryPage}" />
    </Tab>
</FlyoutItem>

Tämän elementin täydellinen reitti on //dashboard/overview/summary.

Globaalit reitit

Globaaleja reittejä käytetään sivuille, jotka eivät ole osa Shell-visuaalista hierarkiaa — tyypillisesti yksityiskohtasivut, muokkaussivut ja muut navigointipinoon työnnettävät sivut. Ne rekisteröidään Routing.RegisterRoute-metodilla:

// AppShell.xaml.cs - konstruktorissa
public AppShell()
{
    InitializeComponent();

    // Globaalien reittien rekisteröinti
    Routing.RegisterRoute("productdetails", typeof(ProductDetailsPage));
    Routing.RegisterRoute("editproduct", typeof(EditProductPage));
    Routing.RegisterRoute("checkout", typeof(CheckoutPage));
    Routing.RegisterRoute("orderconfirmation", typeof(OrderConfirmationPage));
    Routing.RegisterRoute("userprofile", typeof(UserProfilePage));
}

Yksi sudenkuoppa, johon olen itsekin törmännyt: globaalin reitin nimi ei saa olla sama kuin minkään Shell-hierarkiassa olevan elementin reitti. Muuten saat ajonaikaisen poikkeuksen, ja virheilmoitus ei aina ole kovin informatiivinen.

Reittirekisteröinti MauiProgram-tasolla

Isommissa sovelluksissa reittien rekisteröinti kannattaa keskittää yhteen paikkaan. Se tekee koodista paljon selkeämpää:

// RouteRegistration.cs
public static class RouteRegistration
{
    public static void RegisterRoutes()
    {
        // Tuotesivut
        Routing.RegisterRoute("productdetails", typeof(ProductDetailsPage));
        Routing.RegisterRoute("editproduct", typeof(EditProductPage));

        // Tilausprosessi
        Routing.RegisterRoute("cart", typeof(CartPage));
        Routing.RegisterRoute("checkout", typeof(CheckoutPage));
        Routing.RegisterRoute("orderconfirmation", typeof(OrderConfirmationPage));

        // Käyttäjähallinta
        Routing.RegisterRoute("login", typeof(LoginPage));
        Routing.RegisterRoute("register", typeof(RegisterPage));
        Routing.RegisterRoute("forgotpassword", typeof(ForgotPasswordPage));
    }
}

// AppShell.xaml.cs
public AppShell()
{
    InitializeComponent();
    RouteRegistration.RegisterRoutes();
}

GoToAsync-navigointi

Shell.Current.GoToAsync on Shell-navigoinnin ydinmetodi. Se mahdollistaa navigoinnin URI-reittien avulla ja tukee sekä absoluuttisia että suhteellisia reittejä. Koska metodi on asynkroninen, voit odottaa animaatioiden ja sivujen alustamisen valmistumista ennen kuin jatkat.

Absoluuttiset reitit

Absoluuttiset reitit alkavat kahdella kauttaviivalla (//) ja määrittelevät täydellisen polun Shell-hierarkian juuresta. Tärkeää: absoluuttinen navigointi tyhjentää nykyisen navigointipinon.

// Navigoi absoluuttisesti etusivulle — tyhjentää navigointipinon
await Shell.Current.GoToAsync("//home");

// Navigoi absoluuttisesti tuotelistaan
await Shell.Current.GoToAsync("//products/productlist");

Suhteelliset reitit

Suhteelliset reitit lisäävät sivun nykyisen navigointipinon päälle. Tämä on se tapa, jota tulet käyttämään eniten — etenkin kun siirryt yksityiskohtasivuille:

// Navigoi tuotteen yksityiskohtasivulle (lisää navigointipinoon)
await Shell.Current.GoToAsync("productdetails");

// Navigoi usean tason syvälle
await Shell.Current.GoToAsync("productdetails/editproduct");

Taaksepäin navigointi

Taaksepäin navigointi tapahtuu käyttämällä kahta pistettä (..). Yksinkertaista ja eleganttia:

// Palaa yksi taso taaksepäin
await Shell.Current.GoToAsync("..");

// Palaa kaksi tasoa taaksepäin
await Shell.Current.GoToAsync("../..");

// Palaa taaksepäin ja navigoi samalla uuteen sivuun
await Shell.Current.GoToAsync("../orderconfirmation");

Navigoinnin animoinnin hallinta

GoToAsync-metodille voidaan antaa boolean-parametri, joka määrittää käytetäänkö animaatiota siirtymässä:

// Navigoi ilman animaatiota
await Shell.Current.GoToAsync("productdetails", false);

// Navigoi animaatiolla (oletusarvo)
await Shell.Current.GoToAsync("productdetails", true);

Parametrien välittäminen

Parametrien välittäminen sivujen välillä on yksi niistä asioista, jotka joutuu tekemään lähes jokaisessa sovelluksessa. .NET MAUI tarjoaa tähän useita tapoja, ja on hyvä tietää milloin käyttää mitäkin.

Query string -parametrit

Yksinkertaisin tapa välittää parametreja on URI:n query string -osa. Tämä sopii parhaiten yksinkertaisille arvoille kuten tunnisteille:

// Navigoi tuotesivulle ja välitä tuotteen ID
await Shell.Current.GoToAsync($"productdetails?productId={product.Id}");

// Useita parametreja
await Shell.Current.GoToAsync(
    $"productdetails?productId={product.Id}&categoryName={Uri.EscapeDataString(category.Name)}");

IQueryAttributable-rajapinta (suositeltu tapa)

IQueryAttributable on nykyisin suositeltu tapa vastaanottaa navigointiparametreja. Se on turvallisempi kuin vanhempi QueryPropertyAttribute-lähestymistapa — erityisesti NativeAOT-kääntämisen ja trimming-optimoinnin kannalta. Rajapinta toimii sekä sivuilla että ViewModeleissa:

// ProductDetailsViewModel.cs
public class ProductDetailsViewModel : ObservableObject, IQueryAttributable
{
    private readonly IProductService _productService;

    private Product _product;
    public Product Product
    {
        get => _product;
        set => SetProperty(ref _product, value);
    }

    private bool _isBusy;
    public bool IsBusy
    {
        get => _isBusy;
        set => SetProperty(ref _isBusy, value);
    }

    public ProductDetailsViewModel(IProductService productService)
    {
        _productService = productService;
    }

    // IQueryAttributable-rajapinnan toteutus
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("productId", out var productIdObj)
            && int.TryParse(productIdObj?.ToString(), out int productId))
        {
            // Lataa tuotetiedot asynkronisesti
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await LoadProductAsync(productId);
            });
        }
    }

    private async Task LoadProductAsync(int productId)
    {
        try
        {
            IsBusy = true;
            Product = await _productService.GetProductByIdAsync(productId);
        }
        catch (Exception ex)
        {
            await Shell.Current.DisplayAlert("Virhe",
                $"Tuotteen lataaminen epäonnistui: {ex.Message}", "OK");
        }
        finally
        {
            IsBusy = false;
        }
    }
}

Monimutkaisten objektien välittäminen sanakirjalla

Entä kun tarvitset välittää kokonaisen tietomallin eikä pelkkää ID:tä? Silloin sanakirjapohjainen navigointi on oikea valinta. Se välttää serialisoinnin tarpeen kokonaan:

// Välitä kokonainen tuoteobjekti navigoinnin yhteydessä
var navigationParameter = new Dictionary<string, object>
{
    { "product", selectedProduct },
    { "isEditing", true },
    { "returnRoute", "//products/productlist" }
};

await Shell.Current.GoToAsync("editproduct", navigationParameter);
// EditProductViewModel.cs
public class EditProductViewModel : ObservableObject, IQueryAttributable
{
    private Product _product;
    public Product Product
    {
        get => _product;
        set => SetProperty(ref _product, value);
    }

    private bool _isEditing;
    public bool IsEditing
    {
        get => _isEditing;
        set => SetProperty(ref _isEditing, value);
    }

    private string _returnRoute;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        // Vastaanota monimutkainen objekti suoraan
        if (query.TryGetValue("product", out var productObj)
            && productObj is Product product)
        {
            // Luodaan kopio muokkaamista varten
            Product = new Product
            {
                Id = product.Id,
                Name = product.Name,
                Price = product.Price,
                Description = product.Description
            };
        }

        if (query.TryGetValue("isEditing", out var isEditingObj)
            && isEditingObj is bool isEditing)
        {
            IsEditing = isEditing;
        }

        if (query.TryGetValue("returnRoute", out var routeObj)
            && routeObj is string route)
        {
            _returnRoute = route;
        }
    }

    [RelayCommand]
    private async Task SaveAsync()
    {
        // Tallenna muutokset...

        if (!string.IsNullOrEmpty(_returnRoute))
        {
            await Shell.Current.GoToAsync(_returnRoute);
        }
        else
        {
            await Shell.Current.GoToAsync("..");
        }
    }
}

Pieni mutta tärkeä huomio: QueryPropertyAttribute-attribuuttia ei enää suositella. Se perustuu reflektioon, mikä tekee siitä ongelmallisen NativeAOT-kääntämisessä. IQueryAttributable on yksinkertaisesti parempi vaihtoehto kaikin puolin.

Flyout- ja välilehtinavigointi

Shell tarjoaa todella monipuoliset mahdollisuudet flyout-valikon ja välilehtien mukauttamiseen. Katsotaan miten näiden ulkoasua ja käyttäytymistä voidaan hienosäätää.

Flyout-valikon mukauttaminen

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       x:Class="MyApp.AppShell"
       FlyoutBehavior="Flyout"
       FlyoutBackgroundColor="{StaticResource PrimaryDark}"
       FlyoutWidth="280">

    <!-- Mukautettu flyout-otsikko -->
    <Shell.FlyoutHeader>
        <Grid HeightRequest="180"
              BackgroundColor="{StaticResource Primary}"
              Padding="20">
            <VerticalStackLayout VerticalOptions="End">
                <Image Source="app_logo.png"
                       HeightRequest="60"
                       WidthRequest="60"
                       HorizontalOptions="Start" />
                <Label Text="Oma Kauppasovellus"
                       TextColor="White"
                       FontSize="18"
                       FontAttributes="Bold" />
                <Label Text="versio 2.1.0"
                       TextColor="LightGray"
                       FontSize="12" />
            </VerticalStackLayout>
        </Grid>
    </Shell.FlyoutHeader>

    <!-- Flyout-elementit -->
    <FlyoutItem Title="Etusivu" Icon="home.png">
        <ShellContent ContentTemplate="{DataTemplate views:HomePage}" Route="home" />
    </FlyoutItem>

    <FlyoutItem Title="Tilaukset" Icon="orders.png">
        <ShellContent ContentTemplate="{DataTemplate views:OrdersPage}" Route="orders" />
    </FlyoutItem>

    <!-- Mukautettu MenuItem — ei navigoi, vaan suorittaa toiminnon -->
    <MenuItem Text="Kirjaudu ulos"
              IconImageSource="logout.png"
              Clicked="OnLogoutClicked" />

    <!-- Flyout-alatunniste -->
    <Shell.FlyoutFooter>
        <Grid Padding="15" BackgroundColor="{StaticResource PrimaryDark}">
            <Label Text="© 2026 Oma Yritys Oy"
                   TextColor="Gray"
                   FontSize="11"
                   HorizontalOptions="Center" />
        </Grid>
    </Shell.FlyoutFooter>

</Shell>

Flyout-elementin mukautettu ulkoasu

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

Välilehtien mukauttaminen

Välilehtien ulkoasua voidaan muokata Shell-tasolla attached property -ominaisuuksilla. Tässä on esimerkki, joka näyttää miten värit konfiguroidaan:

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       x:Class="MyApp.AppShell"
       Shell.TabBarBackgroundColor="{StaticResource Surface}"
       Shell.TabBarForegroundColor="{StaticResource Primary}"
       Shell.TabBarUnselectedColor="{StaticResource OnSurfaceVariant}"
       Shell.TabBarTitleColor="{StaticResource Primary}">

    <TabBar>
        <Tab Title="Etusivu" Icon="home.png">
            <ShellContent ContentTemplate="{DataTemplate views:HomePage}" />
        </Tab>
        <Tab Title="Haku" Icon="search.png">
            <ShellContent ContentTemplate="{DataTemplate views:SearchPage}" />
        </Tab>
        <Tab Title="Ostoskori" Icon="cart.png">
            <ShellContent ContentTemplate="{DataTemplate views:CartPage}" />
        </Tab>
        <Tab Title="Profiili" Icon="profile.png">
            <ShellContent ContentTemplate="{DataTemplate views:ProfilePage}" />
        </Tab>
    </TabBar>

</Shell>

Yksittäisen sivun navigointipalkin ja välilehtien ominaisuuksia voidaan muuttaa myös sivukohtaisesti, mikä on kätevää esimerkiksi yksityiskohtasivuilla:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyApp.Views.ProductDetailsPage"
             Shell.NavBarIsVisible="True"
             Shell.TabBarIsVisible="False"
             Shell.BackButtonBehavior="{StaticResource CustomBackButton}"
             Title="Tuotteen tiedot">

    <ContentPage.Resources>
        <BackButtonBehavior x:Key="CustomBackButton"
                            IconOverride="arrow_back.png"
                            TextOverride="Takaisin" />
    </ContentPage.Resources>

    <!-- Sivun sisältö -->
</ContentPage>

Navigoinnin elinkaari

Shell-navigoinnin elinkaaren ymmärtäminen on tärkeää — ehkä jopa tärkeämpää kuin moni aluksi ajattelee. Se vaikuttaa siihen, missä vaiheessa tiedot ladataan ja resursseja hallitaan. Shell tarjoaa useita tapahtumia ja metodeja, joilla reagoit navigoinnin eri vaiheisiin.

Shell-tason tapahtumat

// AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        RouteRegistration.RegisterRoutes();

        // Navigointitapahtumat
        Navigating += OnShellNavigating;
        Navigated += OnShellNavigated;
    }

    private void OnShellNavigating(object sender, ShellNavigatingEventArgs e)
    {
        // Tämä tapahtuma laukeaa ENNEN navigointia
        // Voit peruuttaa navigoinnin tarvittaessa

        if (e.Target.Location.OriginalString.Contains("checkout"))
        {
            // Tarkista onko käyttäjä kirjautunut
            if (!AuthService.IsAuthenticated)
            {
                e.Cancel(); // Peruuta navigointi
                MainThread.BeginInvokeOnMainThread(async () =>
                {
                    await GoToAsync("login");
                });
            }
        }
    }

    private void OnShellNavigated(object sender, ShellNavigatedEventArgs e)
    {
        // Tämä tapahtuma laukeaa navigoinnin JÄLKEEN
        System.Diagnostics.Debug.WriteLine(
            $"Navigoitu: {e.Previous?.Location} -> {e.Current.Location} " +
            $"(Lähde: {e.Source})");
    }
}

Sivutason elinkaarimetodit

ContentPage tarjoaa useita elinkaarimetodeja, jotka laukeavat navigoinnin yhteydessä. Tässä on kattava esimerkki:

// ProductListPage.xaml.cs
public partial class ProductListPage : ContentPage
{
    private readonly ProductListViewModel _viewModel;

    public ProductListPage(ProductListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = _viewModel = viewModel;
    }

    // Laukeaa kun sivu on tulossa näkyviin
    protected override void OnAppearing()
    {
        base.OnAppearing();

        // Lataa tiedot vain ensimmäisellä kerralla
        // tai kun tiedetään että data on muuttunut
        if (_viewModel.Products == null || _viewModel.Products.Count == 0)
        {
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await _viewModel.LoadProductsAsync();
            });
        }
    }

    // Laukeaa kun sivu on häviämässä näkyvistä
    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        // Peruuta kesken olevat toiminnot
        _viewModel.CancelPendingOperations();
    }

    // Shell-spesifinen: laukeaa kun navigointi sivulle on valmis
    protected override void OnNavigatedTo(NavigatedToEventArgs args)
    {
        base.OnNavigatedTo(args);
        System.Diagnostics.Debug.WriteLine("Navigoitu ProductListPage-sivulle");
    }

    // Shell-spesifinen: laukeaa kun sivulta navigoidaan pois
    protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
    {
        base.OnNavigatedFrom(args);
        System.Diagnostics.Debug.WriteLine("Navigoitu pois ProductListPage-sivulta");
    }

    // Shell-spesifinen: laukeaa kun navigointi sivulle on alkamassa
    protected override void OnNavigatingFrom(NavigatingFromEventArgs args)
    {
        base.OnNavigatingFrom(args);
        System.Diagnostics.Debug.WriteLine("Navigoidaan pois ProductListPage-sivulta");
    }
}

Elinkaarimetodien suoritusjärjestys navigoitaessa sivulle A sivulta B on seuraava:

  1. Shell.Navigating — Shell-tapahtuma ennen navigointia
  2. A.OnNavigatingFrom — Lähtösivu saa tiedon navigoinnista
  3. A.OnDisappearing — Lähtösivu häviää näkyvistä
  4. B.OnAppearing — Kohdesivu tulee näkyviin
  5. B.OnNavigatedTo — Navigointi kohdesivulle valmis
  6. A.OnNavigatedFrom — Lähtösivulta navigointi valmis
  7. Shell.Navigated — Shell-tapahtuma navigoinnin jälkeen

Tämä järjestys on syytä pitää mielessä, koska se vaikuttaa esimerkiksi siihen, milloin resursseja vapautetaan ja milloin uusia tietoja ladataan.

Hakutoiminto Shell SearchHandlerilla

Shell sisältää sisäänrakennetun hakutoiminnon SearchHandler-luokan avulla. Se tarjoaa hakupalkin, joka integroituu suoraan navigointipalkkiin — ei tarvitse rakentaa omaa hakukomponenttia tyhjästä.

// ProductSearchHandler.cs
public class ProductSearchHandler : SearchHandler
{
    private readonly IProductService _productService;

    public ProductSearchHandler()
    {
        _productService = Application.Current.Handler
            .MauiContext.Services.GetRequiredService<IProductService>();

        Placeholder = "Hae tuotteita...";
        ShowsResults = true;
        SearchBoxVisibility = SearchBoxVisibility.Expanded;
    }

    // Laukeaa kun hakukenttään kirjoitetaan
    protected override async void OnQueryChanged(string oldValue, string newValue)
    {
        base.OnQueryChanged(oldValue, newValue);

        if (string.IsNullOrWhiteSpace(newValue))
        {
            ItemsSource = null;
            return;
        }

        if (newValue.Length < 2) return;

        try
        {
            var results = await _productService.SearchProductsAsync(newValue);
            ItemsSource = results.ToList();
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Hakuvirhe: {ex.Message}");
            ItemsSource = null;
        }
    }

    // Laukeaa kun käyttäjä valitsee hakutuloksen
    protected override async void OnItemSelected(object item)
    {
        base.OnItemSelected(item);

        if (item is Product product)
        {
            var parameters = new Dictionary<string, object>
            {
                { "product", product }
            };

            await Shell.Current.GoToAsync("productdetails", parameters);
        }
    }

    // Laukeaa kun käyttäjä painaa hakupainiketta
    protected override async void OnQueryConfirmed(string query)
    {
        base.OnQueryConfirmed(query);

        if (!string.IsNullOrWhiteSpace(query))
        {
            var parameters = new Dictionary<string, object>
            {
                { "searchQuery", query }
            };

            await Shell.Current.GoToAsync("searchresults", parameters);
        }
    }
}

SearchHandler liitetään sivuun XAML:ssä näin:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:handlers="clr-namespace:MyApp.Handlers"
             x:Class="MyApp.Views.ProductListPage"
             Title="Tuotteet">

    <Shell.SearchHandler>
        <handlers:ProductSearchHandler
            DisplayMemberName="Name">
            <SearchHandler.ItemTemplate>
                <DataTemplate>
                    <Grid ColumnDefinitions="60,*"
                          Padding="10,5"
                          ColumnSpacing="10">
                        <Image Source="{Binding ImageUrl}"
                               HeightRequest="40"
                               WidthRequest="40"
                               Aspect="AspectFill" />
                        <VerticalStackLayout Grid.Column="1"
                                             VerticalOptions="Center">
                            <Label Text="{Binding Name}"
                                   FontSize="14"
                                   FontAttributes="Bold" />
                            <Label Text="{Binding Price, StringFormat='{0:C}'}"
                                   FontSize="12"
                                   TextColor="Gray" />
                        </VerticalStackLayout>
                    </Grid>
                </DataTemplate>
            </SearchHandler.ItemTemplate>
        </handlers:ProductSearchHandler>
    </Shell.SearchHandler>

    <!-- Sivun muu sisältö -->
</ContentPage>

.NET MAUI 10:n uudet ominaisuudet

.NET MAUI 10 tuo mukanaan joitakin todella tervetulleita parannuksia Shell-navigointiin. Käydään läpi keskeisimmät.

NavBarVisibilityAnimationEnabled

Aiemmissa versioissa navigointipalkin piilottaminen ja näyttäminen siirtymien aikana saattoi aiheuttaa visuaalista "hyppimistä" — ja se näytti aika rumalta, rehellisesti sanottuna. Uusi NavBarVisibilityAnimationEnabled-ominaisuus korjaa tämän:

<!-- Ota käyttöön pehmeä animaatio navigointipalkin näkyvyyden muutoksissa -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyApp.Views.DetailPage"
             Shell.NavBarIsVisible="True"
             Shell.NavBarVisibilityAnimationEnabled="True"
             Title="Yksityiskohdat">

    <!-- Sivun sisältö -->
</ContentPage>
// Ohjelmallinen navigointipalkin piilotus pehmeällä animaatiolla
Shell.SetNavBarVisibilityAnimationEnabled(this, true);
Shell.SetNavBarIsVisible(this, false);

Tämä on erityisen hyödyllistä immersive-tyyppisissä näkymissä, joissa navigointipalkki halutaan piilottaa sisältöä selatessa ja näyttää uudelleen ylöspäin selattaessa.

Modaaliset popoverit iOS:lla ja macOS:lla

.NET MAUI 10 laajentaa modaalista navigointia tukemaan popover-tyylisiä esitystapoja iPad- ja Mac Catalyst -sovelluksissa. Tämä on hieno lisä kontekstuaalisten näkymien luomiseen:

// Popover-tyylinen modaalinen näkymä iPadilla ja Macilla
var detailPage = new ProductQuickViewPage(product);

#if IOS || MACCATALYST
// Aseta popover-esitystyyli
detailPage.ModalPresentationStyle = UIKit.UIModalPresentationStyle.Popover;
#endif

await Navigation.PushModalAsync(detailPage);

.NET MAUI 10 parantaa myös Shell-navigoinnin suorituskykyä yleisesti. Navigointipinon hallinta on optimoitu, mikä näkyy erityisesti monimutkaisissa sovelluksissa pienempänä muistinkäyttönä ja nopeampina siirtyminä.

Muita .NET MAUI 10 parannuksia

  • Parannettu HybridWebView-navigointi — Syvempi integraatio web-sisällön ja natiivinavigoinnin välillä
  • Parannettu CollectionView-suorituskyky — Listapohjaiset sivut latautuvat nopeammin, mikä parantaa navigoinnin kokonaistuntumaa
  • Uudet Handler-elinkaaritapahtumat — Tarkempi kontrolli sivujen luomiseen ja tuhoamiseen navigoinnin aikana

Parhaat käytännöt ja MVVM-integraatio

Tuotantolaatuisissa sovelluksissa navigointilogiikka kuuluu näkymämalleihin, ei sivujen code-behind-tiedostoihin. Tämä ei ole pelkkä mielipide — se on käytännössä välttämätöntä, jos haluat testata navigointilogiikkaa yksikkötesteillä.

Navigointipalvelun abstrahointi

Suorien Shell.Current.GoToAsync-kutsujen sijaan kannattaa luoda navigointipalvelu. Tämä mahdollistaa testattavuuden ja vähentää sidontaa Shell-toteutukseen:

// INavigationService.cs
public interface INavigationService
{
    Task NavigateToAsync(string route);
    Task NavigateToAsync(string route, IDictionary<string, object> parameters);
    Task GoBackAsync();
    Task GoBackAsync(IDictionary<string, object> parameters);
    Task GoToRootAsync();
    Task DisplayAlertAsync(string title, string message, string cancel);
    Task<bool> DisplayConfirmAsync(string title, string message,
        string accept, string cancel);
}

// ShellNavigationService.cs
public class ShellNavigationService : INavigationService
{
    public async Task NavigateToAsync(string route)
    {
        await Shell.Current.GoToAsync(route);
    }

    public async Task NavigateToAsync(string route,
        IDictionary<string, object> parameters)
    {
        await Shell.Current.GoToAsync(route, parameters);
    }

    public async Task GoBackAsync()
    {
        await Shell.Current.GoToAsync("..");
    }

    public async Task GoBackAsync(IDictionary<string, object> parameters)
    {
        await Shell.Current.GoToAsync("..", parameters);
    }

    public async Task GoToRootAsync()
    {
        await Shell.Current.GoToAsync("//home");
    }

    public async Task DisplayAlertAsync(string title, string message,
        string cancel)
    {
        await Shell.Current.DisplayAlert(title, message, cancel);
    }

    public async Task<bool> DisplayConfirmAsync(string title, string message,
        string accept, string cancel)
    {
        return await Shell.Current.DisplayAlert(title, message, accept, cancel);
    }
}

Dependency Injection -rekisteröinti

// MauiProgram.cs
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Palvelut
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<IProductService, ProductService>();
        builder.Services.AddSingleton<IAuthService, AuthService>();

        // ViewModelit
        builder.Services.AddTransient<ProductListViewModel>();
        builder.Services.AddTransient<ProductDetailsViewModel>();
        builder.Services.AddTransient<EditProductViewModel>();
        builder.Services.AddTransient<CartViewModel>();
        builder.Services.AddTransient<CheckoutViewModel>();

        // Sivut
        builder.Services.AddTransient<ProductListPage>();
        builder.Services.AddTransient<ProductDetailsPage>();
        builder.Services.AddTransient<EditProductPage>();
        builder.Services.AddTransient<CartPage>();
        builder.Services.AddTransient<CheckoutPage>();

        return builder.Build();
    }
}

Käytännön MVVM-esimerkki navigointipalvelulla

// ProductListViewModel.cs
public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;
    private readonly INavigationService _navigationService;

    [ObservableProperty]
    private ObservableCollection<Product> _products;

    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private bool _isEmpty;

    public ProductListViewModel(
        IProductService productService,
        INavigationService navigationService)
    {
        _productService = productService;
        _navigationService = navigationService;
        _products = new ObservableCollection<Product>();
    }

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        if (IsBusy) return;

        try
        {
            IsBusy = true;
            var products = await _productService.GetAllProductsAsync();

            Products.Clear();
            foreach (var product in products)
            {
                Products.Add(product);
            }

            IsEmpty = Products.Count == 0;
        }
        catch (Exception ex)
        {
            await _navigationService.DisplayAlertAsync(
                "Virhe", $"Tuotteiden lataaminen epäonnistui: {ex.Message}", "OK");
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private async Task NavigateToDetailsAsync(Product product)
    {
        if (product == null) return;

        var parameters = new Dictionary<string, object>
        {
            { "product", product }
        };

        await _navigationService.NavigateToAsync("productdetails", parameters);
    }

    [RelayCommand]
    private async Task AddNewProductAsync()
    {
        var parameters = new Dictionary<string, object>
        {
            { "isEditing", false }
        };

        await _navigationService.NavigateToAsync("editproduct", parameters);
    }

    [RelayCommand]
    private async Task NavigateToCartAsync()
    {
        await _navigationService.NavigateToAsync("cart");
    }
}

Yksikkötestaus navigointilogiikalle

Navigointipalvelun abstrahoinnin ansiosta yksikkötestaus on suoraviivaista. Tämä on mielestäni yksi vahvimmista argumenteista navigointipalvelun käytön puolesta:

// ProductListViewModelTests.cs
public class ProductListViewModelTests
{
    private readonly Mock<IProductService> _mockProductService;
    private readonly Mock<INavigationService> _mockNavigationService;
    private readonly ProductListViewModel _viewModel;

    public ProductListViewModelTests()
    {
        _mockProductService = new Mock<IProductService>();
        _mockNavigationService = new Mock<INavigationService>();
        _viewModel = new ProductListViewModel(
            _mockProductService.Object,
            _mockNavigationService.Object);
    }

    [Fact]
    public async Task NavigateToDetails_WithProduct_NavigatesToCorrectRoute()
    {
        // Arrange
        var product = new Product { Id = 1, Name = "Testituote" };

        // Act
        await _viewModel.NavigateToDetailsCommand.ExecuteAsync(product);

        // Assert
        _mockNavigationService.Verify(
            n => n.NavigateToAsync(
                "productdetails",
                It.Is<IDictionary<string, object>>(
                    d => d.ContainsKey("product") && d["product"] == product)),
            Times.Once);
    }

    [Fact]
    public async Task LoadProducts_OnFailure_ShowsAlert()
    {
        // Arrange
        _mockProductService
            .Setup(s => s.GetAllProductsAsync())
            .ThrowsAsync(new HttpRequestException("Verkkovirhe"));

        // Act
        await _viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _mockNavigationService.Verify(
            n => n.DisplayAlertAsync(
                "Virhe",
                It.Is<string>(s => s.Contains("Verkkovirhe")),
                "OK"),
            Times.Once);
    }

    [Fact]
    public async Task LoadProducts_OnSuccess_PopulatesCollection()
    {
        // Arrange
        var products = new List<Product>
        {
            new() { Id = 1, Name = "Tuote A" },
            new() { Id = 2, Name = "Tuote B" }
        };

        _mockProductService
            .Setup(s => s.GetAllProductsAsync())
            .ReturnsAsync(products);

        // Act
        await _viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        Assert.Equal(2, _viewModel.Products.Count);
        Assert.False(_viewModel.IsEmpty);
    }
}

Navigoinnin suojaus autentikoinnilla

Käytännössä jokainen tuotantosovellus tarvitsee jonkinlaisen navigoinnin suojauksen. Tässä on malli, joka hyödyntää Shell-tason navigointitapahtumia:

// AppShell.xaml.cs
public partial class AppShell : Shell
{
    private readonly IAuthService _authService;

    // Reitit jotka vaativat kirjautumisen
    private static readonly HashSet<string> ProtectedRoutes = new()
    {
        "checkout",
        "orderconfirmation",
        "editproduct",
        "userprofile",
        "orders"
    };

    public AppShell(IAuthService authService)
    {
        InitializeComponent();
        _authService = authService;

        RouteRegistration.RegisterRoutes();

        Navigating += OnShellNavigating;
    }

    private async void OnShellNavigating(object sender, ShellNavigatingEventArgs e)
    {
        var targetRoute = e.Target.Location.OriginalString;

        // Tarkista vaatiiko kohde autentikoinnin
        bool requiresAuth = ProtectedRoutes
            .Any(r => targetRoute.Contains(r, StringComparison.OrdinalIgnoreCase));

        if (requiresAuth && !_authService.IsAuthenticated)
        {
            // Peruuta alkuperäinen navigointi
            var deferral = e.GetDeferral();

            // Näytä kirjautumissivu
            bool loggedIn = await ShowLoginAsync();

            if (loggedIn)
            {
                deferral.Complete(); // Jatka alkuperäistä navigointia
            }
            else
            {
                e.Cancel(); // Peruuta navigointi kokonaan
                deferral.Complete();
            }
        }
    }

    private async Task<bool> ShowLoginAsync()
    {
        var loginPage = new LoginPage();
        await Navigation.PushModalAsync(loginPage);

        var tcs = loginPage.LoginCompletionSource;
        return await tcs.Task;
    }
}

Monisivuinen navigointivuo

Monimutkaisemmissa prosesseissa kuten tilausprosessissa on usein tarpeen hallita usean sivun välistä navigointivuota. Tässä esimerkki checkout-prosessista:

// CheckoutViewModel.cs — esimerkki monivaiheprosessista
public partial class CheckoutViewModel : ObservableObject, IQueryAttributable
{
    private readonly INavigationService _navigationService;
    private readonly IOrderService _orderService;

    [ObservableProperty]
    private Order _currentOrder;

    public CheckoutViewModel(
        INavigationService navigationService,
        IOrderService orderService)
    {
        _navigationService = navigationService;
        _orderService = orderService;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("order", out var orderObj) && orderObj is Order order)
        {
            CurrentOrder = order;
        }
    }

    [RelayCommand]
    private async Task ConfirmOrderAsync()
    {
        try
        {
            var confirmedOrder = await _orderService.PlaceOrderAsync(CurrentOrder);

            var parameters = new Dictionary<string, object>
            {
                { "order", confirmedOrder },
                { "isNewOrder", true }
            };

            await _navigationService.NavigateToAsync(
                "../orderconfirmation", parameters);
        }
        catch (Exception ex)
        {
            await _navigationService.DisplayAlertAsync(
                "Tilausvirhe",
                $"Tilauksen lähettäminen epäonnistui: {ex.Message}",
                "OK");
        }
    }

    [RelayCommand]
    private async Task CancelCheckoutAsync()
    {
        bool confirm = await _navigationService.DisplayConfirmAsync(
            "Peruuta tilaus",
            "Haluatko varmasti peruuttaa tilauksen?",
            "Kyllä", "Ei");

        if (confirm)
        {
            await _navigationService.GoBackAsync();
        }
    }
}

Reittikohtaiset vakiot

Lopuksi vielä yksi käytännön vinkki: pidä reittimerkkijonot vakioissa. Se säästää monelta kirjoitusvirheeltä ja tekee refaktoroinnista paljon helpompaa:

// AppRoutes.cs
public static class AppRoutes
{
    // Shell-hierarkian reitit
    public const string Home = "//home";
    public const string Products = "//products/productlist";
    public const string Favorites = "//products/favorites";
    public const string Categories = "//products/categories";
    public const string Settings = "//settings";

    // Globaalit reitit (suhteelliset)
    public const string ProductDetails = "productdetails";
    public const string EditProduct = "editproduct";
    public const string Cart = "cart";
    public const string Checkout = "checkout";
    public const string OrderConfirmation = "orderconfirmation";
    public const string Login = "login";
    public const string Register = "register";
    public const string UserProfile = "userprofile";
}

// Käyttö ViewModelissa
await _navigationService.NavigateToAsync(AppRoutes.ProductDetails, parameters);
await _navigationService.NavigateToAsync(AppRoutes.Home);

Yhteenveto

Shell-navigointi on .NET MAUI -kehityksen tehokkain navigointimalli, ja tämä opas on kattanut kaikki sen keskeiset osa-alueet — rakenteen perusteista MVVM-integraatioon ja .NET MAUI 10:n uusiin ominaisuuksiin.

Tässä tärkeimmät periaatteet tiivistettynä:

  • Käytä Shell-hierarkiaa sovelluksen visuaalisen rakenteen määrittelyyn. FlyoutItem, Tab ja ShellContent muodostavat selkeän rakenteen.
  • Rekisteröi globaalit reitit Routing.RegisterRoute-metodilla ja pidä reittimerkkijonot vakioissa.
  • Käytä GoToAsync-metodia navigointiin. Absoluuttiset reitit (//) tyhjentävät navigointipinon, suhteelliset reitit lisäävät pinoon ja .. poistaa pinosta.
  • Suosi IQueryAttributable-rajapintaa parametrien vastaanottamiseen — se on turvallisempi NativeAOT-ympäristössä.
  • Käytä sanakirjapohjaista navigointia monimutkaisten objektien välittämiseen ilman serialisointia.
  • Abstrahoi navigointipalvelu rajapinnan taakse testattavuuden vuoksi.
  • Hyödynnä elinkaarimetodeja oikein: OnAppearing tietojen lataamiseen, OnNavigatedTo jälkitoimenpiteisiin ja Shell.Navigating suojaamiseen.
  • Käytä SearchHandleria hakutoiminnallisuuden integrointiin Shell-navigointipalkkiin.

Selkeään navigointiarkkitehtuuriin panostaminen jo projektin alkuvaiheessa maksaa itsensä moninkertaisesti takaisin, kun sovellus kasvaa. Se on yksi niistä päätöksistä, joita ei koskaan tule katumaan.

Tietoa Kirjoittajasta Editorial Team

Our team of expert writers and editors.