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:
- Shell.Navigating — Shell-tapahtuma ennen navigointia
- A.OnNavigatingFrom — Lähtösivu saa tiedon navigoinnista
- A.OnDisappearing — Lähtösivu häviää näkyvistä
- B.OnAppearing — Kohdesivu tulee näkyviin
- B.OnNavigatedTo — Navigointi kohdesivulle valmis
- A.OnNavigatedFrom — Lähtösivulta navigointi valmis
- 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.