This assumes familiarity with .NET MAUI navigation and MVVM. If you've wired up Shell, a custom nav service, or something similar, you're the target audience.
Written for .NET MAUI UI July 2026.
Setup
MvvmEssentials is a navigation/MVVM library I built for .NET MAUI, as an alternative to Shell. Rather than walk through the API in isolation, this post follows Food Delivery, a full showcase app for it, screen by screen.
Before the tour, one piece of scaffolding that shows up on every screen: every ViewModel derives from PageViewModel, which defines a single lifecycle contract:
| Method | When it fires |
|---|---|
OnParametersSet | Navigation parameters arrive |
OnInitialized(Async) | Once, on first appearance |
OnPageAppearing/Disappearing(Async) | Every time the page shows/hides |
OnNavigatedTo/From | On navigation transitions |
OnDispose | DI scope torn down |
Surfaces like tabs, flyout menus, and popups add a couple of extra hooks on top of the same base, rather than a different lifecycle model entirely. Keep this in mind as it comes up in each screen below.
The shell: flyout + tabs
The app opens into MainHostPage, a FlyoutPage whose detail is a TabbedPage:
MainHostPage (FlyoutPage)
├── Menu (Flyout)
└── Detail (NavigationPage wrapper)
└── MainTabbedPage (TabbedPage)
├── RestaurantsTab
├── SearchTab
└── CartTab
This structure is also where the base lifecycle contract matters most. MAUI doesn't propagate appearing/disappearing events into a FlyoutPage's detail content by default, especially when that detail is itself a TabbedPage. Two behaviors fix it, added once at the XAML root:
<FlyoutPage.Behaviors>
<behaviors:FlyoutPresentingBehavior />
<behaviors:FlyoutDetailLifecycleBehavior />
</FlyoutPage.Behaviors>
With that in place, nested tab ViewModels receive appearing/disappearing events reliably. Other frameworks handle this differently: Prism, for example, exposes lifecycle through interfaces like INavigatedAware, and a ViewModel implements whichever it needs. If the interface ends up on a ViewModel that isn't actually reachable through a navigable page, the method compiles fine and never fires, with no error or warning. Here the lifecycle methods are already implemented on PageViewModel itself, so overriding OnNavigatedTo() works without an extra interface step.
Restaurants tab
RestaurantsTab is a TabViewModel, which adds OnTabSelected/OnTabUnselected on top of the base contract, fired every time the tab becomes active rather than once. That distinction matters for where you put things: the restaurant list itself is fine loading once in OnInitialized, while anything that should reflect changes made elsewhere (a badge count, a promo banner) belongs in OnTabSelected instead.
Opening a restaurant
Tapping a restaurant navigates with a strongly-typed parameter rather than a route string:
public partial class RestaurantsTabViewModel(INavigationService navigationService) : TabViewModel
{
[RelayCommand]
private async Task OpenRestaurant(Restaurant restaurant)
{
await navigationService.NavigateAsync(RestaurantDetailViewModel.With(restaurantId: restaurant.Id));
}
}
RestaurantDetailViewModel picks the value up via OnParametersSet, or through auto-mapped properties if the name matches.
Adding to cart
Tapping a menu item opens a popup that hands back a typed result, rather than relying on a callback or shared mutable state:
var result = await _popupService.PresentAsync(AddToCartViewModel.With(item: menuItem));
if (result.TryGetValue(out var cartItem))
{
_cartService.AddItem(cartItem);
}
The calling code reads like any other awaited call, with no polling of a shared property or subscribing to an event to find out what the user picked.
Cart and checkout
CartTab is registered with RegisterPage<CartTabViewModel>(), nested under the tabbed page the same way as the other tabs. Checkout is a separate, ordinary MapPage<CheckoutPage, CheckoutViewModel>() screen reached from the cart.
Order history
This screen illustrates the "once vs. every time" choice from the lifecycle contract above. Order history should reflect a checkout that just happened, so a refresh belongs in OnNavigatedTo (fires on every transition into the page) rather than OnInitialized (fires once, ever). It is the same two-option decision as the restaurants tab, with a different answer.
Search and settings
SearchTab is registered the same way as the other tabs, with RegisterPage<SearchTabViewModel>(). Settings is an ordinary MapPage<SettingsPage, SettingsViewModel>() screen, unrelated to the tab/flyout registration above it.
Wiring it up
// Main FlyoutPage setup - showcases FlyoutHostViewModel
registry
.MapPage<MainHostPage, MainHostViewModel>(isInitial: true)
.RegisterPage<MenuViewModel>()
.MapPage<MainTabbedPage, MainTabbedViewModel>() // required for initial detail page
.RegisterPage<RestaurantsTabViewModel>()
.RegisterPage<SearchTabViewModel>()
.RegisterPage<CartTabViewModel>();
// Regular pages - showcases PageViewModel
registry.MapPage<RestaurantDetailPage, RestaurantDetailViewModel>()
.MapPage<ItemDetailPage, ItemDetailViewModel>()
.MapPage<OrdersPage, OrdersViewModel>()
.MapPage<ProfilePage, ProfileViewModel>()
.MapPage<SettingsPage, SettingsViewModel>()
.MapPage<CheckoutPage, CheckoutViewModel>();
// Popup - showcases PopupViewModel
registry.MapPage<AddToCartPopup, AddToCartViewModel>();
registry.MapPage<ConfirmRemoveItemPopup, ConfirmRemoveItemViewModel>();
The full source for everything above is in the Food Delivery repo if you want to run it directly.
This post is part of .NET MAUI UI July 2026. #dotnetmaui #MAUIUIJuly