MAUI Navigation, Lifecycles, and Nalu
# MAUI Shell Navigation and Its Issues
.NET MAUI includes a URI-based navigation experience that uses routes to navigate to any page in the app, without having to follow a set of navigation hierarchy. In addition, it also provides the ability to navigate backwards without having to visit all of the pages in the navigation stack.
The routes are defined in the Shell.XAML according to their hierarchy. They can be defined including the overall navigation scheme such as tabs, flyouts, and other patterns:
```xml
<Shell ...>
<FlyoutItem ...
Route="animals">
<Tab ...
Route="domestic">
<ShellContent ...
Route="cats" />
<ShellContent ...
Route="dogs" />
</Tab>
<ShellContent ...
Route="monkeys" />
<ShellContent ...
Route="elephants" />
<ShellContent ...
Route="bears" />
</FlyoutItem>
<ShellContent ...
Route="about" />
...
</Shell>
```
Any other additional routes can be explicitly registered in the shell subclass constructor for any detail pages that are not represented in the Shell visual hierarchy:
```c#
Routing.RegisterRoute("monkeydetails", typeof(MonkeyDetailPage));
Routing.RegisterRoute("beardetails", typeof(BearDetailPage));
Routing.RegisterRoute("catdetails", typeof(CatDetailPage));
Routing.RegisterRoute("dogdetails", typeof(DogDetailPage));
Routing.RegisterRoute("elephantdetails", typeof(ElephantDetailPage));
```
Alternative hierarchies are also supported:
```c#
Routing.RegisterRoute("monkeys/details", typeof(MonkeyDetailPage));
Routing.RegisterRoute("bears/details", typeof(BearDetailPage));
Routing.RegisterRoute("cats/details", typeof(CatDetailPage));
Routing.RegisterRoute("dogs/details", typeof(DogDetailPage));
Routing.RegisterRoute("elephants/details", typeof(ElephantDetailPage));
```
This example enables contextual page navigation, where navigating to the `details` route from the page for the `monkeys` route displays the `MonkeyDetailPage`. Similarly, navigating to the `details` route from the page for the `elephants` route displays the `ElephantDetailPage`. For more information, see [Contextual navigation](https://learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation?view=net-maui-8.0#contextual-navigation).
For more details on default shell navigation, see the [official documentation](https://learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation?view=net-maui-8.0).
## Passing Data
### Sending Data
Primitive data can be passed as string based query parameters when performing URI-based programmatic navigation:
```c#
await Shell.Current.GoToAsync($"elephantdetails?name={elephantName}");
```
POCOs can be passed either as single use or multiple use. *Single use* means the dictionary that holds the data is cleared after a single navigation (note that the objects will still be alive; only the collection is emptied) whereas in *multiple use* it is the developer’s responsibility to maintain this dictionary.
```c#
// single use
var navigationParameter = new ShellNavigationQueryParameters
{
{ "Bear", animal }
};
await Shell.Current.GoToAsync($"beardetails", navigationParameter);
// multiple use
var navigationParameter = new Dictionary<string, object>
{
{ "Bear", animal }
};
await Shell.Current.GoToAsync($"beardetails", navigationParameter);
```
### Receiving Data
Passed data can be received either by attributes or by the `IQueryAttributable` interface:
```c#
[QueryProperty(nameof(Bear), "Bear")]
public partial class BearDetailPage : ContentPage
```
```c#
public class MonkeyDetailViewModel : IQueryAttributable, INotifyPropertyChanged
{
public Animal Monkey { get; private set; }
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
Monkey = query["Monkey"] as Animal;
}
...
}
```
For more details, see the [official documentation](https://learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation?view=net-maui-8.0).
## The Problems with Default Shell Navigation
The most common way to inject view models into the corresponding pages is constructor injection. The view model pairs are registered to the MAUI app, and when navigated, the view model is created and passed to the page constructor:
```c#
// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
return MauiApp.CreateBuilder()
.UseMauiApp<App>()
.UseMauiCommunityToolkit(options =>
{
options.SetShouldEnableSnackbarOnWindows(true);
})
.UseSkiaSharp(true)
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
fonts.AddFont("materialdesignicons-webfont.ttf", "MaterialDesignIcons");
})
.RegisterServices()
.RegisterViews()
.RegisterViewModels()
.RegisterPopups()
.RegisterDebugLogger()
.Build();
}
public static MauiAppBuilder RegisterViewModels(this MauiAppBuilder mauiAppBuilder)
{
mauiAppBuilder.Services.AddTransient<HomeViewModel>();
mauiAppBuilder.Services.AddTransient<BridgeDetailsViewModel>();
mauiAppBuilder.Services.AddTransient<ImageViewerViewModel>();
mauiAppBuilder.Services.AddTransient<NewInspectionViewModel>();
mauiAppBuilder.Services.AddTransient<NewElementRecordViewModel>();
return mauiAppBuilder;
}
}
// a sample page
public partial class NewInspectionPage : ContentPage
{
public NewInspectionPage(NewInspectionViewModel viewModel)
{
BindingContext = viewModel;
InitializeComponent();
}
}
```
If the view model class does not contain any resources that need to be disposed manually, this approach has no downsides. The issues begin to arise when a view model has managed or unmanaged resources that need to be disposed when its page is removed from the navigation stack.
Consider the following view model:
```c#
public partial class NewInspectionViewModel : ObservableObject, IQueryAttributable, IDisposable
{
...
/// <summary>
/// The data access object for the view model.
/// </summary>
readonly UnitOfWork unitOfWork;
#region IDisposable Support
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// TODO: dispose managed state (managed objects)
unitOfWork.SaveBridgeProgressChanged -= UnitOfWork_SaveBridgeProgressChanged;
unitOfWork.Dispose();
}
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
disposedValue = true;
}
}
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~NewInspectionViewModel()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private bool disposedValue;
#endregion
```
This view model **needs** to be disposed when the page that it belongs is removed from the navigation stack. However, default MAUI Shell does **not** call `Dispose` methods of the view models **even if they are registered as transient**. This will cause the resources (and the view model) hanging in heap even if the page is destroyed. This issue cannot be solved by subscribing to regular events. A `Page` has two events that can be used for this purpose: `OnDisappearing` and `Unloaded`. However, none of these two guarantee that the page is disposed; the former one is fired even when a new page is pushed onto the page, such as navigating to a detail page. Disposing the VM here will cause losing any data in the parent page as user will surely go back one step anyway. `Unloaded` is even trickier; it works well on Android but fires erratically on iOS. For a more comprehensive discussion on this topic, see [the related GitHub issue on maui](https://github.com/dotnet/maui/issues/7354). The gist is, this issue is “not aligned with what Microsoft is currently investing, and may be considered in .NET 9”.
This means the solution can only be found in third party packages (several of which can be found on the above GitHub discussion).
# nalu-development/nalu
[Nalu](https://github.com/nalu-development/nalu) is a library that claims solving some problems like navigation between pages in a MAUI application. It creates a Shell-based navigation which handles `IDisposable`, provides navigation guards, and simplifies passing parameters.
## Initial Setup
Just follow the instructions on GitHub, as this is an active project and the method may change in time.
## Passing and Receiving Data
It uses `Intent`s to pass data:
```c#
var myIntent = new MyIntent(/* ... */);
await _navigationService.GoToAsync(Navigation.Relative().Push<TwoPageModel>().WithIntent(myIntent));
```
Note that there is no `Intent` class; you can create a DTO class or use an existing class or primitive. This allows type-safety in receiving them:
```c#
public partial class NewInspectionViewModel : ObservableObject, IEnteringAware<int>, IDisposable
{
...
public ValueTask OnEnteringAsync(int intent)
{
bridgeId = intent;
// do stuff
return ValueTask.CompletedTask;
}
}
```
## Navigation
It supports a variety of methods:
```c#
// 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>());
```
## Bug #39
**This bug is no longer relevant as Nalu no longer contains the images that cause problems. You have to use either your custom images, or pass `null!` as argument.**
At the time of writing (August 1, 2024), [the bug 39](https://github.com/nalu-development/nalu/issues/39) (opened by me) was still open (it is closed with the new release). This is nowhere a breaking bug; it only causes Glide errors on Android systems. The library fails to transfer a few image resources (like *nalu_navigation_menu_png*), which in turn results in the many many of the following logcat entries:
> [Glide] Load failed for [nalu_navigation_menu.png] with dimensions [-2147483648x-2147483648]
> [Glide] class com.bumptech.glide.load.engine.GlideException: Failed to load resource
> [Glide] There were 3 root causes:
> [Glide] java.io.FileNotFoundException(/nalu_navigation_menu.png: open failed: ENOENT (No such file or directory))
> [Glide] java.io.FileNotFoundException(open failed: ENOENT (No such file or directory))
> [Glide] java.io.FileNotFoundException(open failed: ENOENT (No such file or directory))
> [Glide] call GlideException # logRootCauses(String) for more detail
> [Glide] Cause (1 of 3): class com.bumptech.glide.load.engine.GlideException: Fetching data failed, class java.io.InputStream, LOCAL
> [Glide] There was 1 root cause:
> [Glide] java.io.FileNotFoundException(/nalu_navigation_menu.png: open failed: ENOENT (No such file or directory))
> ...
This does not cause any app crashes or errors, but still if you want to not have all these errors, the only solution for now is using your own images:
```c#
builder
.UseMauiApp<App>()
.UseNaluNavigation<App>(nav => nav.AddPages()
.WithMenuIcon(ImageSource.FromFile("menu.png"))
.WithBackIcon(ImageSource.FromFile("back.png")))
```