Scaffold Popups & Bottom Sheets
The Scaffold ships one shared overlay primitive powering popups, bottom sheets, drawers and the tab bar's overflow panel: popups and sheets stack freely in open order, each above its own scrim (the drawer and the tab bar panel are single-instance; the panel's scrim sits below the bar), with consistent back/scrim-tap dismissal — identical on iOS and Android, no platform modals involved.
Popups
IScaffoldPopup popup = await scaffold.ShowPopupAsync(new MyPopupView());
// Then either await the close...
await popup.Closed; // completes on EVERY close path
// ...OR close it programmatically (also IAsyncDisposable):
await popup.CloseAsync();
ScaffoldPopupOptions (or the equivalent ScaffoldPopup.* attached properties on the view
itself — except Anchor, AnchorOffset and CustomPlacer, which are call-site only) control
presentation:
| Option | Purpose |
|---|---|
Placement |
Center (default) or anchor-relative: AnchorBelow, AnchorAbove, AnchorStart, AnchorEnd (dropdowns). |
Anchor + AnchorOffset |
The view the anchored placements are relative to. |
CustomPlacer |
IScaffoldPopupPlacer for fully custom geometry. |
Margin |
Safe-area-aware margin around the placement area. |
Scrim |
Dimming brush (always fades in/out; input-blocking even when transparent). |
CloseOnScrimTap / CloseOnBack |
Dismissal policy (default true). |
Declarative flavor — options attached to the view:
<ContentView nalu:ScaffoldPopup.Placement="AnchorBelow"
nalu:ScaffoldPopup.Scrim="#40000000">
Popups enter with a subtle fade+scale; the content view is measured at its natural size within the placement area — and re-placed whenever that natural size changes after presentation (a deferred image, an expanding section, a loaded list): the popup re-fits and re-centers/re-anchors on its own, nothing to call.
Bottom sheets
The sample's duration picker: a model-first bottom sheet (IOverlayService) hosting the
Nalu.Maui.Controls DurationWheel, closing with a typed result.
await scaffold.ShowBottomSheetAsync(new FilterSheet(), new ScaffoldBottomSheetOptions
{
Detents = [ScaffoldSheetDetent.Content, ScaffoldSheetDetent.Fraction(0.9)],
InitialDetent = 0,
ShowDragHandle = true
});
| Option | Purpose |
|---|---|
Detents |
Resting heights: Content (natural height), Fraction(0..1), Height(dp). |
InitialDetent |
Index into Detents. |
AllowPullDownToClose |
Drag below the lowest detent dismisses (default true). |
ShowDragHandle |
The standard grabber. |
MaxWidth |
Caps sheet width (centered) — tablets/landscape. |
Scrim, CloseOnScrimTap, CloseOnBack |
As for popups. |
Sheets are draggable between detents with native-feeling physics; the sheet handles its own
bottom safe-area padding. A Content detent follows the content's natural height live: content
that grows or shrinks after presentation re-resolves the detent (the sheet stays bottom-anchored on
the detent it rests on). The same ScaffoldBottomSheet.* attached properties exist for
declaring options on the sheet view.
Soft keyboard
Sheets and popups hosting text input are keyboard-aware out of the box: by default the keyboard
takes room away from the topmost presented sheet or popup (Resize — the sheet pads its content
above the keyboard, the popup is re-placed above it), and Scaffold.KeyboardMode on the content
(or KeyboardMode in the options) switches to Pan or None. The whole story — pages, sheets,
popups, who owns the keyboard, None + MAUI's SafeAreaEdges — lives in
Scaffold & the Soft Keyboard.
Tab bar panels
Scaffold.ShowTabBarPanelAsync(View, Brush? scrim, bool closeIfOpened) presents a panel
docked above the tab bar while keeping the bar interactive (the scrim covers the page, not
the bar) — this is what the default tab bar's overflow "More" uses (see
Structure & Tab Bar), available for your own quick-switch
panels. ScaffoldTabBar.ShowPanelAsync(...) is the area-level equivalent.
MVVM overlays — IOverlayService
For model-first flows, register overlays and show them without touching views:
builder.UseNaluScaffold(scaffold => scaffold.AddOverlays());
AddOverlays() is source-generated (the generator ships inside the NuGet package): at
build time it discovers every class whose public constructor takes IOverlayRef — the
model-first anchor — and emits plain AddOverlay<...> calls, AOT/trim-safe:
- a
View-derived class takingIOverlayRefregisters view-only (AddOverlay<TView>()): the view is its own lifecycle target, shown viaShow*Async<TView>(), itsBindingContextleft untouched; - any other class is an overlay model, paired with the
Viewwhose constructor takes the model type (a view assigning it toBindingContextwins ties), or theFooModel → FooViewnaming convention.
[AutoOverlay] tunes the discovery: opt in a model that doesn't inject IOverlayRef, name
the view explicitly with [AutoOverlay(typeof(TheView))] when several match, or opt out with
[AutoOverlay(Enabled = false)]. Overlays in other assemblies (which the generator does
not scan) register manually — AddOverlay<TModel, TView>(), AddOverlay<TView>() and the
view-factory overload all remain available and compose freely with AddOverlays().
Diagnostics NALU0101–NALU0103 flag unresolvable or ambiguous views.
public class ItemsPageModel(IOverlayService overlays)
{
public async Task DeleteAsync()
{
// Model receives the intent; TResult completes when the overlay closes.
var confirmed = await overlays.ShowPopupAsync<ConfirmDeleteModel, bool>(itemId);
...
await overlays.ShowBottomSheetAsync<FilterSheetModel>();
}
}
Options can still be passed per call, or declared on the view via the attached properties.
Closing: IOverlayRef
The ref the model (or the view — it is resolvable throughout the presentation scope) injects is exactly two methods, both non-generic:
public interface IOverlayRef
{
Task CloseAsync(); // caller's task completes with default
Task CloseAsync(object? result); // caller's task completes with the result
}
- the result is passed as
object?and validated at close time against theTResultthe overlay was shown with: a mismatch throwsInvalidOperationException, and so does reporting any result at all when the overlay was shown through a resultless overload (Show*Async<TModel>()) — the result-carrying overload is the only one that accepts one; CloseAsync(null)on aTResultpresentation completes the caller withdefault, which is also what every DISMISSAL produces (scrim tap, pull-down, system back, navigation). A model that must distinguish "dismissed" from an explicit empty answer needs a referenceTResultwrapper — aboolresult cannot tellfalseapart from a dismissal;- closing is legal BEFORE the overlay is presented: a close requested from
OnEnteringAsyncis buffered and skips the presentation entirely — the caller's task completes without the overlay ever appearing, andILeavingAware/disposal still run.
Intents: OnEnteringAsync, not the constructor
The intent is NOT a constructor parameter — the model is built by DI first (it can only see
IOverlayRef and registered services), then the intent is delivered exactly as the navigation
engine delivers it to page models:
- a single-parameter method named
OnEnteringAsyncreturningValueTask, whose parameter type the intent is assignable to, is found by reflection and invoked — implementingIEnteringAware<TIntent>is the typed way to declare it (explicit interface implementations match too); - when no such overload fits — or no intent was passed — the parameterless
IEnteringAwarehook runs instead, if implemented; ILeavingAwareruns when the overlay closes, thenIAsyncDisposable/IDisposable.
public partial class DurationSheetModel(IOverlayRef overlay) : IEnteringAware<DurationSheetIntent>
{
public ValueTask OnEnteringAsync(DurationSheetIntent intent) { ... }
private Task Done() => overlay.CloseAsync(new DurationSheetResult(Duration));
}
Scopes
IOverlayService is registered as a singleton (inject it like INavigationService — from
page models and from app-wide services alike), over a singleton registry built once inside
UseNaluScaffold — the AddOverlay* calls are evaluated at startup, never per presentation.
Each presentation creates its own DI scope for the model/view pair, disposed when the
overlay closes: it is a fresh scope (a child of the root provider), not a child of the calling
page's scope, so page-scoped services are not shared with the overlay — the intent is the
channel for what the overlay needs to know.
Keep ONE public constructor per model/view: multi-constructor selection is not service-aware.
While the app is not scaffold-hosted (a non-scaffold navigation host, or a platform without
scaffold hosting — see platform support), every call is a
graceful no-op returning default immediately.
Options resolution order (popups vs sheets)
Both kinds resolve each option as call-site value ?? the content's attached value ?? default, but they read the attached values at a different moment:
- a popup is attached to the scaffold's element tree FIRST (when the view has no parent yet) and read AFTER — attached values produced by styles or resources resolved on parenting are seen;
- a sheet's GEOMETRY options (
Detents,InitialDetent,AllowPullDownToClose,ShowDragHandle,MaxWidth) are read BEFORE the content is wrapped in the sheet chrome and attached — they must be literal on the view; onlyScrim,CloseOnScrimTapandCloseOnBackare read after attachment.
Pass ScaffoldBottomSheetOptions at the call site when a geometry value is not a literal.
Stack semantics
- Entries stack freely (popup over sheet over popup); each has its own scrim.
- System back closes the topmost entry with
CloseOnBack = true; entries withCloseOnBack = falseconsume back without closing. - Navigation closes all open overlays before the page swap.
- Overlay content never self-insets (the scrim covers the whole window uniformly); sheets and panels manage the safe areas that matter for them.
Observing overlays: Scaffold.OverlayEvent
Overlays are not pages: they never appear in Scaffold.NavigationEvent. To observe them
(analytics, diagnostics), subscribe to Scaffold.OverlayEvent: one Presented / Closed
pair per overlay instance, after the fact, whatever opened or closed it — handle or
IOverlayRef, scrim tap, system back, sheet pull-down, navigation, tab bar panel replacement.
OverlayEvent += (_, e) =>
{
// e.Kind: Popup | BottomSheet | TabBarPanel | Flyout
// e.EventType: Presented | Closed
// e.Content: the presented view (for a sheet: your content, not the wrapper)
// e.Model / e.Intent: IOverlayService overlays only (null for view-level Show*Async / flyouts)
// e.Result: on Closed, what the model passed to IOverlayRef.CloseAsync(result); null otherwise
// e.FlyoutSide: Start | End for flyouts
var name = (e.Model ?? e.Content).GetType().Name;
_analytics.Track(e.EventType == ScaffoldOverlayEventType.Presented ? "overlay_open" : "overlay_close", e.Kind, name);
};
Guarantees: Presented is raised once the presentation succeeded (for IOverlayService
overlays, after OnEnteringAsync; a close requested in OnEnteringAsync skips the presentation
and raises nothing); Closed is raised before the model's OnLeavingAsync; a failed
presentation raises nothing; replacing the tab bar panel raises Closed for the old content
and Presented for the new one.
Modal pages are not overlays
Full modal pages (with their own navigation stack semantics) are a navigation feature —
Scaffold.PageMode — documented in Transitions & Modals.