Εισαγωγή
Ας το πούμε ξεκάθαρα: η πλοήγηση είναι η ραχοκοκαλιά κάθε εφαρμογής κινητών. Δεν έχει σημασία πόσο εντυπωσιακό είναι το UI ή πόσο γρήγορα φορτώνονται τα δεδομένα — αν ο χρήστης χαθεί μέσα στην εφαρμογή σας ή κολλήσει στη μετάβαση μεταξύ σελίδων, θα φύγει. Απλά.
Στο .NET MAUI, η Microsoft έχει φτιάξει ένα αρκετά ευέλικτο σύστημα πλοήγησης βασισμένο στο Shell, με URI-based routing, modal navigation, deep linking και πολλά ακόμα. Ειλικρινά, τα πρώτα releases είχαν τα θέματά τους, αλλά τα πράγματα έχουν βελτιωθεί αισθητά.
Σε αυτόν τον οδηγό θα εξερευνήσουμε σε βάθος την αρχιτεκτονική πλοήγησης στο .NET MAUI. Θα ξεκινήσουμε από τα θεμελιώδη του Shell, θα δούμε routing και query parameters, θα αναλύσουμε πώς μπαίνει η πλοήγηση στο MVVM pattern μέσω dependency injection, και θα φτάσουμε μέχρι deep linking, modal navigation και τις βελτιώσεις του .NET 10. Κάθε ενότητα έχει πρακτικά παραδείγματα κώδικα — έτοιμα να τα δοκιμάσετε στα projects σας.
Κατανόηση του .NET MAUI Shell
Το Shell είναι ο βασικός μηχανισμός πλοήγησης στο .NET MAUI και η επίσημα προτεινόμενη προσέγγιση από τη Microsoft. Ουσιαστικά αντικαθιστά τα παλαιότερα μοτίβα (όπως το NavigationPage) δίνοντάς σας ένα ενιαίο, δηλωτικό τρόπο για να ορίσετε την οπτική ιεραρχία μιας εφαρμογής.
Η Οπτική Ιεραρχία του Shell
Το Shell χρησιμοποιεί τρία βασικά στοιχεία για να δομήσει την πλοήγηση:
- FlyoutItem: Αντιπροσωπεύει ένα ή περισσότερα στοιχεία στο flyout menu. Κάθε FlyoutItem είναι παιδί του Shell.
- Tab: Αντιπροσωπεύει ομαδοποιημένο περιεχόμενο σε tabs στο κάτω μέρος. Κάθε Tab είναι παιδί ενός FlyoutItem.
- ShellContent: Αντιπροσωπεύει τα ContentPage objects κάθε tab. Κάθε ShellContent είναι παιδί ενός Tab.
Ας δούμε πώς μοιάζει μια τυπική δομή AppShell:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MyApp.Views"
x:Class="MyApp.AppShell"
FlyoutBehavior="Flyout">
<!-- Flyout Header -->
<Shell.FlyoutHeader>
<Grid HeightRequest="120" BackgroundColor="{StaticResource Primary}">
<Label Text="Η Εφαρμογή Μου"
TextColor="White"
FontSize="24"
VerticalOptions="Center"
HorizontalOptions="Center" />
</Grid>
</Shell.FlyoutHeader>
<!-- Main Navigation -->
<FlyoutItem Title="Αρχική" Icon="home.png">
<ShellContent ContentTemplate="{DataTemplate views:HomePage}" />
</FlyoutItem>
<FlyoutItem Title="Προϊόντα" Icon="products.png">
<Tab Title="Κατάλογος">
<ShellContent ContentTemplate="{DataTemplate views:CatalogPage}" />
</Tab>
<Tab Title="Αγαπημένα">
<ShellContent ContentTemplate="{DataTemplate views:FavoritesPage}" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Ρυθμίσεις" Icon="settings.png">
<ShellContent ContentTemplate="{DataTemplate views:SettingsPage}" />
</FlyoutItem>
</Shell>
Σημειώστε τη χρήση του ContentTemplate αντί για Content. Αυτό ενεργοποιεί τη lazy loading — κάθε σελίδα δημιουργείται μόνο όταν ο χρήστης πλοηγηθεί σε αυτήν. Αυτό κάνει τεράστια διαφορά στον χρόνο εκκίνησης, ειδικά σε εφαρμογές με πολλές σελίδες.
Εγγραφή Routes
Εκτός από τις σελίδες που ορίζονται στην ιεραρχία του Shell, μπορείτε να εγγράψετε επιπλέον routes μέσω κώδικα. Αυτό χρειάζεται κυρίως για σελίδες λεπτομερειών ή σελίδες που δεν ανήκουν στη βασική πλοήγηση:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Εγγραφή routes για σελίδες που δεν ανήκουν στην ιεραρχία
Routing.RegisterRoute("productdetails", typeof(ProductDetailsPage));
Routing.RegisterRoute("checkout", typeof(CheckoutPage));
Routing.RegisterRoute("orderconfirmation", typeof(OrderConfirmationPage));
Routing.RegisterRoute("profile/edit", typeof(EditProfilePage));
}
}
Τα registered routes δημιουργούν σχετικά μονοπάτια — πρακτικά σημαίνει ότι μπορούν να ενεργοποιηθούν από οπουδήποτε μέσα στην εφαρμογή, χωρίς να χρειάζεται αυστηρή ιεραρχία. Αρκετά βολικό.
URI-Based Navigation με GoToAsync
Η μέθοδος GoToAsync είναι η καρδιά της πλοήγησης στο Shell. Λειτουργεί με URI strings και αυτό κάνει τη μετάβαση μεταξύ σελίδων ευέλικτη και — πολύ σημαντικό — testable.
Βασική Πλοήγηση
// Πλοήγηση σε registered route
await Shell.Current.GoToAsync("productdetails");
// Πλοήγηση σε absolute route
await Shell.Current.GoToAsync("//catalog");
// Πλοήγηση πίσω
await Shell.Current.GoToAsync("..");
// Πλοήγηση πίσω στο root
await Shell.Current.GoToAsync("//");
// Πλοήγηση σε nested route
await Shell.Current.GoToAsync("productdetails/reviews");
Η σημαντική διάκριση εδώ: τα relative routes (χωρίς //) προσθέτουν τη σελίδα στο navigation stack, ενώ τα absolute routes (με //) αντικαθιστούν ολόκληρο το navigation stack. Κρατήστε το αυτό γιατί μπορεί να σας μπερδέψει αν δεν το έχετε ξεκαθαρίσει.
Πέρασμα Query Parameters
Ένα από τα πιο ισχυρά χαρακτηριστικά του Shell navigation είναι η δυνατότητα μετάδοσης δεδομένων μέσω query parameters. Υπάρχουν δύο βασικοί τρόποι:
Μέθοδος 1: String-Based Query Parameters
// Αποστολή απλών δεδομένων μέσω URI
await Shell.Current.GoToAsync($"productdetails?productId={product.Id}&source=catalog");
// Πολλαπλές παράμετροι
await Shell.Current.GoToAsync(
$"checkout?cartTotal={cart.Total}&itemCount={cart.Items.Count}¤cy=EUR");
Μέθοδος 2: Dictionary-Based Parameters (Προτεινόμενη)
// Αποστολή σύνθετων αντικειμένων μέσω dictionary
var navigationParameter = new Dictionary<string, object>
{
{ "product", selectedProduct },
{ "relatedProducts", relatedProductsList },
{ "userPreferences", currentUserPrefs }
};
await Shell.Current.GoToAsync("productdetails", navigationParameter);
Η δεύτερη μέθοδος είναι σαφώς καλύτερη για σύνθετα αντικείμενα. Αποφεύγετε τη σειριοποίηση σε string και μπορείτε να στείλετε ολόκληρα objects χωρίς ιδρώτα. Στην πράξη, αυτή χρησιμοποιώ σχεδόν πάντα.
Λήψη Δεδομένων Πλοήγησης με IQueryAttributable
Για να λάβετε τα δεδομένα πλοήγησης στη σελίδα-στόχο, η Microsoft προτείνει τη χρήση του interface IQueryAttributable. Αυτή η προσέγγιση είναι trim-safe και συμβατή με NativeAOT, σε αντίθεση με το παλαιότερο QueryPropertyAttribute που βασίζεται σε reflection (και ειλικρινά, καλύτερα να το ξεχάσετε εκείνο).
Υλοποίηση στο ViewModel
public class ProductDetailsViewModel : ObservableObject, IQueryAttributable
{
private Product _product;
private string _productId;
private bool _isLoading;
public Product Product
{
get => _product;
set => SetProperty(ref _product, value);
}
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// Λήψη σύνθετου αντικειμένου
if (query.TryGetValue("product", out var productObj)
&& productObj is Product product)
{
Product = product;
}
// Εναλλακτικά: λήψη ID και φόρτωση από API
else if (query.TryGetValue("productId", out var idObj)
&& idObj is string id)
{
_productId = id;
_ = LoadProductAsync(id);
}
}
private async Task LoadProductAsync(string productId)
{
IsLoading = true;
try
{
Product = await _productService.GetProductByIdAsync(productId);
}
finally
{
IsLoading = false;
}
}
}
Υλοποίηση στη Σελίδα (Page)
Η σελίδα μπορεί κι αυτή να υλοποιήσει το IQueryAttributable, αν και συνήθως προτιμάμε να το κάνουμε στο ViewModel:
public partial class ProductDetailsPage : ContentPage, IQueryAttributable
{
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("productId", out var idObj))
{
string productId = idObj.ToString();
// Ρύθμιση UI ή BindingContext
Title = $"Προϊόν #{productId}";
}
}
}
Γνωστές Παγίδες
Εδώ υπάρχει μια παγίδα που μπορεί να σας ταλαιπωρήσει αρκετά: τα query parameters μπορεί να διατηρηθούν κατά την πίσω πλοήγηση. Δηλαδή, η ApplyQueryAttributes μπορεί να κληθεί ξανά όταν ο χρήστης πατήσει back. Αν δεν το χειριστείτε, μπορεί να κάνετε διπλή φόρτωση δεδομένων ή χειρότερα:
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// Αγνοήστε κενά query attributes (πιθανό back navigation)
if (query == null || query.Count == 0)
return;
// Προστασία από διπλή φόρτωση
if (query.TryGetValue("productId", out var idObj)
&& idObj is string newId
&& newId != _productId)
{
_productId = newId;
_ = LoadProductAsync(newId);
}
}
MVVM Navigation Pattern με Dependency Injection
Σε μια σωστά αρχιτεκτονημένη εφαρμογή MVVM, η πλοήγηση δεν πρέπει να εξαρτάται άμεσα από τα Views. Αντ' αυτού, χρησιμοποιούμε ένα NavigationService που εγχέεται στα ViewModels μέσω dependency injection. Αυτό ίσως ακούγεται σαν over-engineering στην αρχή, αλλά θα σας σώσει πολύ χρόνο μακροπρόθεσμα.
Ορισμός του INavigationService
public interface INavigationService
{
Task NavigateToAsync(string route);
Task NavigateToAsync(string route, IDictionary<string, object> parameters);
Task GoBackAsync();
Task GoToRootAsync();
}
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 GoToRootAsync()
{
await Shell.Current.GoToAsync("//");
}
}
Εγγραφή στο DI Container
Η εγγραφή γίνεται στο MauiProgram.cs, μαζί με τα Views και ViewModels:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Εγγραφή υπηρεσιών
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddSingleton<IAuthService, AuthService>();
// Εγγραφή ViewModels
builder.Services.AddTransient<HomeViewModel>();
builder.Services.AddTransient<CatalogViewModel>();
builder.Services.AddTransient<ProductDetailsViewModel>();
builder.Services.AddTransient<CheckoutViewModel>();
// Εγγραφή Pages
builder.Services.AddTransient<HomePage>();
builder.Services.AddTransient<CatalogPage>();
builder.Services.AddTransient<ProductDetailsPage>();
builder.Services.AddTransient<CheckoutPage>();
return builder.Build();
}
}
Χρήση στο ViewModel
public partial class CatalogViewModel : ObservableObject
{
private readonly INavigationService _navigationService;
private readonly IProductService _productService;
public CatalogViewModel(
INavigationService navigationService,
IProductService productService)
{
_navigationService = navigationService;
_productService = productService;
}
[ObservableProperty]
private ObservableCollection<Product> _products = new();
[RelayCommand]
private async Task ViewProductDetails(Product product)
{
var parameters = new Dictionary<string, object>
{
{ "product", product }
};
await _navigationService.NavigateToAsync("productdetails", parameters);
}
[RelayCommand]
private async Task GoToCheckout()
{
await _navigationService.NavigateToAsync("checkout");
}
}
Τα πλεονεκτήματα αυτής της αρχιτεκτονικής είναι τεράστια. Τα ViewModels μπορούν πλέον να δοκιμαστούν μοναδιαία χωρίς εξάρτηση από το Shell, και η λογική πλοήγησης αλλάζει σε ένα μόνο σημείο. Αν αύριο αλλάξετε εντελώς το navigation framework, τα ViewModels σας δεν θα χρειαστούν αλλαγή.
Modal Navigation και Popup Patterns
Δεν γίνονται όλα με απλή stack-based πλοήγηση. Κάποιες φορές χρειάζεστε modal σελίδες — δηλαδή πλήρεις οθόνες που εμποδίζουν τον χρήστη να πλοηγηθεί αλλού μέχρι να ολοκληρώσει κάποια ενέργεια. Σκεφτείτε login screens ή φόρμες επεξεργασίας.
Modal Navigation στο Shell
Στο Shell, η modal παρουσίαση ελέγχεται μέσω του Shell.PresentationMode property:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MyApp.Views.LoginPage"
Shell.PresentationMode="ModalAnimated">
<VerticalStackLayout Spacing="16" Padding="24">
<Label Text="Σύνδεση" FontSize="28" FontAttributes="Bold" />
<Entry Placeholder="Email"
Text="{Binding Email}"
Keyboard="Email" />
<Entry Placeholder="Κωδικός"
Text="{Binding Password}"
IsPassword="True" />
<Button Text="Είσοδος"
Command="{Binding LoginCommand}" />
<Button Text="Ακύρωση"
Command="{Binding CancelCommand}"
BackgroundColor="Transparent"
TextColor="Gray" />
</VerticalStackLayout>
</ContentPage>
Οι διαθέσιμες τιμές του PresentationMode:
- NotAnimated: Κανονική πλοήγηση χωρίς animation
- Animated: Κανονική πλοήγηση με animation (η προεπιλογή)
- Modal: Modal παρουσίαση χωρίς animation
- ModalAnimated: Modal παρουσίαση με animation
- ModalNotAnimated: Modal παρουσίαση χωρίς animation
Popup Dialogs
Για πιο απλές αλληλεπιδράσεις, το .NET MAUI σας δίνει τρεις built-in μεθόδους popup. Τίποτα φανταχτερό, αλλά καλύπτουν τις περισσότερες βασικές ανάγκες:
// Alert dialog
bool answer = await DisplayAlert(
"Διαγραφή",
"Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το στοιχείο;",
"Ναι",
"Όχι");
if (answer)
{
await DeleteItemAsync();
}
// Action sheet
string action = await DisplayActionSheet(
"Επιλέξτε ενέργεια",
"Ακύρωση",
"Διαγραφή",
"Κοινοποίηση",
"Αντιγραφή",
"Επεξεργασία");
// Prompt dialog
string name = await DisplayPromptAsync(
"Μετονομασία",
"Εισάγετε νέο όνομα:",
initialValue: currentName,
maxLength: 50,
keyboard: Keyboard.Text);
Σύνθετα Popups με MAUI Community Toolkit
Αν οι built-in μέθοδοι δεν αρκούν (και σε πραγματικά projects συχνά δεν αρκούν), το .NET MAUI Community Toolkit προσφέρει το Popup control με πλήρη προσαρμογή:
// Ορισμός popup XAML
public partial class ProductFilterPopup : Popup
{
public ProductFilterPopup(ProductFilterViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
private void OnApplyClicked(object sender, EventArgs e)
{
Close(BindingContext as ProductFilterViewModel);
}
private void OnCancelClicked(object sender, EventArgs e)
{
Close(null);
}
}
// Χρήση μέσω PopupService (DI)
public class CatalogViewModel : ObservableObject
{
private readonly IPopupService _popupService;
public CatalogViewModel(IPopupService popupService)
{
_popupService = popupService;
}
[RelayCommand]
private async Task ShowFilters()
{
var result = await _popupService
.ShowPopupAsync<ProductFilterPopup>();
if (result is ProductFilterViewModel filters)
{
await ApplyFiltersAsync(filters);
}
}
}
Deep Linking: Android App Links και iOS Universal Links
Το deep linking επιτρέπει σε εξωτερικά URLs να ανοίγουν συγκεκριμένες σελίδες μέσα στην εφαρμογή σας. Είναι κρίσιμο για marketing campaigns, push notifications και sharing λειτουργίες. Η ρύθμισή του, ωστόσο, θέλει υπομονή — πρέπει να κάνετε ξεχωριστές ρυθμίσεις για Android και iOS.
Ρύθμιση Android App Links
Στο Android, τα App Links χρησιμοποιούν verified HTTP/HTTPS URLs. Πρώτα, προσθέστε ένα Intent Filter στο AndroidManifest.xml ή (πιο συχνά) μέσω attribute στο MainActivity:
[Activity(
Theme = "@style/Maui.SplashTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize
| ConfigChanges.Orientation
| ConfigChanges.UiMode)]
[IntentFilter(
new[] { Android.Content.Intent.ActionView },
AutoVerify = true,
Categories = new[]
{
Android.Content.Intent.CategoryDefault,
Android.Content.Intent.CategoryBrowsable
},
DataScheme = "https",
DataHost = "www.myapp.com",
DataPathPrefix = "/products")]
public class MainActivity : MauiAppCompatActivity
{
}
Θα χρειαστείτε επίσης ένα Digital Asset Links αρχείο στο domain σας (/.well-known/assetlinks.json):
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.mycompany.myapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}]
Ρύθμιση iOS Universal Links
Στο iOS, τα Universal Links χρειάζονται δύο πράγματα: ένα apple-app-site-association αρχείο στο server σας και το Associated Domains entitlement στην εφαρμογή. Ναι, είναι λίγο πιο περίπλοκο από ό,τι θα θέλαμε.
// Αρχείο apple-app-site-association (στο server, χωρίς .json extension)
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.mycompany.myapp"],
"paths": ["/products/*", "/orders/*", "/profile"]
}
]
}
}
Στο Entitlements.plist:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.myapp.com</string>
</array>
Χειρισμός Deep Links στην Εφαρμογή
Αφού ρυθμίσετε τις δύο πλατφόρμες, πρέπει να χειριστείτε τα εισερχόμενα deep links στο App.xaml.cs. Εδώ γίνεται η "μαγεία" — μετατρέπετε εξωτερικά URLs σε εσωτερική πλοήγηση:
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState activationState)
{
var window = new Window(new AppShell());
// Χειρισμός deep link κατά το άνοιγμα
if (activationState?.State != null
&& activationState.State.TryGetValue(
"deepLinkUri", out var uriObj)
&& uriObj is Uri uri)
{
HandleDeepLink(uri);
}
return window;
}
private async void HandleDeepLink(Uri uri)
{
// Αναμονή μέχρι να φορτώσει πλήρως το Shell
await Task.Delay(500);
string path = uri.AbsolutePath.TrimStart('/');
switch (path)
{
case string p when p.StartsWith("products/"):
string productId = p.Replace("products/", "");
await Shell.Current.GoToAsync(
$"productdetails?productId={productId}");
break;
case "orders":
await Shell.Current.GoToAsync("//orders");
break;
case "profile":
await Shell.Current.GoToAsync("//profile");
break;
default:
await Shell.Current.GoToAsync("//");
break;
}
}
}
Διαχείριση Navigation Stack
Η κατανόηση και ο σωστός χειρισμός του navigation stack είναι κρίσιμα για μια ομαλή εμπειρία χρήστη. Αυτό είναι ένα σημείο που πολλοί developers υποτιμούν — μέχρι που αρχίζουν να εμφανίζονται παράξενα bugs.
Αφαίρεση Σελίδων από το Stack
Μετά από μια ροή πολλαπλών βημάτων (π.χ. checkout flow), συχνά θέλετε να αφαιρέσετε ενδιάμεσες σελίδες ώστε ο χρήστης να μη μπορεί να "γυρίσει πίσω" στη φόρμα πληρωμής μετά την ολοκλήρωσή της:
// Μετά τη ολοκλήρωση ενός checkout flow
// Αφαιρέστε τις ενδιάμεσες σελίδες
// και πλοηγηθείτε στη σελίδα επιβεβαίωσης
await Shell.Current.GoToAsync("../..", animate: true);
await Shell.Current.GoToAsync("orderconfirmation",
new Dictionary<string, object>
{
{ "orderId", newOrder.Id }
});
Αποτροπή Back Navigation
Σε κάποιες περιπτώσεις θέλετε να αποτρέψετε εντελώς το back navigation. Κλασικό παράδειγμα: η σελίδα επιβεβαίωσης παραγγελίας. Δε θέλετε ο χρήστης να πατήσει "πίσω" και να ξαναδεί τη φόρμα πληρωμής:
public partial class OrderConfirmationPage : ContentPage
{
protected override bool OnBackButtonPressed()
{
// Αποτροπή hardware back button (Android)
// Αντί να πάει πίσω, πηγαίνει στην αρχική
MainThread.BeginInvokeOnMainThread(async () =>
{
bool goHome = await DisplayAlert(
"Πλοήγηση",
"Θέλετε να επιστρέψετε στην αρχική σελίδα;",
"Ναι",
"Παραμονή εδώ");
if (goHome)
{
await Shell.Current.GoToAsync("//");
}
});
return true; // true = consumed, don't navigate back
}
}
Navigation Events
Το Shell παρέχει events για να παρεμβάλετε λογική κατά τη διάρκεια της πλοήγησης. Αυτό είναι ιδιαίτερα χρήσιμο για authentication guards — π.χ. να εμποδίσετε πρόσβαση σε σελίδες χωρίς login:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Ακρόαση navigation events
Navigating += OnShellNavigating;
Navigated += OnShellNavigated;
}
private void OnShellNavigating(object sender, ShellNavigatingEventArgs e)
{
// Ελέγξτε αν ο χρήστης προσπαθεί να πλοηγηθεί
// σε προστατευμένη σελίδα χωρίς authentication
if (e.Target.Location.OriginalString.Contains("checkout")
&& !IsUserAuthenticated())
{
// Ακύρωση πλοήγησης
e.Cancel();
// Εναλλακτική: ανακατεύθυνση στη σελίδα login
MainThread.BeginInvokeOnMainThread(async () =>
{
await GoToAsync("login");
});
}
}
private void OnShellNavigated(object sender, ShellNavigatedEventArgs e)
{
// Logging ή analytics
System.Diagnostics.Debug.WriteLine(
$"Navigated to: {e.Current.Location}");
}
private bool IsUserAuthenticated()
{
// Έλεγχος authentication status
return Preferences.Get("IsAuthenticated", false);
}
}
Tabbed Navigation και Flyout Patterns
Ο σχεδιασμός της κύριας πλοήγησης απαιτεί σκέψη. Η επιλογή μεταξύ tabs και flyout δεν είναι μόνο αισθητική — επηρεάζει πώς ο χρήστης αντιλαμβάνεται τη δομή της εφαρμογής. Ας δούμε τα δύο βασικά patterns.
Bottom Tabs Pattern
Τα bottom tabs είναι ιδανικά όταν έχετε 3-5 κύριες ενότητες και ο χρήστης εναλλάσσεται συχνά μεταξύ τους. Σκεφτείτε Instagram, Twitter — αυτό ακριβώς το μοτίβο:
<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>
<Tab Title="Αρχική" Icon="home.png">
<ShellContent
ContentTemplate="{DataTemplate views:HomePage}" />
</Tab>
<Tab Title="Αναζήτηση" Icon="search.png">
<ShellContent
ContentTemplate="{DataTemplate views:SearchPage}" />
</Tab>
<Tab Title="Καλάθι" Icon="cart.png">
<ShellContent
ContentTemplate="{DataTemplate views:CartPage}" />
</Tab>
<Tab Title="Προφίλ" Icon="profile.png">
<ShellContent
ContentTemplate="{DataTemplate views:ProfilePage}" />
</Tab>
</TabBar>
</Shell>
Flyout Pattern
Το flyout menu (ή hamburger menu, αν προτιμάτε) ταιριάζει σε εφαρμογές με πολλές ενότητες ή όταν θέλετε να δώσετε έμφαση σε κάποιες σελίδες:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MyApp.Views"
x:Class="MyApp.AppShell"
FlyoutBehavior="Flyout"
FlyoutBackgroundColor="{StaticResource Surface}">
<FlyoutItem Title="Πίνακας Ελέγχου" Icon="dashboard.png">
<ShellContent
ContentTemplate="{DataTemplate views:DashboardPage}" />
</FlyoutItem>
<FlyoutItem Title="Παραγγελίες" Icon="orders.png">
<ShellContent
ContentTemplate="{DataTemplate views:OrdersPage}" />
</FlyoutItem>
<FlyoutItem FlyoutItemIsVisible="True">
<Shell.FlyoutItemTemplate>
<DataTemplate>
<Grid HeightRequest="1" BackgroundColor="LightGray" />
</DataTemplate>
</Shell.FlyoutItemTemplate>
<ShellContent ContentTemplate="{DataTemplate views:DashboardPage}" />
</FlyoutItem>
<FlyoutItem Title="Ρυθμίσεις" Icon="settings.png">
<ShellContent
ContentTemplate="{DataTemplate views:SettingsPage}" />
</FlyoutItem>
<!-- Menu Item χωρίς Shell Content -->
<MenuItem Text="Αποσύνδεση"
IconImageSource="logout.png"
Clicked="OnLogoutClicked" />
</Shell>
Hybrid Pattern: Flyout + Tabs
Ένα μοτίβο που χρησιμοποιώ αρκετά συχνά είναι ο συνδυασμός flyout με bottom tabs. Η κύρια ενότητα έχει tabs για γρήγορη πρόσβαση, ενώ οι δευτερεύουσες ενότητες μπαίνουν στο flyout:
<Shell FlyoutBehavior="Flyout">
<FlyoutItem Title="Κατάστημα" Icon="store.png">
<Tab Title="Κατάλογος" Icon="catalog.png">
<ShellContent
ContentTemplate="{DataTemplate views:CatalogPage}" />
</Tab>
<Tab Title="Προσφορές" Icon="deals.png">
<ShellContent
ContentTemplate="{DataTemplate views:DealsPage}" />
</Tab>
<Tab Title="Νέες Αφίξεις" Icon="new.png">
<ShellContent
ContentTemplate="{DataTemplate views:NewArrivalsPage}" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Λογαριασμός" Icon="account.png">
<ShellContent
ContentTemplate="{DataTemplate views:AccountPage}" />
</FlyoutItem>
</Shell>
Νέες Βελτιώσεις στο .NET 10
Το .NET 10 φέρνει κάποιες αρκετά ενδιαφέρουσες βελτιώσεις στο σύστημα πλοήγησης του MAUI. Ας δούμε τις πιο αξιοσημείωτες.
XAML Source Generator
Αυτό είναι, κατά τη γνώμη μου, η πιο σημαντική αλλαγή. Ο νέος XAML Source Generator μεταγλωττίζει τα XAML αρχεία σε C# κώδικα κατά το build, αντί να κάνει parsing κατά τη διάρκεια εκτέλεσης. Τι σημαίνει αυτό στην πράξη:
- Ταχύτερη εκκίνηση: Δεν χρειάζεται πλέον XAML parsing κατά τη φόρτωση σελίδας
- Πρώιμη ανίχνευση σφαλμάτων: Σφάλματα XAML εντοπίζονται κατά τη μεταγλώττιση, όχι κατά την εκτέλεση
- Inspectable κώδικας: Ο generated κώδικας μπορεί να επιθεωρηθεί για debugging
SplitView Control
Το νέο SplitView control προσαρμόζεται αυτόματα μεταξύ side-by-side (tablet/desktop) και stacked (phone) layout. Αν φτιάχνετε εφαρμογές που πρέπει να δουλεύουν σε πολλαπλά μεγέθη οθόνης, αυτό θα σας γλιτώσει αρκετό κώδικα:
<SplitView IsPaneOpen="{Binding IsMasterVisible}"
DisplayMode="Overlay"
PaneBackground="{StaticResource Surface}">
<SplitView.Pane>
<!-- Master: λίστα στοιχείων -->
<CollectionView ItemsSource="{Binding Items}"
SelectionMode="Single"
SelectionChanged="OnItemSelected">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Item">
<Label Text="{Binding Title}" Padding="16" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</SplitView.Pane>
<!-- Detail: λεπτομέρειες επιλεγμένου στοιχείου -->
<ContentView Content="{Binding SelectedItemView}" />
</SplitView>
Βελτιωμένη Ποιότητα και Σταθερότητα
Η Microsoft έχει δηλώσει ότι η κύρια εστίαση του .NET MAUI 10 είναι η βελτίωση ποιότητας. Λιγότερα bugs στη πλοήγηση, πιο σταθερό Shell behavior, καλύτερη υποστήριξη nested routes, και βελτιωμένα transition animations. Ειδικά τα ζητήματα με τα query parameters κατά το back navigation — ένα χρόνιο πρόβλημα που ταλαιπωρούσε πολύ κόσμο — έχουν αντιμετωπιστεί σε μεγάλο βαθμό.
Testing Navigation Logic
Ένα από τα μεγαλύτερα πλεονεκτήματα του Navigation Service pattern που είδαμε νωρίτερα; Η δυνατότητα δοκιμών. Ας δούμε πώς γράφουμε unit tests για τη λογική πλοήγησης.
Mock NavigationService
public class MockNavigationService : INavigationService
{
public string LastNavigatedRoute { get; private set; }
public IDictionary<string, object> LastParameters { get; private set; }
public int NavigationCount { get; private set; }
public int GoBackCount { get; private set; }
public Task NavigateToAsync(string route)
{
LastNavigatedRoute = route;
NavigationCount++;
return Task.CompletedTask;
}
public Task NavigateToAsync(
string route,
IDictionary<string, object> parameters)
{
LastNavigatedRoute = route;
LastParameters = parameters;
NavigationCount++;
return Task.CompletedTask;
}
public Task GoBackAsync()
{
GoBackCount++;
return Task.CompletedTask;
}
public Task GoToRootAsync()
{
LastNavigatedRoute = "//";
NavigationCount++;
return Task.CompletedTask;
}
}
Unit Tests
[Fact]
public async Task ViewProductDetails_ShouldNavigate_WithCorrectParameters()
{
// Arrange
var mockNav = new MockNavigationService();
var mockProductService = new MockProductService();
var viewModel = new CatalogViewModel(mockNav, mockProductService);
var testProduct = new Product { Id = "123", Name = "Test Product" };
// Act
await viewModel.ViewProductDetailsCommand.ExecuteAsync(testProduct);
// Assert
Assert.Equal("productdetails", mockNav.LastNavigatedRoute);
Assert.NotNull(mockNav.LastParameters);
Assert.True(mockNav.LastParameters.ContainsKey("product"));
Assert.Equal(testProduct, mockNav.LastParameters["product"]);
}
[Fact]
public async Task GoToCheckout_ShouldNavigate_ToCheckoutRoute()
{
// Arrange
var mockNav = new MockNavigationService();
var mockProductService = new MockProductService();
var viewModel = new CatalogViewModel(mockNav, mockProductService);
// Act
await viewModel.GoToCheckoutCommand.ExecuteAsync(null);
// Assert
Assert.Equal("checkout", mockNav.LastNavigatedRoute);
Assert.Equal(1, mockNav.NavigationCount);
}
Βέλτιστες Πρακτικές και Συνήθη Λάθη
Ας κλείσουμε με κάποια πράγματα που έχω μάθει (μερικά φορές the hard way) δουλεύοντας με πλοήγηση στο .NET MAUI.
Βέλτιστες Πρακτικές
- Χρησιμοποιήστε πάντα IQueryAttributable: Αποφύγετε το QueryPropertyAttribute — δεν είναι trim-safe και δεν λειτουργεί σωστά με NativeAOT.
- Κρατήστε τα routes ρηχά: Αποφύγετε βαθιά nested routes. Δύο ή τρία επίπεδα βάθος αρκούν για τις περισσότερες εφαρμογές.
- Χρησιμοποιήστε NavigationService: Μην καλείτε
Shell.Current.GoToAsyncκατευθείαν από τα ViewModels — κάνει τα tests πολύ δύσκολα. - Lazy loading σελίδων: Χρησιμοποιήστε πάντα
ContentTemplateστο Shell. Η διαφορά στον χρόνο εκκίνησης μπορεί να είναι δραματική. - Αποφύγετε navigation στο constructor: Μην εκτελείτε navigation λογική μέσα σε constructors — χρησιμοποιήστε
OnAppearingήApplyQueryAttributes.
Συνήθη Λάθη
- Duplicate route registration: Η εγγραφή route με ίδιο όνομα σκάει με exception. Βεβαιωθείτε ότι κάθε route name είναι μοναδικό.
- Αγνόηση του MainThread: Navigation calls πρέπει πάντα να γίνονται στο main thread. Αν καλέσετε
GoToAsyncαπό background thread, θα δείτε ακατανόητα crashes. - Memory leaks: Μη ξεχνάτε να αποσυνδέσετε events κατά το navigation away. Χρησιμοποιήστε τα
NavigatedFromevents ή WeakEventManager. - Hardcoded route strings: Ορίστε τα routes ως constants. Ένα typo σε ένα route string μπορεί να σας κοστίσει ώρες debugging:
public static class Routes
{
public const string ProductDetails = "productdetails";
public const string Checkout = "checkout";
public const string OrderConfirmation = "orderconfirmation";
public const string Login = "login";
public const string EditProfile = "profile/edit";
}
// Χρήση
await _navigationService.NavigateToAsync(Routes.ProductDetails, parameters);
Σύνοψη
Η αρχιτεκτονική πλοήγησης στο .NET MAUI είναι ένα πλούσιο σύστημα που, αν αξιοποιηθεί σωστά, μπορεί να δημιουργήσει εξαιρετικές εμπειρίες χρήστη. Ας ανακεφαλαιώσουμε τα βασικά:
- Το Shell αποτελεί τη βάση της πλοήγησης, με δηλωτικό ορισμό ιεραρχίας και URI-based routing.
- Η μέθοδος GoToAsync με dictionary-based parameters είναι ο σύγχρονος τρόπος μετάδοσης δεδομένων μεταξύ σελίδων.
- Το IQueryAttributable είναι η trim-safe, NativeAOT-compatible προσέγγιση για λήψη navigation data.
- Ένα NavigationService με dependency injection εξασφαλίζει testable κώδικα.
- Το deep linking (Android App Links + iOS Universal Links) χρειάζεται ρύθμιση ανά πλατφόρμα, αλλά ανοίγει τεράστιες δυνατότητες.
- Το .NET 10 βελτιώνει σημαντικά ποιότητα και σταθερότητα, ενώ φέρνει εργαλεία όπως τον XAML Source Generator.
Θυμηθείτε: η πλοήγηση δεν είναι απλώς "πώς πηγαίνω από τη σελίδα Α στη σελίδα Β". Είναι αρχιτεκτονικό θέμα που επηρεάζει απόδοση, testability, συντηρησιμότητα και — κυρίως — την εμπειρία του χρήστη. Επενδύστε χρόνο στο σχεδιασμό της πλοήγησης από την αρχή. Θα χαρείτε που το κάνατε.