Shell | Dialogs
All user-facing dialogs in Shiny Shell go through the IDialogs interface. Inject it into your ViewModels via constructor injection.
public class MyViewModel(IDialogs dialogs){ // dialogs is ready to use}IDialogs is registered as a singleton by UseShinyShell() and dispatches all calls to the UI thread automatically — safe to call from any thread.
Custom Dialog Providers
Section titled “Custom Dialog Providers”The default IDialogs implementation (ShellDialogs) uses MAUI’s built-in Shell dialogs. You can replace it with your own implementation — for example to plug in a third-party dialog library, a custom bottom-sheet, or a test double — using UseDialogs<TDialog>():
builder .UseMauiApp<App>() .UseShinyShell(x => x .AddGeneratedMaps() .UseDialogs<MyCustomDialogs>() );
public class MyCustomDialogs : IDialogs{ public Task Alert(string? title, string message, string acceptText = "OK") { /* ... */ } public Task<bool> Confirm(string? title, string message, string acceptText = "Yes", string cancelText = "No") { /* ... */ } public Task<string?> Prompt(string? title, string message, string acceptText = "OK", string cancelText = "Cancel", string? placeholder = null, string initialValue = "", int maxLength = -1, Keyboard? keyboard = null) { /* ... */ } public Task<string> ActionSheet(string? title, string? cancel, string? destruction, params string[] buttons) { /* ... */ }}The default registration uses TryAddSingleton, so calling UseDialogs<>() always wins — regardless of call order.
Shiny Controls Dialogs
Section titled “Shiny Controls Dialogs”A pre-built alternative using the owned, animated, themeable dialog service from Shiny.Maui.Controls. The dialog is always rendered by the library — the native platform alert/prompt is never used — so it looks identical across platforms and respects your theme. Install Shiny.Maui.Shell.ShinyDialogs, then configure:
builder .UseMauiApp<App>() .UseShinyControls() // registers the Shiny.Maui.Controls IDialogService .UseShinyShell(x => x .UseShinyDialogs() // route IDialogs through the Controls dialog service .AddGeneratedMaps() )UseShinyControls() registers the underlying IDialogService; UseShinyDialogs() registers the IDialogs adapter that forwards Shell dialog calls to it. No ViewModel changes needed — same IDialogs interface, different visual presentation.
The package also ships a matching IDialogPresenter for ViewModel dialogs — add UseShinyDialogPresenter() and ShowDialog renders as a themed card over a dimmed backdrop instead of a modal page. See Changing how dialogs appear.
UxDivers Popups
Section titled “UxDivers Popups”A pre-built alternative using UXDivers Popups for styled, animated popup dialogs. Install UXDivers.Popups.Maui, add theme dictionaries to App.xaml, then configure:
builder .UseMauiApp<App>() .UseShinyShell(x => x .UseUxDiversDialogs() // Registers IDialogs and initializes the UxDivers popup infrastructure .AddGeneratedMaps() )Add these to your App.xaml merged dictionaries:
<uxd:DarkTheme xmlns:uxd="clr-namespace:UXDivers.Popups.Maui.Controls;assembly=UXDivers.Popups.Maui" /><uxd:PopupStyles xmlns:uxd="clr-namespace:UXDivers.Popups.Maui.Controls;assembly=UXDivers.Popups.Maui" />No ViewModel changes needed — same IDialogs interface, different visual presentation.
The package also ships a matching IDialogPresenter for ViewModel dialogs — add UseUxDiversDialogPresenter() and ShowDialog renders in a UXDivers PopupPage instead of a modal page. See Changing how dialogs appear.
Display an informational dialog with a single button.
await dialogs.Alert("Error", "Something went wrong");
// With custom button textawait dialogs.Alert("Success", "Item saved successfully", "Got it");| Parameter | Type | Default | Description |
|---|---|---|---|
title |
string? |
— | Alert title. Pass null to omit. |
message |
string |
— | Alert body text (required) |
acceptText |
string |
"OK" |
Button text |
Confirm
Section titled “Confirm”Display a confirmation dialog and return the user’s choice.
bool confirmed = await dialogs.Confirm( "Delete Item", "Are you sure you want to delete this?", "Delete", "Cancel");
if (confirmed){ // proceed with deletion}| Parameter | Type | Default | Description |
|---|---|---|---|
title |
string? |
— | Dialog title. Pass null to omit. |
message |
string |
— | Dialog body text (required) |
acceptText |
string |
"Yes" |
Accept button text |
cancelText |
string |
"No" |
Cancel button text |
Prompt
Section titled “Prompt”Display a text input dialog. Returns the entered text, or null if the user cancelled.
// Simple promptvar name = await dialogs.Prompt("Name", "What is your name?");if (name != null){ // user entered a value}
// With all optionsvar pin = await dialogs.Prompt( "Security", "Enter your PIN", acceptText: "Submit", cancelText: "Cancel", placeholder: "4-digit PIN", initialValue: "", maxLength: 4, keyboard: Keyboard.Numeric);| Parameter | Type | Default | Description |
|---|---|---|---|
title |
string? |
— | Dialog title. Pass null to omit. |
message |
string |
— | Dialog body text (required) |
acceptText |
string |
"OK" |
Accept button text |
cancelText |
string |
"Cancel" |
Cancel button text |
placeholder |
string? |
null |
Placeholder text shown when the input is empty |
initialValue |
string |
"" |
Pre-filled input value |
maxLength |
int |
-1 |
Maximum characters allowed (-1 = no limit) |
keyboard |
Keyboard? |
null |
Keyboard type (Keyboard.Numeric, Keyboard.Email, etc.) |
ActionSheet
Section titled “ActionSheet”Display an action sheet with multiple options. Returns the text of the selected button.
var action = await dialogs.ActionSheet( "Photo Options", "Cancel", "Delete Photo", "Take Photo", "Choose from Library", "Share");
switch (action){ case "Take Photo": // open camera break; case "Choose from Library": // open gallery break; case "Share": // share photo break; case "Delete Photo": // destructive action break;}| Parameter | Type | Description |
|---|---|---|
title |
string? |
Sheet title. Pass null to omit. |
cancel |
string? |
Cancel button text. Pass null to omit. |
destruction |
string? |
Destructive action text (shown in red on some platforms). Pass null to omit. |
buttons |
string[] |
Action button labels |
ViewModel Dialogs
Section titled “ViewModel Dialogs”IDialogs covers the four primitives above. When a dialog needs real UI to collect a result — a
colour picker, a filter sheet, a signature pad — build an ordinary Page + ViewModel pair, have the
ViewModel implement IDialogAware<T>, and await the value it produces.
[ShellMap<PickColorPage>("PickColor")]public partial class PickColorViewModel : ObservableObject, IDialogAware<string>{ public event EventHandler<string>? Completed; public event EventHandler? Cancelled;
[ShellProperty("The colour to pre-select", required: false)] public string Preset { get; set; } = "Red";
[RelayCommand] void Pick(string colour) => this.Completed?.Invoke(this, colour); [RelayCommand] void Cancel() => this.Cancelled?.Invoke(this, EventArgs.Empty);}The ViewModel raises exactly one of the two events to close itself. There is no base class to inherit
— a ViewModel’s base slot belongs to ObservableObject.
Calling it
Section titled “Calling it”The source generator emits a typed Show{Route}Dialog extension for every dialog-aware [ShellMap]
ViewModel, so the call site needs no type arguments and [ShellProperty] values become parameters:
var result = await navigator.ShowPickColorDialog(preset: "Violet");
if (result.TryGetValue(out var colour)) this.Selected = colour;
// or, with a fallbackthis.Selected = result.ValueOr("Red");The underlying method is public if you’d rather not use the generated wrapper, but it needs both type arguments spelled out — C# cannot infer a type argument from a constraint:
var result = await navigator.ShowDialog<PickColorViewModel, string>(x => x.Preset = "Violet");DialogResult<T>
Section titled “DialogResult<T>”ShowDialog returns DialogResult<T> rather than T because default(T) cannot express
cancellation for a value type — a bool dialog could not otherwise tell “the user chose No” apart
from “the user dismissed it”.
| Outcome | Result |
|---|---|
ViewModel raised Completed |
IsCancelled == false, Value set |
ViewModel raised Cancelled |
IsCancelled == true |
| User dismissed the dialog (hardware back, iOS swipe-down) | IsCancelled == true |
The CancellationToken you passed fired |
throws OperationCanceledException |
Every dismissal path completes the await. A dialog closed without either event being raised
reports cancellation rather than hanging forever.
Lifecycle
Section titled “Lifecycle”| Hook | Dialog ViewModel | Page underneath |
|---|---|---|
IPageLifecycleAware.OnAppearing |
✅ when shown | ✅ when the dialog closes |
IPageLifecycleAware.OnDisappearing |
✅ when closed | ✅ when the dialog opens |
IDisposable.Dispose |
✅ when closed | — |
INavigationAware.OnNavigatingFrom |
❌ not raised | ❌ not raised |
INavigationConfirmation.CanNavigate |
❌ not consulted | ❌ not consulted |
INavigator.Navigating / Navigated |
❌ not raised | ❌ not raised |
The three that don’t fire are deliberate. Showing a dialog is not a navigation stack mutation, and an “are you sure you want to leave?” guard firing because a dialog opened would be wrong.
The Page underneath column describes the default modal presenter. With either of the overlay presenters below, that page never leaves the screen, so it gets neither hook — see Changing how dialogs appear.
Changing how dialogs appear
Section titled “Changing how dialogs appear”How a dialog appears is decided by the registered IDialogPresenter. The ViewModel, the
IDialogAware<T> contract, and the call site are identical whichever one you register.
| Presenter | Package | Register with | Presentation |
|---|---|---|---|
ShellModalDialogPresenter (default) |
Shiny.Maui.Shell |
— | The page on Shell’s modal stack |
ShinyOverlayDialogPresenter |
Shiny.Maui.Shell.ShinyDialogs |
UseShinyDialogPresenter() |
A themed card over a dimmed backdrop, inside the current page |
UxDiversDialogPresenter |
Shiny.Maui.Shell.UxDiversDialogs |
UseUxDiversDialogPresenter() |
A UXDivers PopupPage over a dimmed backdrop |
builder.UseShinyShell(x => x .AddGeneratedMaps() .UseShinyDialogs() // IDialogs -> Shiny.Maui.Controls .UseShinyDialogPresenter() // ShowDialog -> overlay card);Both overlay presenters take the same options:
.UseShinyDialogPresenter(o => // or .UseUxDiversDialogPresenter(o => ...){ o.BackdropOpacity = 0.6; // 0.5 by default o.BackdropColor = null; // null follows the theme's scrim o.DismissOnBackdropTap = true; o.CornerRadius = 24; // 16 by default o.CardBackgroundColor = null; // null follows the theme's surface colour o.MaxWidth = 480; // 420 by default o.Margin = new Thickness(24); o.AnimationDuration = 220;})ShinyOverlayDialogPresenter adds ConfigureCard (an Action<Border> run against the card before it
is shown); UxDiversDialogPresenter adds AvoidKeyboard and ConfigurePopup (an Action<PopupPage>,
most usefully for a different AppearingAnimation).
Two behavioural differences from the modal default, both a consequence of the page underneath staying on screen behind the scrim:
- The page underneath receives neither
OnDisappearingwhen the dialog opens norOnAppearingwhen it closes. The dialog ViewModel’s own hooks — includingDispose— are unchanged. - Dismissal is a tap on the backdrop, unless you set
DismissOnBackdropTap = false. WithUxDiversDialogPresenterthe Android back button also closes the popup, because UXDivers Popups maps it to closing the topmost popup.ShinyOverlayDialogPresenterhas no popup to close — its overlay lives inside the page — so a back press navigates the page underneath and takes the dialog with it; the presenter watches for the host page disappearing (from any cause: a back press, a tab switch, a programmaticGoBack) so the awaiting caller is released rather than left hanging. Every one of these paths reportsIsCancelled.
Writing your own
Section titled “Writing your own”Implement IDialogPresenter for anything that can host a Page:
public class MyPopupPresenter : IDialogPresenter{ public async Task Present(Page page, object viewModel, CancellationToken dismiss) { // show the page... // complete this Task once the page is gone, whether the user dismissed it // or `dismiss` fired. Never throw OperationCanceledException on `dismiss`. }}builder.UseShinyShell(x => x .AddGeneratedMaps() .UseDialogPresenter<MyPopupPresenter>());For a host that takes a View rather than a Page — a popup, a bottom sheet, a custom overlay —
derive from ViewDialogPresenter, which is what both built-in overlay presenters are built on. It
hands you the page’s content with its binding context already set, and takes care of everything the
page would otherwise have done for you: raising IPageLifecycleAware, disposing an IDisposable
ViewModel, and giving the content back to its page afterwards.
public class MySheetPresenter(IMainThread mainThread) : ViewDialogPresenter(mainThread){ protected override async Task PresentView(View content, object viewModel, CancellationToken dismiss) { // called on the main thread, binding context already set. // show `content`, complete once it is gone, and detach it from your // host before returning so it can go back to its page. }}IDialogPresenter only decides how the page appears. Resolving the ViewModel, running configure,
awaiting the result, and tearing the dialog down all stay in the navigator, so a custom presenter
never reimplements any of that.
Common Patterns
Section titled “Common Patterns”Navigation Confirmation
Section titled “Navigation Confirmation”Use IDialogs with INavigationConfirmation to guard unsaved changes:
public class EditViewModel(INavigator navigator, IDialogs dialogs) : INavigationConfirmation{ public bool HasUnsavedChanges { get; set; }
public async Task<bool> CanNavigate() { if (!HasUnsavedChanges) return true;
return await dialogs.Confirm( "Unsaved Changes", "You have unsaved changes. Discard them?" ); }}Delete Confirmation
Section titled “Delete Confirmation”public class ItemViewModel(INavigator navigator, IDialogs dialogs){ async Task DeleteItem() { if (await dialogs.Confirm("Delete", "This action cannot be undone.")) { await itemService.Delete(ItemId); await navigator.GoBack(("Deleted", true)); } }}User Input
Section titled “User Input”public class ListViewModel(IDialogs dialogs){ async Task RenameItem(Item item) { var newName = await dialogs.Prompt( "Rename", "Enter a new name", placeholder: "New name", initialValue: item.Name, maxLength: 100 );
if (newName != null) item.Name = newName; }}

