Nkraft.MvvmEssentials
1.7.4
See the version list below for details.
dotnet add package Nkraft.MvvmEssentials --version 1.7.4
NuGet\Install-Package Nkraft.MvvmEssentials -Version 1.7.4
<PackageReference Include="Nkraft.MvvmEssentials" Version="1.7.4" />
<PackageVersion Include="Nkraft.MvvmEssentials" Version="1.7.4" />
<PackageReference Include="Nkraft.MvvmEssentials" />
paket add Nkraft.MvvmEssentials --version 1.7.4
#r "nuget: Nkraft.MvvmEssentials, 1.7.4"
#:package Nkraft.MvvmEssentials@1.7.4
#addin nuget:?package=Nkraft.MvvmEssentials&version=1.7.4
#tool nuget:?package=Nkraft.MvvmEssentials&version=1.7.4
MvvmEssentials
Lightweight MVVM utility library for .NET MAUI that simplifies navigation, flyout menus, tabs, popups, and multi-step wizards with opinionated conventions and minimal boilerplate. An alternative to .NET MAUI Shell.
Page types
This page covers setup and the concepts shared by every page type. For usage of a specific surface, see its guide:
- NavigationPage — plain pages and the navigation service (push/replace, strongly-typed parameters)
- TabbedPage — bottom/top tabs with lifecycle propagation
- FlyoutPage — hamburger menu with a swappable detail area
- Modals — pages that return a result, with the same top bar as regular pages
- Popups — modal dialogs with result handling (powered by Nkraft.Mopups, a maintained fork of Mopups)
- Wizard — multi-step flows over a shared state object
Setup
Quick test using this Food Delivery app, or just follow the instructions below.
1. Configure in MauiProgram.cs
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMvvmEssentials(registry =>
{
// ViewModel and Page naming convention must strictly be followed:
// page_name + "Page", vm_name + "ViewModel", wherein page_name == vm_name
//
// Mark one page with isInitial: true. This is where the app starts.
// If you need conditional startup logic (e.g. auth checks),
// implement IAppStartup instead (see below).
registry.MapPage<LandingPage, LandingViewModel>(isInitial: true)
.MapPage<MainPage, MainViewModel>()
.MapPage<LoginPage, LoginViewModel>()
.MapPage<SettingsPage, SettingsViewModel>();
});
// Required: registers the auto-discovered or generated IAppStartup
builder.Services.AddDiscoveredAppStartup();
// ..
}
}
Note:
AddDiscoveredAppStartup()is source-generated at compile time, only in the project that calls it. It automatically wires up yourIAppStartupimplementation if one exists, or generates a default one from the page markedisInitial: true.
Note:
ConfigureMvvmEssentialsalso configures popups for you. Do not callConfigureMopups()yourself.
2. Wire up the window in App.xaml.cs
public partial class App : Application
{
private readonly AppStartupWindowHook _hook;
public App(AppStartupWindowHook hook)
{
_hook = hook;
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
_hook.Attach();
return base.CreateWindow(activationState);
}
}
That's it. The hook fires the initial navigation automatically.
3. Delete any Shell related files. They are not used here.
Custom Startup Logic (Optional)
If you need to run async logic before deciding where to navigate (e.g. auth checks, feature flags),
implement IAppStartup in your app project or in a project it references (e.g. a separate ViewModel
library). The source generator will find it automatically, no registration needed.
public class AppStartup(INavigationService navigationService, IAuthService authService) : IAppStartup
{
private readonly INavigationService _navigationService = navigationService;
private readonly IAuthService _authService = authService;
async Task<IResult> IAppStartup.OnInitializedAsync()
{
var isLoggedIn = await _authService.CheckAsync();
// Return the navigation result: the library rethrows a failure's original exception.
return await _navigationService
.Absolute(withNavigation: false)
.Push(isLoggedIn ? typeof(HomeViewModel) : typeof(LoginViewModel))
.NavigateAsync();
}
}
Why return the result? If startup navigation fails (e.g. a ViewModel constructor throws), the library rethrows the real exception instead of MAUI's generic "set MainPage" error. To recover instead, navigate to a fallback page and return that result. Lifecycle exceptions are handled separately (see Lifecycle Exceptions).
Rules:
- Only one
IAppStartupimplementation is allowed across your app and its referenced projects. Defining two will produce a compile error (MVE002).- If it lives in a referenced project, it must be
public. Otherwise you get a compile error (MVE007).- If no
IAppStartupis found and no page is markedisInitial: true, a compiler warning (MVE001) is emitted.- When
IAppStartupis defined, theisInitial: trueflag is ignored.
MapPage vs RegisterPage vs MapView
All three register a ViewModel with a scoped lifetime in the DI container, but they serve different roles:
| | MapPage<TPage, TViewModel> | RegisterPage<TViewModel> | MapView<TView, TViewModel> |
|---|---|---|---|
| Navigable via NavigateAsync | ✅ Yes | ❌ No | ❌ No |
| Creates a mapping | ✅ Page–VM | ❌ No | ✅ ContentView–VM |
| Scoped DI lifetime | ✅ Yes | ✅ Yes | ✅ Yes |
| Use for | Pages navigated to by the navigation service | VMs bound via XAML (tabs, flyout menu) | Views built by VM type (wizard steps) |
Use MapView when a ViewModel asks for its view by VM type alone, as wizard steps do with
CreateView<TViewModel>(). This keeps ViewModels free of view types, so they can live in a separate
project from your views. See Wizard.
Use RegisterPage for any ViewModel that is wired to its page or view via XAML BindingContext
rather than created by the navigation service. The navigation service has no knowledge of these VMs
and will not be able to navigate to them by name.
The flyout's initial detail is the one exception: it is bound in XAML like the others, but if your
app returns to it (for example, a "home" action in the menu), it must use MapPage so the navigation
service can find it again.
registry.MapPage<MainHostPage, MainHostViewModel>(isInitial: true) // navigatable
.RegisterPage<MenuViewModel>() // bound in XAML as FlyoutPage.Flyout
// Ideally RegisterPage, but the flyout has to return to its initial detail page, which needs MapPage.
.MapPage<MainTabbedPage, MainTabbedViewModel>() // bound in XAML as FlyoutPage.Detail
.RegisterPage<HomeViewModel>() // bound in XAML as a TabbedPage tab
.RegisterPage<ProfileViewModel>() // bound in XAML as a TabbedPage tab
.MapPage<OnboardingHostPage, OnboardingHostViewModel>() // navigatable wizard host
.MapView<OnboardingGoalView, OnboardingGoalViewModel>() // wizard step
.MapPage<OrdersPage, OrdersViewModel>() // navigable
.MapPage<SettingsPage, SettingsViewModel>(); // navigable
ViewModel Lifecycle
Every page-backing ViewModel derives from PageViewModel and shares the lifecycle below. Surface-specific
hooks (tabs, flyout menus, wizard steps) are documented in each page type's guide.
<details> <summary>PageViewModel</summary>
| Method | When it is called |
|---|---|
OnParametersSet |
Called when navigation parameters are passed to this ViewModel |
OnInitialized |
Called once on the first page appearing |
OnInitializedAsync |
Async version of OnInitialized |
OnPageAppearing |
Called every time the page appears |
OnPageAppearingAsync |
Async version of OnPageAppearing |
OnPageDisappearing |
Called every time the page disappears |
OnPageDisappearingAsync |
Async version of OnPageDisappearing |
OnNavigatedTo |
Called when the page is navigated to |
OnNavigatedFrom |
Called when the page is navigated away from |
OnNavigatedToRoot |
Called when the navigation stack is popped back to this page as root |
OnNavigatedToRootAsync |
Async version of OnNavigatedToRoot |
OnPageUnloaded |
Called when the page is removed from the visual tree |
OnDispose |
Called when the DI scope is disposed |
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 40}, 'themeVariables': {'fontSize': '13px'}}}%%
flowchart TD
START([ViewModel created]):::created
START -->|navigation parameters passed in| A[OnParametersSet]:::method
A -->|first appearance only| C[OnInitialized<br/>+ Async]:::method
C --> NT[OnNavigatedTo]:::method
NT --> AP[OnPageAppearing<br/>+ Async]:::method
AP --> RUN([Page active]):::running
RUN -->|another page comes to the front| DP[OnPageDisappearing<br/>+ Async]:::method
DP --> NF[OnNavigatedFrom]:::method
NF -->|page removed from the visual tree| UL[OnPageUnloaded]:::method
UL --> DS[OnDispose]:::method
DS -->|DI scope disposed| END([ViewModel disposed]):::shutdown
NF -->|stack popped back to this page as root| RT[OnNavigatedToRoot<br/>+ Async]:::method
RT -->|page shown again| AP
classDef method fill:#EFEAFB,stroke:#512BD4,stroke-width:1.5px,color:#2E1A66;
classDef created fill:#DCEAFB,stroke:#3B7DD8,stroke-width:1.5px,color:#0F3C6E,font-weight:bold;
classDef running fill:#E4F0D6,stroke:#6FA03C,stroke-width:1.5px,color:#33530F,font-weight:bold;
classDef shutdown fill:#FBE3D2,stroke:#D98A3B,stroke-width:1.5px,color:#6E3A0F,font-weight:bold;
</details>
Resource Cleanup (IDisposable)
All ViewModels (PageViewModel, TabViewModel, FlyoutMenuViewModel, PopupViewModel, WizardStepViewModel) implement IDisposable. When a page is unloaded, the library automatically disposes its ViewModel via the DI scope.
To clean up resources, override OnDispose() in your ViewModel:
public class MyViewModel : PageViewModel
{
private readonly Timer _timer;
public MyViewModel()
{
_timer = new Timer(OnTick, null, 0, 1000);
}
protected override void OnDispose()
{
base.OnDispose();
_timer.Dispose();
}
}
Lifecycle Exceptions
Lifecycle methods (OnInitialized, OnPageAppearing, OnNavigatedTo, OnTabSelected, OnFlyoutOpened, ...
and their async versions) are called by MAUI while it is navigating. An exception from one of them never
propagates into MAUI or fails the navigation: the page still opens, and the exception is reported instead.
Every such exception goes through one place, configured with the ConfigureMvvmEssentials overload:
builder.ConfigureMvvmEssentials(
registry => registry.MapPage<LandingPage, LandingViewModel>(isInitial: true),
options =>
{
// Crash (default): rethrows the original exception on the UI thread after the navigation
// finishes, so it reaches your unhandled-exception handler.
// LogOnly: logs it and keeps the app running.
options.LifecycleExceptionBehavior = LifecycleExceptionBehavior.Crash;
// Called first, for every lifecycle exception.
options.OnLifecycleException = context =>
{
// e.g. send to your crash reporter
MyCrashReporter.Track(context.Exception, context.MethodName, context.Source?.GetType().Name);
// Optionally keep running for this one exception, even with Crash.
// context.Handled = true;
};
});
context.MethodNameis the method the library was calling.OnInitialized/OnInitializedAsyncrun insideOnPageAppearing/OnPageAppearingAsyncand are reported under those names; the stack trace shows the exact method.context.Sourceis the ViewModel (or tab/flyout component) that threw.- Don't assume the callback runs on the UI thread.
- Handle errors you expect (e.g. a failed API call) inside the lifecycle method and show them on the page. This mechanism is for the ones you don't expect.
- A failed
OnInitializedis not retried on the next appearance. - Not covered: exceptions from
OnParametersSetor the ViewModel constructor (navigation hasn't started, so the returnedIResultfails instead), and fromOnNavigatedToRoot(reported in theIResultofNavigateToRootAsync).
Upgrading
Changes that may need edits in your app, and how to make them, are listed in the migration guide.
Contributing
Contributions are welcome. To get started:
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-change) - Commit your changes (
git commit -am 'Add my change') - Push to the branch (
git push origin feature/my-change) - Open a pull request
Please open an issue first for significant changes so the direction can be discussed before implementation.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-android36.0 is compatible. net10.0-browser was computed. net10.0-ios was computed. net10.0-ios26.0 is compatible. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Options (>= 10.0.13)
- Microsoft.Maui.Controls (>= 10.0.110)
- Nkraft.CrossUtility (>= 1.0.12.2)
- Nkraft.Mopups (>= 1.0.2.2)
-
net10.0-android36.0
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Options (>= 10.0.13)
- Microsoft.Maui.Controls (>= 10.0.110)
- Nkraft.CrossUtility (>= 1.0.12.2)
- Nkraft.Mopups (>= 1.0.2.2)
-
net10.0-ios26.0
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Options (>= 10.0.13)
- Microsoft.Maui.Controls (>= 10.0.110)
- Nkraft.CrossUtility (>= 1.0.12.2)
- Nkraft.Mopups (>= 1.0.2.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.10.0-alpha.3 | 76 | 10/25/2026 |
| 1.10.0-alpha.2 | 60 | 10/24/2026 |
| 1.10.0-alpha.1 | 49 | 10/24/2026 |
| 1.9.1.2 | 89 | 10/24/2026 |
| 1.9.1 | 112 | 10/23/2026 |
| 1.9.0 | 109 | 10/23/2026 |
| 1.8.0 | 130 | 10/20/2026 |
| 1.7.4 | 106 | 10/19/2026 |
| 1.5.1 | 90 | 10/12/2026 |
| 1.4.1.2 | 92 | 10/4/2026 |
Wizard can now skip steps!