Shiny MAUI Shell — A Library That Takes Shell to the Next Level


If you’ve built anything non-trivial with .NET MAUI Shell, you’ve felt the pain. String-based routes. No ViewModel lifecycle. No way to unit test navigation. Manual BindingContext wiring. The constant dance with Shell.Current — a static singleton that laughs at your dependency injection setup.

Shiny MAUI Shell fixes all of it.


The Problem with Default MAUI Shell

Let’s be honest about what default Shell navigation looks like in a real app:

// Registering routes — just strings, hope you don't typo
Routing.RegisterRoute("detail", typeof(DetailPage));
Routing.RegisterRoute("settings", typeof(SettingsPage));

// Navigating — more strings, more hope
await Shell.Current.GoToAsync($"detail?id={itemId}&page={pageNum}");

// Receiving parameters — manual parsing in code-behind
[QueryProperty(nameof(Id), "id")]
[QueryProperty(nameof(Page), "page")]
public partial class DetailPage : ContentPage
{
    public string Id { get; set; }
    public string Page { get; set; }
}

What’s wrong with this?

  • Strings everywhere. Rename a route? Find-and-replace across your entire codebase and pray.
  • No compile-time safety. Typo in "detial" instead of "detail"? You’ll find out at runtime.
  • Shell.Current is untestable. Good luck mocking a static singleton in your unit tests.
  • No ViewModel lifecycle. MAUI doesn’t know or care when your ViewModel should initialize, appear, or clean up.
  • Manual wiring. You’re setting BindingContext in code-behind like it’s 2015.
  • No navigation guards. Want to prevent navigation when there are unsaved changes? Build it yourself.

Enter Shiny MAUI Shell

Shiny MAUI Shell wraps the standard MAUI Shell and gives you what should have been there from the start: type-safe navigation, source-generated routes, ViewModel lifecycle management, testable services, and zero boilerplate wiring.

Setup

Install the NuGet package and add one line to your MauiProgram.cs:

var builder = MauiApp.CreateBuilder()
    .UseMauiApp<App>()
    .UseShinyShell(x => x.AddGeneratedMaps());

That’s it. The source generator handles the rest.


Source Generation: The Star of the Show

This is where Shiny MAUI Shell really shines. Decorate your ViewModel with two attributes and the source generator writes all the boilerplate for you.

Your Code

[ShellMap<DetailPage>("Detail")]
public partial class DetailViewModel : ObservableObject, IQueryAttributable
{
    [ShellProperty]
    [ObservableProperty]
    string itemId;

    [ShellProperty(required: false)]
    [ObservableProperty]
    int page;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue(nameof(ItemId), out var id))
            ItemId = id?.ToString();
        if (query.TryGetValue(nameof(Page), out var p))
            Page = int.TryParse(p?.ToString(), out var val) ? val : 0;
    }
}

What Gets Generated

Three files appear at compile time — zero runtime cost.

Route constants so you never type a string route again:

public static class Routes
{
    public const string Detail = "Detail";
}

Strongly-typed navigation extension methods:

public static class NavigationExtensions
{
    public static Task NavigateToDetail(
        this INavigator navigator,
        string itemId,
        int page = default)
    {
        return navigator.NavigateTo<DetailViewModel>(x =>
        {
            x.ItemId = itemId;
            x.Page = page;
        });
    }
}

DI registration for all your page/ViewModel mappings:

public static class NavigationBuilderExtensions
{
    public static ShinyAppBuilder AddGeneratedMaps(this ShinyAppBuilder builder)
    {
        builder.Add<DetailPage, DetailViewModel>("Detail");
        return builder;
    }
}

Invalid route names? The generator emits a SHINY001 compiler error. You catch it before your app even runs.


Here’s the real comparison. Default MAUI Shell vs Shiny MAUI Shell, doing the same thing.

// Default MAUI — string concatenation, no type safety
await Shell.Current.GoToAsync($"detail?id={itemId}&page={pageNum}");

// Shiny — source-generated, strongly typed
await navigator.NavigateToDetail(itemId, pageNum);
// Default MAUI — not possible without code-behind hacks

// Shiny — configure the destination ViewModel directly
await navigator.NavigateTo<DetailViewModel>(vm =>
{
    vm.ItemId = itemId;
    vm.Page = pageNum;
});

Going Back

// Default MAUI
await Shell.Current.GoToAsync("..");

// Shiny — go back with return data
await navigator.GoBack(("Result", true), ("ItemId", selectedId));

Pop to Root

// Default MAUI
await Shell.Current.GoToAsync("//main");

// Shiny
await navigator.PopToRoot();

Switch the Entire Shell

// Default MAUI — not supported without hacking Application.Current.MainPage

// Shiny — first-class support
await navigator.SwitchShell<MainAppShell>();

This is huge for login/logout flows where you need to swap between an authentication shell and your main app shell.


Testable By Default

Default MAUI Shell navigation depends on Shell.Current — a static singleton tied to the UI framework. You can’t mock it. You can’t test it. You just… hope it works.

Shiny MAUI Shell gives you INavigator and IDialogs — proper interfaces that you inject through DI and mock in tests:

public class DetailViewModel(INavigator navigator, IDialogs dialogs)
{
    public async Task Save()
    {
        // Save logic here
        await dialogs.Alert("Success", "Item saved!");
        await navigator.GoBack();
    }

    public async Task Delete()
    {
        var confirmed = await dialogs.Confirm("Delete?", "This cannot be undone.");
        if (confirmed)
        {
            // Delete logic
            await navigator.GoBack(("Deleted", true));
        }
    }
}

Testing this is straightforward:

[Fact]
public async Task Save_ShowsAlert_ThenNavigatesBack()
{
    var navigator = Substitute.For<INavigator>();
    var dialogs = Substitute.For<IDialogs>();
    var vm = new DetailViewModel(navigator, dialogs);

    await vm.Save();

    await dialogs.Received(1).Alert("Success", "Item saved!");
    await navigator.Received(1).GoBack();
}

Try doing that with Shell.Current.GoToAsync. I’ll wait.


ViewModel Lifecycle: Finally

Default MAUI has no concept of a ViewModel lifecycle. Your ViewModel doesn’t know when its page appears, disappears, or gets popped off the stack. Shiny gives you four interfaces — implement only what you need.

IPageLifecycleAware

public class DetailViewModel : ObservableObject, IPageLifecycleAware
{
    public void OnAppearing()
    {
        // Refresh data, start timers, resume subscriptions
    }

    public void OnDisappearing()
    {
        // Pause work, save state
    }
}

INavigationConfirmation

Guard against accidental navigation when there are unsaved changes:

public class EditViewModel : ObservableObject, INavigationConfirmation
{
    bool hasUnsavedChanges;

    public async Task<bool> CanNavigate()
    {
        if (!hasUnsavedChanges)
            return true;

        return await dialogs.Confirm(
            "Unsaved Changes",
            "You have unsaved changes. Discard them?"
        );
    }
}

The user tries to navigate away, gets a confirmation dialog, and can cancel. No custom back button handlers. No page-level overrides. Just implement the interface.

INavigationAware

Mutate parameters as you leave a page:

public class DetailViewModel : ObservableObject, INavigationAware
{
    public void OnNavigatingFrom(IDictionary<string, object> parameters)
    {
        parameters["LastViewed"] = CurrentItemId;
        parameters["Timestamp"] = DateTime.UtcNow;
    }
}

IDisposable

When a page is permanently removed from the navigation stack, Dispose() is called on the ViewModel. Clean up event subscriptions, cancel tokens, release resources.


Dialogs Without the Singleton

Every MAUI developer has written this at some point:

await Shell.Current.DisplayAlert("Error", "Something went wrong", "OK");

Besides being untestable, it requires your ViewModel to know about Shell.Current. Shiny’s IDialogs wraps all of this cleanly:

public class MyViewModel(IDialogs dialogs)
{
    async Task ShowDialogs()
    {
        // Alert
        await dialogs.Alert("Title", "Message");

        // Confirm
        bool confirmed = await dialogs.Confirm("Delete?", "Are you sure?");

        // Prompt
        string name = await dialogs.Prompt("Name", "Enter your name", placeholder: "John");

        // Action Sheet
        string choice = await dialogs.ActionSheet(
            "Options", "Cancel", "Delete", "Edit", "Share"
        );
    }
}

Thread-safe, automatically marshalled to the UI thread, fully mockable.


Need cross-cutting concerns like analytics or logging? Subscribe to navigation events:

public class AnalyticsTracker : IMauiInitializeService
{
    public void Initialize(IServiceProvider services)
    {
        var navigator = services.GetRequiredService<INavigator>();

        navigator.Navigating += (_, args) =>
        {
            Analytics.Track("page_leaving", new Dictionary<string, string>
            {
                ["from"] = args.FromUri,
                ["to"] = args.ToUri,
                ["type"] = args.NavigationType.ToString()
            });
        };

        navigator.Navigated += (_, args) =>
        {
            Analytics.Track("page_viewed", new Dictionary<string, string>
            {
                ["page"] = args.ToUri,
                ["viewModel"] = args.ToViewModel?.GetType().Name
            });
        };
    }
}

Automatic Page-ViewModel Binding

No more setting BindingContext in code-behind. When you register a page/ViewModel pair, Shiny’s ShinyRouteFactory automatically:

  1. Creates the page instance
  2. Resolves the ViewModel from DI
  3. Sets the BindingContext
  4. Wires up all lifecycle events

Your page code-behind becomes what it should be — empty:

public partial class DetailPage : ContentPage
{
    public DetailPage() => InitializeComponent();
}

The Full Picture

Here’s what a complete, production-ready ViewModel looks like with Shiny MAUI Shell:

[ShellMap<DetailPage>("Detail")]
public partial class DetailViewModel(
    INavigator navigator,
    IDialogs dialogs,
    IMyApiService api,
    ILogger<DetailViewModel> logger
) : ObservableObject,
    IQueryAttributable,
    IPageLifecycleAware,
    INavigationConfirmation,
    IDisposable
{
    [ShellProperty]
    [ObservableProperty]
    string itemId;

    [ObservableProperty]
    ItemModel item;

    bool hasUnsavedChanges;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue(nameof(ItemId), out var id))
            ItemId = id?.ToString();
    }

    public async void OnAppearing()
    {
        Item = await api.GetItem(ItemId);
        logger.LogInformation("Loaded item {Id}", ItemId);
    }

    public void OnDisappearing() { }

    public async Task<bool> CanNavigate()
    {
        if (!hasUnsavedChanges) return true;
        return await dialogs.Confirm("Unsaved Changes", "Discard?");
    }

    [RelayCommand]
    async Task Save()
    {
        await api.SaveItem(Item);
        hasUnsavedChanges = false;
        await dialogs.Alert("Saved", "Item saved successfully.");
        await navigator.GoBack();
    }

    public void Dispose() =>
        logger.LogInformation("DetailViewModel disposed");
}

And navigating to it from anywhere:

// One line. Type-safe. Source-generated. Testable.
await navigator.NavigateToDetail("abc-123");

Quick Comparison

CapabilityDefault MAUI ShellShiny MAUI Shell
Route registrationManual stringsSource-generated
Navigation callsString-basedStrongly-typed methods
Parameter passingQuery string concatenationLambda configuration
Compile-time validationNoneSHINY001 errors
ViewModel lifecycleNoneAppearing, Disappearing, Dispose
Navigation guardsNoneINavigationConfirmation
DialogsShell.Current singletonInjectable IDialogs
Unit testabilityEffectively impossibleFirst-class support
Page-ViewModel bindingManual code-behindAutomatic via DI
Shell switchingHack requiredSwitchShell<T>()
Back navigation with dataManualGoBack(params)
Navigation eventsNoneNavigating / Navigated

Getting Started

dotnet add package Shiny.Maui.Shell
// MauiProgram.cs
var builder = MauiApp.CreateBuilder()
    .UseMauiApp<App>()
    .UseShinyShell(x => x.AddGeneratedMaps());

Decorate your ViewModels with [ShellMap] and [ShellProperty], implement the lifecycle interfaces you need, inject INavigator and IDialogs, and let the source generator handle the rest.

Your navigation code becomes type-safe, testable, and maintainable. The way it should have been from the start.

Check it out on GitHub.


comments powered by Disqus