Hai să fim sinceri: navigarea într-o aplicație mobilă e unul dintre acele subiecte care par triviale până când ajungi la al patrulea ecran și deja te-ai pierdut prin NavigationPage-uri imbricate. Aici intră în scenă Shell — coloana vertebrală a navigării în .NET MAUI și, în opinia mea, cea mai bună treabă pe care echipa MAUI a făcut-o moștenind ceva din Xamarin.Forms.
Spre deosebire de vechiul NavigationPage, Shell oferă o ierarhie URI-based care simplifică deep linking-ul, gestionarea tab bar-urilor și pagini modale — toate cu un model declarativ în XAML și un API tipat în code-behind. Practic, gândește-te la aplicația ta ca la un mic site web cu rute proprii.
În ghidul ăsta pentru .NET 9 (mai 2026) acoperim tot ce ai nevoie ca să construiești o experiență de navigare robustă: rute, parametri complecși, deep linking de la notificări push și URL-uri externe, pagini modale, integrare MVVM cu CommunityToolkit.Mvvm și pattern-uri pentru testare. So, să trecem la treabă.
Ce este Shell și de ce să îl folosești
Shell e un container de navigare care implementează cele mai comune pattern-uri UI mobile: flyout menu, tab bar de jos, tab-uri sus și combinații ale lor. Componentele cheie sunt:
Shell— rădăcina aplicației, care conține întreaga ierarhie de navigare.FlyoutItem— un element în meniul lateral (clasicul hamburger menu).TabBar— un set de tab-uri fără flyout.Tab— un tab individual care poate conține mai multeShellContent.ShellContent— pagina efectivă afișată într-un tab.
Avantajul major? Fiecare destinație are o rută URI, deci poți naviga prin "//main/products?id=42" exact ca pe web. Asta face deep linking-ul, restaurarea stării și testarea unitară mult mai simple — și, în plus, te scapă de durerile de cap pe care le aveai cu stack-uri de navigare manuale.
Configurarea inițială a unui Shell
Începe prin a edita AppShell.xaml (creat automat de template-ul .NET MAUI) și adaugă structura de bază:
<?xml version="1.0" encoding="UTF-8" ?>
<Shell x:Class="MyApp.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MyApp.Views"
Shell.FlyoutBehavior="Flyout"
Title="MyApp">
<FlyoutItem Title="Acasă" Icon="home.png">
<ShellContent Route="home"
ContentTemplate="{DataTemplate views:HomePage}" />
</FlyoutItem>
<FlyoutItem Title="Produse" Icon="cart.png">
<Tab Title="Listă" Icon="list.png">
<ShellContent Route="products"
ContentTemplate="{DataTemplate views:ProductsPage}" />
</Tab>
<Tab Title="Favorite" Icon="star.png">
<ShellContent Route="favorites"
ContentTemplate="{DataTemplate views:FavoritesPage}" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Setări" Icon="settings.png">
<ShellContent Route="settings"
ContentTemplate="{DataTemplate views:SettingsPage}" />
</FlyoutItem>
</Shell>
Atributul Route e esențial — el devine identificatorul URI pe care îl vei folosi în GoToAsync. Dacă îl uiți (cum am făcut eu prima dată), vei primi excepții misterioase care nu îți spun mare lucru despre ce s-a întâmplat.
Înregistrarea rutelor pentru pagini de detaliu
Paginile care nu apar în meniu (de exemplu, ProductDetailsPage) trebuie înregistrate manual în code-behind-ul Shell-ului:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("products/details", typeof(ProductDetailsPage));
Routing.RegisterRoute("products/details/reviews", typeof(ReviewsPage));
Routing.RegisterRoute("checkout", typeof(CheckoutPage));
Routing.RegisterRoute("checkout/payment", typeof(PaymentPage));
}
}
Convenția cu / creează o ierarhie: navigarea către products/details împinge pagina peste products, iar butonul Back se întoarce automat la lista de produse. Simplu și frumos.
Navigare programatică cu GoToAsync
Toate operațiile de navigare folosesc Shell.Current.GoToAsync, care acceptă URI-uri relative sau absolute:
// Rută relativă — push peste pagina curentă
await Shell.Current.GoToAsync("details");
// Rută absolută — resetează stack-ul de navigare
await Shell.Current.GoToAsync("//home");
// Înapoi cu un nivel
await Shell.Current.GoToAsync("..");
// Înapoi cu două niveluri
await Shell.Current.GoToAsync("../..");
// Înapoi și apoi push pe altă rută
await Shell.Current.GoToAsync("../search");
Diferența dintre // și o rută relativă e crucială. // înseamnă „du-te la o secțiune principală a Shell-ului” și șterge stack-ul curent — perfect după login. O rută fără slash e un push pe stack-ul existent. Dacă încurci cele două (și se va întâmpla), o să ajungi cu un buton Back care duce înapoi la ecranul de login. Nu e ideal.
Transmiterea parametrilor între pagini
În .NET MAUI 9 ai trei moduri de a trimite date la pagina destinație. Pentru tipuri primitive (string, int, bool) folosește query string:
await Shell.Current.GoToAsync($"products/details?id={product.Id}&source=list");
Pe pagina destinație, decorezi ViewModel-ul cu [QueryProperty]:
[QueryProperty(nameof(ProductId), "id")]
[QueryProperty(nameof(Source), "source")]
public partial class ProductDetailsViewModel : ObservableObject
{
[ObservableProperty]
private int productId;
[ObservableProperty]
private string? source;
partial void OnProductIdChanged(int value)
{
// Se apelează automat când parametrul este setat
_ = LoadProductAsync(value);
}
private async Task LoadProductAsync(int id) { /* ... */ }
}
Trimiterea de obiecte complexe
Pentru obiecte complexe nu folosi serializare JSON în URI — devine fragil și expune date. Folosește dicționarul IDictionary<string, object>:
var navigationParameter = new Dictionary<string, object>
{
{ "Product", selectedProduct },
{ "Discount", currentDiscount }
};
await Shell.Current.GoToAsync("details", navigationParameter);
În ViewModel-ul destinație implementează IQueryAttributable:
public partial class ProductDetailsViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private Product? product;
[ObservableProperty]
private Discount? discount;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("Product", out var p) && p is Product product)
Product = product;
if (query.TryGetValue("Discount", out var d) && d is Discount discount)
Discount = discount;
}
}
Atenție: ApplyQueryAttributes e apelat înainte de OnNavigatedTo, deci nu te baza pe view-ul fizic atunci. Dacă ai nevoie să ajustezi UI-ul după ce e atașat, folosește evenimentul NavigatedTo al paginii. Asta e una dintre acele capcane subtile pe care le descoperi de obicei doar după două ore de debugging.
Deep linking: pornirea aplicației de la un URL extern
Deep linking-ul îți permite să deschizi aplicația direct la o pagină specifică dintr-un email, notificare push sau link web. Pe Android se configurează prin Intent Filters, iar pe iOS prin Universal Links.
Android: configurarea intent filter
În Platforms/Android/MainActivity.cs adaugă atributul:
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
[IntentFilter(new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "https",
DataHost = "myapp.example.com",
DataPathPrefix = "/products")]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
HandleDeepLink(intent);
}
private void HandleDeepLink(Intent? intent)
{
var data = intent?.Data?.ToString();
if (string.IsNullOrEmpty(data)) return;
// https://myapp.example.com/products/42 → //products/details?id=42
var uri = new Uri(data);
var segments = uri.AbsolutePath.Trim('/').Split('/');
if (segments.Length == 2 && segments[0] == "products" && int.TryParse(segments[1], out var id))
{
MainThread.BeginInvokeOnMainThread(async () =>
await Shell.Current.GoToAsync($"//products/details?id={id}"));
}
}
}
iOS: Universal Links
Pe iOS, adaugă în Info.plist intrarea com.apple.developer.associated-domains cu applinks:myapp.example.com și override-ează ContinueUserActivity în AppDelegate:
public override bool ContinueUserActivity(UIApplication application,
NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler)
{
if (userActivity.ActivityType == NSUserActivityType.BrowsingWeb &&
userActivity.WebPageUrl is NSUrl url)
{
var path = url.Path;
if (path?.StartsWith("/products/") == true)
{
var id = path.Substring("/products/".Length);
MainThread.BeginInvokeOnMainThread(async () =>
await Shell.Current.GoToAsync($"//products/details?id={id}"));
return true;
}
}
return false;
}
Pentru ca Universal Links să funcționeze, trebuie să găzduiești și fișierul apple-app-site-association pe domeniul tău, iar Apple îl validează la prima instalare a aplicației. Onestly, prima oară când am setat asta, am pierdut o jumătate de zi pentru că uitasem să servesc fișierul cu Content-Type: application/json. Detaliu mic, dar te oprește complet.
Pagini modale cu Shell
O pagină modală apare peste tot conținutul curent (fără tab bar) și e ideală pentru fluxuri de tip onboarding, login sau formulare de creare. În Shell, marchezi o rută ca modală prin Shell.PresentationMode:
// În XAML al paginii destinație:
<ContentPage ...
Shell.PresentationMode="ModalAnimated">
Sau dinamic, la înregistrarea rutei:
Routing.RegisterRoute("login", typeof(LoginPage));
// Apoi navigare:
await Shell.Current.GoToAsync("login");
Valori posibile pentru PresentationMode:
NotAnimated— push obișnuit fără animație.Animated— push obișnuit cu animație.ModalNotAnimated— modal fără animație.ModalAnimated— modal cu animație (slide-up).
Hook-uri de navigare: anularea și logging-ul
Shell expune două evenimente cheie pentru a intercepta navigarea: Navigating (înainte) și Navigated (după). Le poți folosi pentru telemetrie, validare sau anulare:
public AppShell()
{
InitializeComponent();
this.Navigating += OnShellNavigating;
this.Navigated += OnShellNavigated;
}
private async void OnShellNavigating(object? sender, ShellNavigatingEventArgs e)
{
// Verifică dacă utilizatorul are sesiune validă
if (e.Target.Location.OriginalString.Contains("checkout") &&
!await _authService.IsAuthenticatedAsync())
{
e.Cancel();
await Shell.Current.GoToAsync("login");
}
}
private void OnShellNavigated(object? sender, ShellNavigatedEventArgs e)
{
_telemetry.TrackPageView(e.Current.Location.OriginalString);
}
În .NET 9 evenimentul Navigating suportă și GetDeferral() pentru operații asincrone (de exemplu, prompt de confirmare):
private async void OnShellNavigating(object? sender, ShellNavigatingEventArgs e)
{
if (e.Source == ShellNavigationSource.Pop && _hasUnsavedChanges)
{
var token = e.GetDeferral();
var confirm = await Shell.Current.DisplayAlert(
"Modificări nesalvate",
"Sigur dorești să ieși fără să salvezi?",
"Da", "Nu");
if (!confirm) e.Cancel();
token.Complete();
}
}
Integrare MVVM cu CommunityToolkit.Mvvm
Pentru un cod curat, evită Shell.Current.GoToAsync direct în view-uri. Creează un serviciu de navigare injectabil:
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("..");
}
Înregistrează-l în MauiProgram.cs și injectează-l în ViewModel:
// MauiProgram.cs
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
builder.Services.AddTransient<ProductsViewModel>();
builder.Services.AddTransient<ProductDetailsViewModel>();
// ProductsViewModel.cs
public partial class ProductsViewModel : ObservableObject
{
private readonly INavigationService _navigation;
public ProductsViewModel(INavigationService navigation)
{
_navigation = navigation;
}
[RelayCommand]
private async Task OpenDetailsAsync(Product product)
{
await _navigation.GoToAsync("details", new Dictionary<string, object>
{
{ "Product", product }
});
}
}
Avantajul? ViewModel-ul nu mai depinde de tipul Shell și poate fi testat unitar prin mockuirea lui INavigationService. Pentru mine, asta a fost momentul în care testele de ViewModel au început să fie efectiv utile, nu doar un exercițiu de bifat în CI.
Personalizarea aspectului flyout-ului
Header-ul, footer-ul și template-ul itemilor din flyout pot fi customizate complet în XAML. Iată un header cu avatar utilizator:
<Shell.FlyoutHeader>
<Grid HeightRequest="180" BackgroundColor="{StaticResource Primary}">
<VerticalStackLayout Padding="20" VerticalOptions="End">
<Image Source="user_avatar.png"
HeightRequest="60" WidthRequest="60"
Aspect="AspectFill" />
<Label Text="{Binding UserName}"
TextColor="White" FontSize="18" FontAttributes="Bold" />
<Label Text="{Binding UserEmail}"
TextColor="White" FontSize="13" Opacity="0.85" />
</VerticalStackLayout>
</Grid>
</Shell.FlyoutHeader>
Pentru a stiliza fiecare item din flyout, definește un DataTemplate setat pe Shell.ItemTemplate.
Pattern-uri pentru aplicații cu autentificare
Cea mai comună arhitectură pentru aplicații cu login e să ai două structuri Shell separate: una pentru fluxul de autentificare și una pentru conținutul autenticat. Pentru a comuta între ele, schimbă Application.Current.MainPage:
// În LoginViewModel după login reușit:
Application.Current.MainPage = new AppShell();
await Shell.Current.GoToAsync("//home");
// La logout:
Application.Current.MainPage = new LoginShell();
Alternativ, păstrează un singur AppShell și folosește FlyoutItem.IsVisible bound la starea de autentificare ca să ascunzi rutele protejate. Eu personal prefer prima variantă — e mai clar separată și mai ușor de raționat când debugzi.
Greșeli frecvente și cum să le eviți
- Folosirea
Navigation.PushAsyncîn loc deGoToAsync— în Shell, API-ul vechi încă funcționează, dar nu actualizează URI-ul curent și rupe deep linking-ul. - Înregistrarea rutei după pornirea aplicației —
Routing.RegisterRoutetrebuie chemat în constructorulAppShell, nu lazy. - Trimiterea de string-uri JSON ca parametri — limita de URL e de aproximativ 2000 caractere pe Android. Folosește
IDictionary. - Apelarea
GoToAsyncdintr-un thread non-UI — wrappează cuMainThread.BeginInvokeOnMainThreadsau cuDispatcher.DispatchAsync. - Setarea proprietăților de Shell înainte ca pagina să fie atașată — proprietăți precum
Shell.NavBarIsVisibletrebuie setate în XAML sau înOnAppearing.
Testarea unitară a navigării
Pentru că am abstractizat navigarea într-un serviciu, putem testa logica fără să rulăm aplicația real:
public class ProductsViewModelTests
{
[Fact]
public async Task OpenDetails_NavigatesWithProductParameter()
{
// Arrange
var navigationMock = new Mock<INavigationService>();
var sut = new ProductsViewModel(navigationMock.Object);
var product = new Product { Id = 42 };
// Act
await sut.OpenDetailsCommand.ExecuteAsync(product);
// Assert
navigationMock.Verify(n => n.GoToAsync(
"details",
It.Is<IDictionary<string, object>>(d =>
d.ContainsKey("Product") && d["Product"] == product)),
Times.Once);
}
}
Întrebări frecvente
Pot folosi Shell împreună cu NavigationPage clasic?
Da, dar doar în interiorul unei rute Shell. Poți declara un ShellContent care încarcă un NavigationPage, dar pierzi avantajele URI-based navigation. Pentru aplicații noi, folosește exclusiv Shell. Pentru migrarea Xamarin.Forms, vezi ghidul nostru complet de migrare.
Cum schimb tab-ul curent programatic?
Folosește Shell.Current.CurrentItem sau navighează către ruta absolută a tab-ului: await Shell.Current.GoToAsync("//favorites"). Acesta resetează stack-ul tab-ului destinație, ceea ce e de obicei comportamentul dorit.
De ce nu se actualizează parametrii când navighez la aceeași pagină?
Shell reutilizează instanța paginii în mod implicit pentru rute identice. Dacă ai nevoie de o instanță nouă la fiecare navigare, înregistrează ruta cu un factory: Routing.RegisterRoute("details", typeof(ProductDetailsPage)) și asigură-te că ViewModel-ul e înregistrat ca Transient în DI.
Cum gestionez butonul Back hardware pe Android cu Shell?
Override-ează metoda OnBackButtonPressed pe pagina ta sau gestionează evenimentul Shell.Navigating cu e.Source == ShellNavigationSource.Pop. Returnarea valorii true din OnBackButtonPressed previne comportamentul implicit.
Shell funcționează pe Windows și macOS?
Da. Pe Windows, flyout-ul devine un NavigationView nativ WinUI, iar tab-urile devin pivot. Pe macOS Catalyst comportamentul e similar cu iPad. Singura diferență notabilă: gesturile swipe-back nu există pe desktop, deci asigură-te că ai mereu un buton Back vizibil.
Concluzie
Shell transformă navigarea în .NET MAUI dintr-o sursă comună de bug-uri într-un sistem declarativ, testabil și prietenos cu deep linking-ul. Cheia? Tratează rutele ca pe URL-uri reale: ierarhice, parametrizabile și separate de view-uri prin servicii injectate. Combinat cu CommunityToolkit.Mvvm și DI, obții o arhitectură care scalează de la prototip la aplicație de producție fără rescrieri majore.
Pentru pasul următor, integrează-ți noul sistem de navigare cu pipeline-ul CI/CD și acoperă-l cu UI tests automate — ambele subiecte pe care le-am tratat în articole anterioare din această serie.