Nalu.Maui.Navigation

Nalu.Maui.Navigation NuGet Package Nalu.Maui NuGet Package Downloads

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 Transient and Scoped service lifetimes
  • Async void lifecycle: Page lifecycle events use async void methods 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 Scoped services 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 → ... → DisappearingLeaving → Animation → Dispose

Important notes:

  • OnAppearingAsync and OnDisappearingAsync fire multiple times (when returning from child pages)
  • OnEnteringAsync and OnLeavingAsync fire 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.

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

  1. ✅ Use interfaces for ViewModels (better testing)
  2. ✅ Use record types for intents (convenient value equality in unit tests)
  3. ✅ Keep OnEnteringAsync fast (<30ms) - or use Background Loading Pattern for slow operations
  4. ✅ Use IAppearingAware for operations that should run when returning from child pages
  5. ✅ Implement IDisposable for cleanup (i.e. when using Timer)
  6. ✅ Enable leak detection in development
  7. Match cleanup to creation: Constructor → Dispose, Entering → Leaving, Appearing → Disappearing
  8. Dispatch navigation from lifecycle events - use IDispatcher.DispatchAsync() to avoid blocking

Learn More

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.