Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

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.

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.

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.

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 text
await 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

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

Display a text input dialog. Returns the entered text, or null if the user cancelled.

// Simple prompt
var name = await dialogs.Prompt("Name", "What is your name?");
if (name != null)
{
// user entered a value
}
// With all options
var 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.)

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

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.

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 fallback
this.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");

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.

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.

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 OnDisappearing when the dialog opens nor OnAppearing when it closes. The dialog ViewModel’s own hooks — including Dispose — are unchanged.
  • Dismissal is a tap on the backdrop, unless you set DismissOnBackdropTap = false. With UxDiversDialogPresenter the Android back button also closes the popup, because UXDivers Popups maps it to closing the topmost popup. ShinyOverlayDialogPresenter has 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 programmatic GoBack) so the awaiting caller is released rather than left hanging. Every one of these paths reports IsCancelled.

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.

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?"
);
}
}
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));
}
}
}
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;
}
}