Mustafa Can Yücel
blog-post-24

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"))) ```