Provides .NET MAUI tools to help with everyday challenges.
We provide a set of interfaces to react to navigation lifecycle events, and it takes care of disposing all the resources when the page is popped.
The navigation system awaits every navigation lifecycle event, so you can safely perform asynchronous operations with real async ValueTask
methods instead of relying on async void
ones.
Navigation.Relative()
.Push<ContactsPageModel>()
.Push<ContactDetailPageModel>()
There’s also a “navigation guard” feature that allows you to prevent the navigation to happen if a condition is not met. You can leverage that to ask the user to confirm leaving the page.
// Starting from: RootPageModel > ContactsPageModel > ContactDetailPageModel
Navigation.Absolute()
.ShellContent<RootPageModel>()
// This is gonna trigger the navigation guard on ContactDetailPageModel
ValueTask<bool> CanLeaveAsync() => { ... ask the user };
Nalu.Maui
automatically detects and reports memory leaks when the debugger is attached.
An alert dialog will be shown when your Page
was not collected after navigating away.
In the above example the leak detection on ContactsPageModel
, ContactDetailPageModel
and their respective Page
s triggers after the navigation completed (root page appears).
Leak detection can be enabled in MauiProgram.cs
:
.UseNaluNavigation<App>(nav => nav
.AddPages()
.WithLeakDetectorState(NavigationLeakDetectorState.EnabledWithDebugger)
)
With Nalu navigation, a ServiceScope
is created for each page, so you can use Scoped
services in your pages and view models.
Pages and view models are in fact registered as Scoped
services and automatically disposed by the ServiceScope
when the page is removed from the navigation stack.
First of all, you need to add the Nalu.Maui package to your project, then just call UseNaluNavigation
in your MauiProgram
:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>()
This method will scan the <App>
assembly for pages and view models by naming convention MainPage
=> MainPageModel
.
You can specify a custom naming convention by passing a function that returns the view model type given the page type:
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav.AddPages((pageType) => pageType.Name.Replace("Page", "ViewModel")))
Important notes:
INotifyPropertyChanged
interfaceBindingContext
propertypublic partial class MainPage : ContentPage
{
public MainPage(MainPageModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
Eventually you can specify the pages and view models manually:
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav.AddPage<MainPageModel, MainPage>())
To help with testability you can also register the page model as an interface:
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav.AddPage<IMainPageModel, MainPageModel, MainPage>())
Note: the automatic registration by naming convention automatically considers the page model as an interface.
Nalu can be used even without MVVM pattern, just add your pages as Scoped
services:
builder
.UseNaluNavigation<App>()
.Services
.AddScoped<MyPage>();
You can eventually customize in one place all the back and menu icons.
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav.AddPages()
.WithMenuIcon(ImageSource.FromFile("menu.png"))
.WithBackIcon(ImageSource.FromFile("back.png")))
Nalu navigation is based on Shell
navigation, so you need to define your Shell
in AppShell.xaml
by inheriting from NaluShell
.
Use nalu:Navigation.PageType
to specify the page type for each ShellContent
.
<?xml version="1.0" encoding="utf-8"?>
<nalu:NaluShell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:pages="clr-namespace:Nalu.Maui.Sample.PageModels"
xmlns:nalu="https://nalu-development.github.com/nalu/navigation"
x:Class="Nalu.Maui.Sample.AppShell">
<FlyoutItem Route="main"
FlyoutDisplayOptions="AsMultipleItems">
<Tab Title="Pages"
Route="pages">
<ShellContent nalu:Navigation.PageType="pages:OnePage"
Title="Page One"/>
<ShellContent nalu:Navigation.PageType="pages:TwoPage"
Title="Page Two"/>
</Tab>
</FlyoutItem>
<ShellContent nalu:Navigation.PageType="pages:FivePage"
Title="Page Five"/>
</nalu:NaluShell>
In the code behind you need to set the initial shell page passing the navigation service and the initial page type to the base constructor:
public partial class App : Application
{
public App(INavigationService navigationService)
{
InitializeComponent();
MainPage = new AppShell(navigationService);
}
}
public partial class AppShell : NaluShell
{
public AppShell(INavigationService navigationService) : base(navigationService, typeof(OnePage))
{
InitializeComponent();
}
}
Shell
structure is based on Item
> Section
> Content
hierarchy.
Even when you don’t specify an Item
or a Section
in the Shell
definition, it is automatically created for you.
For example, the following Shell
definition
<nalu:NaluShell>
<ShellContent nalu:Navigation.PageType="pages:OnePage"/>
<ShellContent nalu:Navigation.PageType="pages:TwoPage"/>
</nalu:NaluShell>
is equivalent to
<nalu:NaluShell>
<ShellItem>
<ShellSection>
<ShellContent nalu:Navigation.PageType="pages:OnePage"/>
</ShellSection>
</ShellItem>
<ShellItem>
<ShellSection>
<ShellContent nalu:Navigation.PageType="pages:TwoPage"/>
</ShellSection>
</ShellItem>
</nalu:NaluShell>
That said, Nalu
navigation provides the following navigation behavior when switching between ShellContent
s:
ShellSection
, navigation stack will be poppedShellSection
but in the same ShellItem
, the current navigation stack will be persistedShellItem
, all of the current item’s navigation stacks will be popped and the ShellContent
pages will be destroyedYou can customize this behavior by providing a custom NavigationBehavior
to the Navigation
object.
For example you can also use the IgnoreGuards
behavior to ignore the ILeavingGuard
when popping a page:
await _navigationService.GoToAsync(Navigation.Relative(NavigationBehavior.IgnoreGuards).Pop());
The page view model can selectively react to navigation events by implementing the following interfaces:
IEnteringAware
: defines a ValueTask OnEnteringAsync()
called when the page is entering the navigation stackIAppearingAware
: defines a ValueTask OnAppearingAsync()
called when the page is appearingIDisappearingAware
: defines a ValueTask OnDisappearingAsync()
called when the page is disappearingILeavingAware
: defines a ValueTask OnLeavingAsync()
called when the page is leaving the navigation stackWith Nalu navigation you can also pass parameters to the target page using the IntentAware
interfaces:
IEnteringAware<TIntent>
: defines a ValueTask OnEnteringAsync(TIntent intent)
called when the page is entering the navigation stackIAppearingAware<TIntent>
: defines a ValueTask OnAppearingAsync(TIntent intent)
called when the page is appearingNote: when an intent is passed to the view model, the OnEnteringAsync
and OnAppearingAsync
parameterless methods will be invoked only if the intent-specific method is not implemented.
You can change this behavior by configuring Nalu with Strict
behavior.
.UseNaluNavigation<App>(nav => nav
.AddPages()
.WithNavigationIntentBehavior(NavigationIntentBehavior.Strict)
)
Obviously nothing stops you to call the intent-less ones manually from the intent-aware one if you need to.
Sometimes you want to protect a page from being popped from the navigation stack, for example when the user is editing a form.
You can do that by implementing the ILeavingGuard
interface which defines a ValueTask<bool> CanLeaveAsync()
method from which you can eventually display a prompt to ask the user if they want to leave the page.
public class ViewModel : ILeavingGuard
{
public async ValueTask<bool> CanLeaveAsync()
{
return await ConfirmUserLeaveAsync("Are you sure you want to leave without saving?") // a method to verify the leave action
}
}
Note: a page “appears” only when it is the target of the navigation, intermediate pages models will trigger OnAppearingAsync
unless the ILeavingGuard
needs to be evaluated.
First of all, you need to inject the INavigationService
in your page model:
public class OnePageModel : IOnePageModel
{
private readonly INavigationService _navigationService;
public OnePageModel(INavigationService navigationService)
{
_navigationService = navigationService;
}
}
Then you can use the GoToAsync
method to navigate to a page using relative or absolute navigation:
// Add a page to the navigation stack
await _navigationService.GoToAsync(Navigation.Relative().Push<TwoPageModel>());
// Add a page to the navigation stack providing an intent
var myIntent = new MyIntent(/* ... */);
await _navigationService.GoToAsync(Navigation.Relative().Push<TwoPageModel>().WithIntent(myIntent));
// Remove the current page from the navigation stack
await _navigationService.GoToAsync(Navigation.Relative().Pop());
// Remove the current page from the navigation stack providing an intent to the previous page
var myIntent = new MyResult(/* ... */);
await _navigationService.GoToAsync(Navigation.Relative().Pop().WithIntent(myIntent))
// Pop two pages than push a new one
await _navigationService.GoToAsync(Navigation.Relative().Pop().Pop().Push<ThreePageModel>());
// Pop to the root page using absolute navigation
await _navigationService.GoToAsync(Navigation.Absolute().ShellContent<MainPageModel>());
// Switch to a different shell content and push a page there
await _navigationService.GoToAsync(Navigation.Absolute().ShellContent<OtherPageModel>().Push<OtherChildPageModel>());
Note:
Navigation.Relative().Push<TwoPage>()
).Nalu provides a Navigation
markup extension that can be used to navigate to a page using relative or absolute navigation:
<!-- Pops the current page -->
<Button Command="{nalu:NavigateCommand}" Text="Back">
<Button.CommandParameter>
<nalu:RelativeNavigation>
<nalu:NavigationPop />
</nalu:RelativeNavigation>
</Button.CommandParameter>
</Button>
<!-- Push a new page onto the navigation stack with intent -->
<Button Command="{nalu:NavigateCommand}" Text="Push some page">
<Button.CommandParameter>
<nalu:RelativeNavigation Intent="{Binding MyIntentValue}">
<nalu:NavigationSegment Type="pages:SixPage" />
</nalu:RelativeNavigation>
</Button.CommandParameter>
</Button>
<!-- Navigate to main page -->
<Button Command="{nalu:NavigateCommand}" Text="Go to main page">
<Button.CommandParameter>
<nalu:AbsoluteNavigation>
<nalu:NavigationSegment x:TypeArguments="pages:MainPage" />
</nalu:AbsoluteNavigation>
</Button.CommandParameter>
</Button>
Sometimes you need to share a service between pages, starting from a specific page down to all the nested pages.
Nalu navigation provides an INavigationServiceProvider
service that can be used to provide services to nested page.
In the page model where you want to start providing the service, you need to inject the INavigationServiceProvider
and call the Provide
method:
public class PersonPageModel(INavigationServiceProvider navigationServiceProvider) : IPersonPageModel // which inherits from IEnteringAware<int>
{
public ValueTask OnEnteringAsync(int personId)
{
var personContext = new PersonContext(personId);
navigationServiceProvider.AddNavigationScoped<IPersonContext>(personContext);
}
}
Then you can inject the service in the nested page models through the INavigationServiceProvider
:
public class PersonDetailsPageModel(INavigationServiceProvider navigationServiceProvider) : IPersonDetailsPageModel
{
private readonly IPersonContext _personContext = navigationServiceProvider.GetRequiredService<IPersonContext>();
}
Here’s an example of how to unit test navigation using NSubstitute
:
Using record
for intents is recommended to avoid having to implement an equality comparer.
Suppose to have defined an intent class public record AnIntent(int Value = 0);
.
// Arrange
var navigationService = Substitute.For<INavigationService>();
navigationService.GoToAsync(Arg.Any<Navigation>()).Returns(Task.FromResult(true));
var viewModel = new MyViewModel(navigationService);
// Act
await viewModel.DoSomethingAsync(5);
// Assert
var expectedNavigation = Navigation.Relative().Push<TargetPageModel>().WithIntent(new AnIntent(5));
await navigationService.Received().GoToAsync(Arg.Is<Navigation>(n => n.Matches(expectedNavigation)));
Unfortunately MAUI navigation (NavigationPage, or Shell) do not provide automatic page/view model disposal as widely explained in this issue. This is a problem because it can lead to memory/event leaks.
There are other big issues with Shell navigation:
Shell.Current.GoToAsync
API is really hard to understand: can you easily tell what’s the difference between GoToAsync("Page1")
/ GoToAsync("/Page1")
/ GoToAsync("//Page1")
/ GoToAsync("///Page1")
?ShellContent
will never be disposed, even if you navigate to a different shell item.Transient
and Scoped
service lifetime in MAUI?async void
methods to perform asynchronous operations on page lifecycle eventsOn the other hand, Shell
offers a convenient way to define the app structure including tab bar and flyout menu.
Shell
also supports having multiple navigation stacks alive at the same time when using a global TabBar
.
Nalu navigation is based on Shell
navigation, but it solves all the issues above.