| name | shiny-maui-shell |
| description | Generate .NET MAUI Shell pages, ViewModels, navigation, and source-generated routes using Shiny MAUI Shell |
| auto_invoke | true |
| triggers | ["maui shell","shell navigation","xaml navigation","attached navigation","tab badge","badge","shell switch","switch shell","maui navigation","maui page","maui viewmodel","INavigator","IDialogs","ShellMap","ShellProperty","appLinks","app link","app links","applink","deep link","deeplink","universal link","custom url scheme","url scheme","IAppLinks","UseAppLinks","AppLinkOptions","AppLinkRegistry","AppLinkRoutes","AppLinkMatch","ShinyAppLinkSchemes","ShinyAppLinkDomains","[Truncated]"] |
Shiny MAUI Shell Skill
You are an expert in Shiny MAUI Shell, a library that enhances .NET MAUI Shell with ViewModel lifecycle management, navigation services, source generation, tab badges, and XAML-triggered navigation.
When to Use This Skill
Invoke this skill when the user wants to:
- Create new MAUI pages with ViewModels using Shiny Shell conventions
- Set up or configure Shiny MAUI Shell in their application
- Switch between different Shell instances at runtime (e.g., login shell vs main app shell)
- Implement navigation between pages using
INavigator
- Set or clear tab badge values on tabs in the active Shell
- Add route-based XAML navigation with
Navigate.* attached properties
- Build multi-segment navigation chains using
INavigationBuilder (push multiple pages, pop-and-push)
- Show dialogs (alert, confirm, prompt, action sheet) using
IDialogs
- Add ViewModel lifecycle hooks (appearing, disappearing, navigation confirmation)
- Use source generation with
[ShellMap] and [ShellProperty] attributes
- Pass parameters between pages during navigation
- Create modal pages or tab navigation
- Migrate from vanilla MAUI Shell or Prism navigation to Shiny MAUI Shell
- Set up AI-driven navigation using
Microsoft.Extensions.AI with route discovery and NavigateToRoute
- Create AI-compatible ViewModels with descriptive
[ShellMap] and [ShellProperty] attributes
Library Overview
Documentation: https://shinylib.net/maui
GitHub: https://github.com/shinyorg/mauishell
NuGet: Shiny.Maui.Shell
Namespace: Shiny
Shiny MAUI Shell wraps .NET MAUI Shell to provide:
- Page-to-ViewModel registration and automatic BindingContext assignment
- A testable
INavigator service for all navigation operations
- A testable
IDialogs service for alert, confirm, prompt, and action sheet dialogs
INavigationBuilder for multi-segment navigation (push multiple pages in one operation, pop-and-push)
- Native numeric tab badges via
INavigator.SetTabBadge* / ClearTabBadge*
- Attached-property XAML navigation via
Navigate.Route, Navigate.RelativeNavigation, and parameter helpers
- Shell switching — swap the entire Shell at runtime (e.g., login → main app)
- ViewModel lifecycle interfaces (appearing, disappearing, dispose, navigation confirmation)
INavigationInterceptor guards that can cancel or redirect any navigation - including app links, shortcuts and tab taps
- Source generators that eliminate boilerplate route registration, produce strongly-typed navigation methods, and generate AI tool metadata
ShinyShell base class for deterministic initial-page BindingContext assignment
ShellServices record that aggregates INavigator, IDialogs, and IMainThread for convenient single-parameter injection
IMainThread abstraction with built-in workarounds for macOS and Linux where MainThread.InvokeOnMainThreadAsync can deadlock / fail
- Pluggable
IDialogs implementation via UseDialogs<TDialog>() — swap in your own dialog provider (e.g. ACR UserDialogs, a custom sheet, a test double)
Inspired by Prism Library by Dan Siegel and Brian Lagunas.
Setup
1. Install NuGet Package
dotnet add package Shiny.Maui.Shell
2. Configure in MauiProgram.cs
Manual registration:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseShinyShell(x => x
.Add<MainPage, MainViewModel>(registerRoute: false)
.Add<DetailPage, DetailViewModel>("Detail")
.Add<SettingsPage, SettingsViewModel>("Settings")
)
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
return builder.Build();
}
With source generation (preferred):
builder
.UseMauiApp<App>()
.UseShinyShell(x => x
.AddGeneratedMaps()
.AddAiTools()
)
With a custom dialog provider:
builder
.UseMauiApp<App>()
.UseShinyShell(x => x
.AddGeneratedMaps()
.UseDialogs<MyCustomDialogs>()
);
UseDialogs<TDialog>() replaces the default ShellDialogs provider. The default registration uses TryAddSingleton, so a UseDialogs<> call always wins.
Built-in alternative providers (same IDialogs interface — no ViewModel changes):
builder
.UseMauiApp<App>()
.UseShinyControls()
.UseShinyShell(x => x
.AddGeneratedMaps()
.UseShinyDialogs()
.UseShinyDialogPresenter()
);
builder
.UseMauiApp<App>()
.UseShinyShell(x => x
.AddGeneratedMaps()
.UseUxDiversDialogs()
.UseUxDiversDialogPresenter()
);
Either UXDivers call initializes the popup infrastructure (UseUXDiversPopups()) itself — do NOT
also call builder.UseUXDiversPopups(), and calling both Shiny extensions initializes it once.
3. AppShell must inherit from ShinyShell
Your AppShell (or any Shell subclass) must inherit from Shiny.ShinyShell instead of Shell. This ensures the initial page's BindingContext is set deterministically via Shell's own OnNavigated lifecycle.
AppShell.xaml:
<shiny:ShinyShell
x:Class="MyApp.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:shiny="clr-namespace:Shiny;assembly=Shiny.Maui.Shell"
xmlns:local="clr-namespace:MyApp"
Title="MyApp">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</shiny:ShinyShell>
AppShell.xaml.cs:
using Shiny;
namespace MyApp;
public partial class AppShell : ShinyShell
{
public AppShell()
{
InitializeComponent();
}
}
Important Notes
- Pages defined in AppShell.xaml should use
registerRoute: false since Shell already registers them
- Pages navigated to programmatically need route registration (the default behavior)
- All Pages and ViewModels are registered as Transient in DI automatically
Code Generation Instructions
When generating code for Shiny MAUI Shell projects, follow these conventions:
1. ViewModels
All ViewModels must implement INotifyPropertyChanged. Use CommunityToolkit.Mvvm ObservableObject as the base:
[ShellMap<MyPage>("MyRoute")]
public partial class MyViewModel : ObservableObject
{
}
- Use
[ShellMap<TPage>("Route")] on every ViewModel class
- The
route parameter must be a valid C# identifier — it is used as the generated constant name and method name
- Invalid route names (hyphens, spaces, leading digits) produce a SHINY001 compiler error
- When no route is specified, the page type name without the
Page suffix is used as the generated name
- Set
registerRoute: false only for pages already declared in AppShell.xaml
- ViewModel classes using source generation should be
partial
- Use primary constructors to inject
INavigator and other dependencies
2. Navigation Properties
Use [ShellProperty] on ViewModel properties that should be passed as navigation parameters:
[ShellMap<DetailPage>("Detail")]
public partial class DetailViewModel : ObservableObject
{
[ShellProperty]
public string ItemId { get; set; }
[ShellProperty(required: false)]
public int PageIndex { get; set; }
}
- Properties marked
[ShellProperty] are required by default
- Use
[ShellProperty(required: false)] for optional parameters
[ShellProperty] properties are set directly by the source-generated navigation methods — no IQueryAttributable needed
- Source generator creates strongly-typed extension methods on
INavigator
3. Lifecycle Interfaces
Implement these interfaces on ViewModels as needed:
| Interface | Purpose |
|---|
IPageLifecycleAware | OnAppearing() / OnDisappearing() hooks |
INavigationConfirmation | Task<bool> CanNavigate() - confirm before leaving. Only asked for user-driven Shell navigation (tab tap, flyout, hardware back), not for INavigator calls - use an INavigationInterceptor to guard those |
INavigationAware | OnNavigatingFrom(IDictionary<string, object>) - mutate args before leaving |
IQueryAttributable | ApplyQueryAttributes(IDictionary<string, object>) - receive navigation args (only needed for string-based NavigateTo(route, args) — not needed when using [ShellProperty]) |
IDisposable | Cleanup when page is removed from navigation stack |
4. Navigation Events
INavigator exposes two events for observing navigation:
Navigating — fires before navigation with the source ViewModel instance
Navigated — fires after navigation with the destination ViewModel instance
navigator.Navigating += (sender, args) =>
{
};
navigator.Navigated += (sender, args) =>
{
};
Hook these events in an IMauiInitializeService for cross-cutting concerns like logging or analytics.
4a. Navigation Interceptors (guards)
Use INavigationInterceptor when navigation must be blocked or rerouted - auth guards, unsaved
changes, feature flags. Use the Navigating/Navigated events when you only need to observe.
public class AuthNavigationInterceptor(IAuthService auth) : INavigationInterceptor
{
public int Order => -100;
public async Task<NavigationInterceptorResult> InterceptNavigationAsync(
string uri,
object? viewModel,
CancellationToken cancellationToken
)
{
if (await auth.IsAuthorized(cancellationToken) || uri.Contains("Login"))
return NavigationInterceptorResult.Continue;
return NavigationInterceptorResult.Redirect<LoginViewModel>();
}
}
Register in MauiProgram.cs - they run in registration order, first to cancel or redirect wins:
builder.UseShinyShell(x => x
.AddGeneratedMaps()
.AddNavigationInterceptor<AuthNavigationInterceptor>()
.AddNavigationInterceptor<AuditNavigationInterceptor>()
.AddNavigationInterceptor((uri, vm, ct) => Task.FromResult(NavigationInterceptorResult.Continue), order: 100)
);
Results:
| Result | Behaviour |
|---|
NavigationInterceptorResult.Continue | Next interceptor, then navigate |
Cancel() | Nothing navigates; the caller's Task completes normally |
Redirect("Detail") | Push |
Redirect("//Main/Home") / Redirect("/Login") | Reset the stack (single leading / is promoted to //) |
Redirect<LoginViewModel>() | Reset the stack to that ViewModel's route - prefer this, it is refactor-safe |
Redirect<DetailViewModel>(relativeNavigation: true) | Push that ViewModel's route |
Rules to generate correctly:
- The
viewModel argument is the destination ViewModel, already resolved and populated
(configure callback run, app link values applied). Mutating it is allowed - that instance is
bound to the page, except on a registerRoute: false (ShellContent) page that is already
realised, which keeps the ViewModel it already has. It is null for unmapped routes and for Shell-driven navigation (tab taps,
hardware back), so always null-check or pattern-match: if (viewModel is DetailViewModel vm).
- The ViewModel being left comes from
INavigationContextAccessor.Current.FromViewModel -
inject INavigationContextAccessor for that, plus FromUri, ToUri, NavigationType,
Parameters, RedirectCount.
- A redirect re-runs the whole chain against the new URI. Guard against redirecting to the page you
are guarding (check the URI or ViewModel type first), or it just gets ignored; a real loop throws
after 10 hops.
- Interceptors cover
INavigator calls, the navigation builder, back navigation, app links, app
shortcuts and user-driven Shell navigation. They do not cover ShowDialog or SwitchShell.
- Anything thrown propagates to the caller and the navigation does not happen.
- Interceptors are registered as singletons - do not hold per-navigation state in fields.
- Ordering is
Order (lowest first) then registration order. Put guards below 0 and observers above.
- Every
INavigator navigation method returns Task<bool> - false means an interceptor cancelled
it (a redirect returns true). Generated NavigateTo{Route} methods return Task<bool> too.
- To navigate from inside a guard, or for any navigation that must not be guarded, pass
bypassInterceptors: true: NavigateTo<LoginViewModel>(bypassInterceptors: true),
GoBack(1, bypassInterceptors: true), PopToRoot(bypassInterceptors: true),
CreateBuilder()...Navigate(bypassInterceptors: true) or the fluent
CreateBuilder().BypassInterceptors()...Navigate(). A RedirectUri does not need it.
INavigationContextAccessor.Current.Direction gives Forward / Back / Root when the rule
only cares which way the user is going; .GetDirection() converts any NavigationType.
- An inbound app link a guard blocks reports
AppLinkResult.Blocked from IAppLinks.Handle
(distinct from Unhandled, which means nothing matched).
- A dialog can be awaited inside an interceptor (
IDialogs.Confirm / ActionSheet) - the
navigation waits on the answer. Treat a dismissed sheet (which returns the cancel text) as
Cancel(), and always narrow to the destination being guarded first.
public class UnsavedChangesInterceptor(
INavigationContextAccessor context,
IDialogs dialogs
) : INavigationInterceptor
{
public async Task<NavigationInterceptorResult> InterceptNavigationAsync(
string uri,
object? viewModel,
CancellationToken cancellationToken
)
{
if (context.Current?.FromViewModel is not IUnsavedChanges { HasUnsavedChanges: true })
return NavigationInterceptorResult.Continue;
return await dialogs.Confirm("Unsaved Changes", "Discard changes?")
? NavigationInterceptorResult.Continue
: NavigationInterceptorResult.Cancel();
}
}
public class AskFirstInterceptor(IDialogs dialogs) : INavigationInterceptor
{
const string LetItGo = "Let it through";
const string SendElsewhere = "Go to Settings instead";
const string StopIt = "Stop navigation";
public async Task<NavigationInterceptorResult> InterceptNavigationAsync(
string uri,
object? viewModel,
CancellationToken cancellationToken
)
{
if (viewModel is not DetailViewModel)
return NavigationInterceptorResult.Continue;
var choice = await dialogs.ActionSheet(
$"Navigating to '{uri}'",
cancel: StopIt,
destruction: null,
buttons: [LetItGo, SendElsewhere]
);
return choice switch
{
LetItGo => NavigationInterceptorResult.Continue,
SendElsewhere => NavigationInterceptorResult.Redirect<SettingsViewModel>(relativeNavigation: true),
_ => NavigationInterceptorResult.Cancel()
};
}
}
5. Navigation