Nalu.Maui.Navigation
A powerful, type-safe navigation system for .NET MAUI that fixes Shell navigation's pain points while preserving its strengths.
Why Nalu Navigation?
Standard MAUI Shell navigation has several critical issues:
- Memory leaks: Pages and ViewModels aren't properly disposed (MAUI Issue #7354)
- Confusing API: Hard to understand the difference between
GoToAsync("Page"),GoToAsync("/Page"),GoToAsync("//Page"), etc. - No scoped services: Difficult to distinguish between
TransientandScopedservice lifetimes - Async void lifecycle: Page lifecycle events use
async voidmethods instead of proper async patterns - No navigation context: Can't share data between nested pages easily
Nalu Navigation solves all these problems while keeping Shell's best features: tab bars, flyout menus, and multiple navigation stacks.
Quick Start
1. Installation
dotnet add package Nalu.Maui.Navigation
2. Setup in MauiProgram.cs
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav
.AddPages() // Automatically discovers MainPage => MainPageModel
.WithLeakDetectorState(NavigationLeakDetectorState.EnabledWithDebugger)
);
return builder.Build();
}
}
Configuration options:
.AddPages()- Auto-discover pages with naming convention.AddPages(pageType => ...)- Custom naming convention.AddPage<MainPageModel, MainPage>()- Manual registration.AddPage<IMainPageModel, MainPageModel, MainPage>()- With interface (better for testing)
Without MVVM? You can use Nalu without ViewModels - just register pages as
Scopedservices and use page types in navigation.
3. Create your Page and ViewModel
Pages must require the ViewModel as a constructor parameter:
public partial class MainPage : ContentPage
{
public MainPage(MainPageModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
ViewModels must implement INotifyPropertyChanged:
public class MainPageModel : ObservableObject
{
private readonly INavigationService _navigationService;
public MainPageModel(INavigationService navigationService)
{
_navigationService = navigationService;
}
}
4. Define your Shell
Create AppShell.xaml inheriting from NaluShell:
<nalu:NaluShell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:nalu="https://nalu-development.github.com/nalu/navigation"
xmlns:pages="clr-namespace:MyApp.Pages"
x:Class="MyApp.AppShell">
<ShellContent nalu:Navigation.PageType="pages:MainPage"
Title="Home"/>
<ShellContent nalu:Navigation.PageType="pages:SettingsPage"
Title="Settings"/>
</nalu:NaluShell>
Code-behind:
public partial class AppShell : NaluShell
{
public AppShell(INavigationService navigationService)
: base(navigationService, typeof(MainPage))
{
InitializeComponent();
}
}
5. Initialize Shell in App.cs
public partial class App : Application
{
public App(INavigationService navigationService)
{
InitializeComponent();
MainPage = new AppShell(navigationService);
}
}
Android-specific: Cache the Window instance:
#if ANDROID
private Window? _window;
#endif
protected override Window CreateWindow(IActivationState? activationState)
{
#if ANDROID
return _window ??= new Window(new AppShell(_navigationService));
#else
return new Window(new AppShell(_navigationService));
#endif
}
Core Concepts
Automatic Disposal and Scoped Services
Nalu creates a ServiceScope for each navigated page. Pages and ViewModels are registered as Scoped and automatically disposed when removed from the navigation stack.
// This service lives only while the page is in the navigation stack
builder.Services.AddScoped<IPageSpecificService, PageSpecificService>();
When you implement IDisposable, it's automatically called after the page is removed and the navigation animation completes.
Type-Safe Navigation
Navigate using types, not strings:
// Push onto current stack
await _navigationService.GoToAsync(
Navigation.Relative().Push<ContactDetailPageModel>()
);
// Switch to a different shell content
await _navigationService.GoToAsync(
Navigation.Absolute().Root<SettingsPageModel>()
);
// Pop current page
await _navigationService.GoToAsync(
Navigation.Relative().Pop()
);
Shell Structure and Navigation Behavior
Shell organizes as: ShellItem > ShellSection > ShellContent > NavigationStack
Navigation behavior varies by hierarchy:
- Same ShellSection: Navigation stack pops to target
- Different ShellSection, same ShellItem: Current stack is preserved
- Different ShellItem: All stacks cleared, pages disposed
Basic Navigation
Relative Navigation
// Push
Navigation.Relative().Push<DetailPageModel>()
// Pop
Navigation.Relative().Pop()
// Replace (pop and push)
Navigation.Relative().Pop().Push<NewPageModel>()
// Pop multiple
Navigation.Relative().Pop().Pop().Push<PageModel>()
Absolute Navigation
// Navigate to shell content
Navigation.Absolute().Root<MainPageModel>()
// Navigate and push
Navigation.Absolute().Root<SettingsPageModel>().Add<DetailPageModel>()
// Custom route
Navigation.Absolute().Root<MainPageModel>("custom-route")
XAML Navigation
<!-- Pop -->
<Button Command="{nalu:NavigateCommand}" Text="Back">
<Button.CommandParameter>
<nalu:RelativeNavigation>
<nalu:NavigationPop />
</nalu:RelativeNavigation>
</Button.CommandParameter>
</Button>
<!-- Push -->
<Button Command="{nalu:NavigateCommand}" Text="Details">
<Button.CommandParameter>
<nalu:RelativeNavigation>
<nalu:NavigationSegment Type="pages:DetailPage" />
</nalu:RelativeNavigation>
</Button.CommandParameter>
</Button>
Lifecycle Events Overview
Nalu provides async/await lifecycle events. Implement only the interfaces you need - most pages use just 1-2:
// Simple page - just load data when appearing
public class ContactListPageModel : ObservableObject, IAppearingAware
{
public async ValueTask OnAppearingAsync()
{
await LoadContactsAsync();
}
}
Available lifecycle interfaces:
public class MyPageModel : ObservableObject,
IEnteringAware, // Before animation starts (keep fast!)
IAppearingAware, // After animation completes
IDisappearingAware, // Before leaving
ILeavingAware, // Being removed from stack
IDisposable // After disposal
{
public async ValueTask OnEnteringAsync()
{
// Fast initialization - delays animation
await QuickSetupAsync();
}
public async ValueTask OnAppearingAsync()
{
// Slow operations - show loading indicator
await LoadDataAsync();
}
public ValueTask OnDisappearingAsync()
{
StopTimers();
return ValueTask.CompletedTask;
}
public ValueTask OnLeavingAsync()
{
UnsubscribeEvents();
return ValueTask.CompletedTask;
}
public void Dispose()
{
// Dispose resources
}
}
Event order: Entering → Animation → Appearing → ... → Disappearing → Leaving → Animation → Dispose
Important notes:
OnAppearingAsyncandOnDisappearingAsyncfire multiple times (when returning from child pages)OnEnteringAsyncandOnLeavingAsyncfire once per stack entry- For slow operations (>500ms) in
OnEnteringAsync, use the Background Loading Pattern to avoid blocking navigation
📘 Deep dive: See Navigation Lifecycle for timing details, choosing the right interface, and advanced patterns.
Passing Data with Intents
Intents are strongly-typed data passed during navigation:
// Define intent
public record ContactIntent(int ContactId);
// Navigate with intent
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<ContactDetailPageModel>()
.WithIntent(new ContactIntent(42))
);
// Receive intent
public class ContactDetailPageModel : IEnteringAware<ContactIntent>
{
public async ValueTask OnEnteringAsync(ContactIntent intent)
{
await LoadContactAsync(intent.ContactId);
}
}
Awaitable intents for getting results back:
// Define awaitable intent
public class SelectContactIntent : AwaitableIntent<Contact?> { }
// Navigate and await result in one call
var intent = new SelectContactIntent();
var selectedContact = await _navigationService.ResolveIntentAsync<ContactSelectionPageModel, Contact?>(intent);
// Pushed page sets the result and navigates back
intent.SetResult(new Contact("Jane Doe"));
await navigationService.GoToAsync(Navigation.Relative().Pop());
📘 Deep dive: See Navigation Intents for returning results, awaitable intents, intent behaviors, and patterns.
Navigation Guards
Prevent navigation to confirm unsaved changes:
public class EditPageModel : ObservableObject, ILeavingGuard
{
public async ValueTask<bool> CanLeaveAsync()
{
if (!HasUnsavedChanges) return true;
return await DisplayAlert(
"Unsaved Changes",
"Leave without saving?",
"Leave", "Stay"
);
}
}
Bypass guards when needed:
Navigation.Relative(NavigationBehavior.IgnoreGuards).Pop()
📘 Deep dive: See Advanced Navigation for behaviors, scoped services, and leak detection.
Testing Navigation
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new MyViewModel(navigationService);
// Act
await viewModel.NavigateToDetailsAsync(5);
// Assert
var expectedNav = Navigation.Relative()
.Push<DetailPageModel>()
.WithIntent(new DetailIntent(5));
await navigationService.Received().GoToAsync(
Arg.Is<Navigation>(n => n.Matches(expectedNav))
);
📘 Deep dive: See Testing and Troubleshooting for complete testing patterns and common issues.
Common Patterns
Initialization Flow
// Start with a splash page
public AppShell(INavigationService navigationService)
: base(navigationService, typeof(InitPage), new StartupIntent())
{ }
// In the InitPage ViewModel - use IAppearingAware
public class InitPageModel : IAppearingAware<StartupIntent>
{
private readonly IDispatcher _dispatcher;
private readonly INavigationService _navigationService;
public async ValueTask OnAppearingAsync(StartupIntent intent)
{
await LoadDataAsync();
// Must dispatch - can't navigate directly from lifecycle event
_ = _dispatcher.DispatchAsync(() =>
_navigationService.GoToAsync(
Navigation.Absolute(NavigationBehavior.Immediate).Root<HomePageModel>()
)
);
}
}
Tab Bar with Multiple Stacks
<TabBar>
<Tab Title="Home">
<ShellContent nalu:Navigation.PageType="pages:HomePage"/>
</Tab>
<Tab Title="Search">
<ShellContent nalu:Navigation.PageType="pages:SearchPage"/>
</Tab>
</TabBar>
Each tab maintains its own navigation stack independently.
Best Practices
- ✅ Use interfaces for ViewModels (better testing)
- ✅ Use
recordtypes for intents (convenient value equality in unit tests) - ✅ Keep
OnEnteringAsyncfast (<30ms) - or use Background Loading Pattern for slow operations - ✅ Use
IAppearingAwarefor operations that should run when returning from child pages - ✅ Implement
IDisposablefor cleanup (i.e. when usingTimer) - ✅ Enable leak detection in development
- ✅ Match cleanup to creation: Constructor → Dispose, Entering → Leaving, Appearing → Disappearing
- ✅ Dispatch navigation from lifecycle events - use
IDispatcher.DispatchAsync()to avoid blocking
Learn More
- 📘 Navigation Lifecycle - Deep dive into lifecycle events and timing
- 📘 Navigation Intents - Passing data and returning results
- 📘 Advanced Navigation - Guards, behaviors, scoped services, and leak detection
- 📘 Testing & Troubleshooting - Unit testing and common issues
Migration from Shell
| Shell Navigation | Nalu Navigation |
|---|---|
await Shell.Current.GoToAsync("page") |
await _navigationService.GoToAsync(Navigation.Relative().Push<PageModel>()) |
await Shell.Current.GoToAsync("..") |
await _navigationService.GoToAsync(Navigation.Relative().Pop()) |
await Shell.Current.GoToAsync("//route") |
await _navigationService.GoToAsync(Navigation.Absolute().Root<PageModel>()) |
| Query parameters | Strongly-typed intents |
OnNavigatedTo / OnNavigatedFrom |
IEnteringAware / ILeavingAware / IAppearingAware / IDisappearingAware |
API Reference
For complete API documentation, see the API reference.