Install
Clone the 15-minute path: samples/Playground
All in-repo samples: samples/
git clone https://github.com/nuvyntralabs/Plugin.Maui.MVVMExpress.gitdotnet add package Plugin.Maui.MVVMExpress.Core
dotnet add package Plugin.Maui.MVVMExpressAdd Navigation, Dialogs, Validation, Pagination, Reactive, SourceGenerators, Compatibility, and Testing only when the app uses those surfaces.
dotnet add package Plugin.Maui.MVVMExpress.Navigation
dotnet add package Plugin.Maui.MVVMExpress.Dialogs
dotnet add package Plugin.Maui.MVVMExpress.Validation
dotnet add package Plugin.Maui.MVVMExpress.Pagination
dotnet add package Plugin.Maui.MVVMExpress.Reactive
dotnet add package Plugin.Maui.MVVMExpress.SourceGenerators
dotnet add package Plugin.Maui.MVVMExpress.TestingRegister the host
Shared libraries and tests call AddMvvmExpress. A MAUI app calls UseMvvmExpress, which registers Core services, replaces IMainThread with MauiMainThread, marshals command/property notifications, and auto-attaches ViewModelLifecycleBehavior.
using Plugin.Maui.MVVMExpress.Hosting;
using Plugin.Maui.MVVMExpress.Navigation;
using Plugin.Maui.MVVMExpress.Dialogs;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMvvmExpress(o => o
.UseNavigationPage()
.UseDialogs()
.UseAuth<LoginViewModel>());
return builder.Build();
}
}// net10.0 tests / shared ViewModel projects
services.AddMvvmExpress();
services.AddAuth<LoginViewModel>();UseNavigationPage is the login → replace-root → push host. UseShell is optional — do not register both unless you really have two hosts. UseDialogs replaces NullDialogs with MauiDialogs and MauiNotifier. UseAuth<TChallenge>() wraps GuardedNavigator — register IAuthState yourself and do not reconstruct the guard. Generated [Route] / [RequiresAuth] apply from UseMvvmExpress via a ModuleInitializer. Call InitializeComponent() on App before resolving pages.
| Choose | When |
|---|---|
| UseNavigationPage + ResetAsync / ReplaceRootAsync | Login → home, chat host, or any app that must drop the back-stack so Back cannot return to login. Replaces window.Page with a NavigationPage. |
| UseShell | Flyout / tab catalog, existing Shell routes, or //home as a root ShellContent (AuthApp). ResetAsync only works when the destination is a root ShellContent. |
| Do not register both | Unless you really have two hosts (two windows). One INavigator per IWindowContext. |
First screen
The 15-minute path is Playground. After UseMvvmExpress above, a first page is a partial ViewModel, [Notify], [AsyncModelCommand], and a XAML bind. The command token is the ViewModel token — Dispose cancels ViewModelCancellationToken. Do not mash MauiProgram, the ViewModel, and the page into one file.
public partial class HomeViewModel : PageViewModel
{
[Notify] private int _count;
[AsyncModelCommand]
private async Task IncrementAsync(CancellationToken cancellationToken)
{
await Task.Delay(80, cancellationToken);
Count++;
}
}<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:vm="clr-namespace:MyApp"
x:Class="MyApp.HomePage"
x:DataType="vm:HomeViewModel">
<VerticalStackLayout Padding="24">
<Label Text="{Binding Count}" />
<Button Text="Increment" Command="{Binding IncrementCommand}" />
</VerticalStackLayout>
</ContentPage>Add Plugin.Maui.MVVMExpress.SourceGenerators with PrivateAssets=all. Types must be partial. Hand-written SetProperty stays valid — the next section shows that path with AsyncState.
First ViewModel
Inherit ViewModel. Bind AsyncState to the page — or use AsyncStateView / BusyOverlayBehavior in the host package. Put work on AsyncModelCommand so IsRunning, Cancel, and the ViewModel token stay aligned. Bind Button.Command to AsyncModelCommand on 0.6.0+ (UI-thread marshal + weak CanExecuteChanged). Do not call Page.DisplayAlert or Shell.Current from the ViewModel.
using Plugin.Maui.MVVMExpress.ComponentModel;
using Plugin.Maui.MVVMExpress.Input;
using Plugin.Maui.MVVMExpress.State;
public sealed class HomeViewModel : ViewModel
{
public AsyncState<IReadOnlyList<Product>> Products { get; } = new();
public AsyncModelCommand RefreshCommand { get; }
public HomeViewModel(ICatalog catalog)
{
RefreshCommand = new AsyncModelCommand(
ct => Products.LoadAsync(token => catalog.ListAsync(token), ct));
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
RefreshCommand.Cancel();
}
base.Dispose(disposing);
}
}Dispose cancels ViewModelCancellationToken. The token remains readable after dispose. Page XAML binds ItemsSource to Products.Data and IsRefreshing to Products.IsRefreshing (or RefreshCommand.IsRunning).
Commands
- ModelCommand / ModelCommand<T> — sync; weak CanExecuteChanged.
- AsyncModelCommand / AsyncModelCommand<T> — async, IsRunning, Cancel, ExecuteAsync; weak CanExecuteChanged.
- ICommand.Execute never throws. Failures go to IErrorSink / IDialogs. ExecuteAsync still rethrows.
- AsyncCommandOptions: timeout, retry, Debounce, Throttle, ConcurrencyMode (Prevent, CancelPrevious, Queue, Allow, Replace).
SaveCommand = new AsyncModelCommand(
SaveAsync,
() => CanSave,
new AsyncCommandOptions
{
Concurrency = ConcurrencyMode.Prevent,
Timeout = TimeSpan.FromSeconds(15),
RetryCount = 2,
RetryDelay = TimeSpan.FromSeconds(1),
});Collections (mid and large lists)
Do not Add in a loop for mid or large lists. AddRange raises one CollectionChanged Reset. Large lists must also virtualize in CollectionView — the framework will not pretend 100,000 realized cells are fine.
var items = new ObservableRangeCollection<Product>();
items.AddRange(page); // one CollectionChanged Reset
items.ReplaceRange(next);Messaging
The handler must use the recipient argument so a weak subscribe does not pin the ViewModel. Do not write (msg) => this.Refresh() — that captures this and defeats the weak table.
hub.Subscribe<HomeViewModel, CartChanged>(
this,
static (vm, _) => vm.Refresh(),
weak: true);Dialogs
Add Plugin.Maui.MVVMExpress.Dialogs and call UseDialogs() on UseMvvmExpress. Inject IDialogs for alerts/confirm and INotifier for toast. MauiDialogs hops to IMainThread like MauiNotifier. MauiToastPresenter draws on Window.AddOverlay — it never wraps or replaces Page.Content, so ResetAsync cannot restore a stale tree. Tests use FakeDialogs (it implements both). Inject IToastPresenter to record toasts without a window.
public sealed class ProductEditViewModel : PageViewModel
{
public ProductEditViewModel(IDialogs dialogs, INotifier notifier)
{
/* store dialogs + notifier */
}
public async Task DeleteAsync(CancellationToken cancellationToken)
{
var ok = await Dialogs!.ConfirmAsync(
"Delete product",
"This cannot be undone.",
accept: "Delete",
cancel: "Cancel",
cancellationToken);
if (!ok)
{
return;
}
await _catalog.DeleteAsync(_productId, cancellationToken);
await Notifier.ToastAsync("Deleted", cancellationToken: cancellationToken);
}
}Validation
Plugin.Maui.MVVMExpress.Validation ships DataAnnotations plus IValidator and MustMatchAttribute. The package includes ILLink.Descriptors.xml that roots Required, MinLength, MaxLength, StringLength, Range, RegularExpression, EmailAddress, Compare, and MustMatch. Custom attributes need an app-level descriptor. FluentValidation is an adapter the app may add. XAML Validation.For remains Plugin.Maui.FormValidation.
public sealed class ProductDraft
{
[Required, StringLength(80)]
public string Name { get; set; } = "";
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}
var summary = await validator.ValidateAsync(draft, cancellationToken);
if (!summary.IsValid)
{
return Outcome.Failure("validation", summary.Messages[0].Message);
}Forms, dirty guard, undo
FormViewModel lives in Core and does not reference MAUI. Field(name, value) creates a FormField<T>. Bind(field, propertyName, notifyCanExecute) wires the public property and CanExecute — do not write a manual PropertyChanged wrapper. Bind FormField.Error / HasError. When IDialogs is registered, leaving a dirty form confirms “Discard changes?”. Tests set DirtyNavigation = DirtyNavigationMode.SilentBlock. SubmitAsync(work) calls MarkClean() on success. Use [MustMatch(nameof(Password))] or MustMatch(password, confirm).
public sealed class ProductEditViewModel : FormViewModel
{
private readonly FormField<string> _name;
public ProductEditViewModel()
{
_name = Field("Name", "");
Bind(_name, nameof(Name), () => SaveCommand.NotifyCanExecuteChanged());
}
public string Name
{
get => _name.Value ?? "";
set => _name.Value = value;
}
private Task<Outcome> SaveAsync(CancellationToken ct) =>
SubmitAsync(token => _catalog.SaveAsync(Name, token), cancellationToken: ct);
}Reactive derived state
Plugin.Maui.MVVMExpress.Reactive does not take System.Reactive. Core stays Rx-free. CombineLatest derives a value from two properties; dispose the observable with the ViewModel. Search debounce remains SearchQuery in Pagination.
using Plugin.Maui.MVVMExpress.Reactive;
_fullName = PropertyObservable.CombineLatest(
PropertyObservable.Observe(this, nameof(First), () => First ?? ""),
PropertyObservable.Observe(this, nameof(Last), () => Last ?? ""),
static (first, last) => $"{first} {last}".Trim());
_fullName.Subscribe(_ => Notify(nameof(FullName)));Source generators
Add Plugin.Maui.MVVMExpress.SourceGenerators with PrivateAssets=all. Attributes live in Core. Types must be partial. UseMvvmExpress applies generated [Route] / [RequiresAuth] via a ModuleInitializer. You can still call services.AddGeneratedViewModels() explicitly. Hand-written SetProperty and Map<TViewModel> remain valid.
[RegisterViewModel]
[Route("generated")]
[RequiresAuth]
public partial class GeneratedCatalogViewModel : ViewModel
{
[Notify]
[NotifyAlso(nameof(Label))]
private string _query = "";
[Notify]
[PersistState]
private string _draft = "";
public string Label => $"Q: {Query}";
[ModelCommand]
private void Clear() => Query = "";
}
services.AddGeneratedViewModels();Pagination and search
PagedCollection<T> / DelegatePagedCollection<T> own load-more, refresh, and retry — not a live chat inbox. SnapshotCollection<T> loads once in InitializeAsync; later LoadAsync calls are no-ops unless force is true. After appear, mutate with AddLocal / Insert. Do not pair DelegatePagedCollection with CollectionView RemainingItemsThreshold when the fetch is sync. SearchQuery.Text binds to Entry; filter from CommittedText after debounce. Do not two-way bind SearchQuery to Android SearchBar.
public sealed class InboxViewModel : ViewModel
{
public SnapshotCollection<Conversation> Inbox { get; }
public SearchQuery Search { get; } = new();
public InboxViewModel(IInbox catalog)
{
Inbox = new SnapshotCollection<Conversation>(
ct => catalog.ListAsync(Search.CommittedText, ct));
}
protected override Task InitializeAsync(CancellationToken cancellationToken) =>
Inbox.LoadAsync(cancellationToken: cancellationToken);
}
// catalog paging (not a live inbox)
await Products.RefreshAsync(ct);
await Products.LoadMoreAsync(ct);Chat-style host
A WhatsApp-style app is one persistent screen, in-place tabs, a filterable inbox, and a thread on a NavigationPage stack. That is not a Shell + PagedCollection + appear/refresh app. Auth and forms stay on FormViewModel / IAuthState / UseAuth.
Reference app: WhatsApp clone using MVVMExpress Framework
builder.UseMvvmExpress(o =>
{
o.UseNavigationPage((nav, _) => nav
.Map<LoginViewModel, LoginPage>("login")
.Map<ChatHostViewModel, ChatHostPage>("chats")
.Map<ChatThreadViewModel, ChatThreadPage>("thread"));
o.UseDialogs();
o.UseAuth<LoginViewModel>();
});
await Navigator.ResetAsync<ChatHostViewModel>();
await Navigator.NavigateToAsync<ChatThreadViewModel, ChatNavArgs>(new(id));public sealed class ChatHostViewModel : SectionHostViewModel
{
public ChatHostViewModel()
{
Inbox = Add("chats", new ChatInboxViewModel(seed));
Add("updates", new ChatInboxViewModel([]));
}
public ChatInboxViewModel Inbox { get; }
}
// Bind tab buttons to SelectCommand and visibility to CurrentKey.
// Hub handlers: CoalescingDispatcher (marshal + coalesce).Auth and offline adapters
In-memory IAuthState, IAccountService, ICache, ICachedFetcher, and IConnectivityProbe exist for samples and tests. IAuthState exposes Email, DisplayName, and Changed. Production apps adapt sibling plugins instead of shipping those types. GuardedNavigator remains the implementation; UseAuth is the getting-started API.
// Auth — UseAuth wraps GuardedNavigator. Adapt Plugin.Maui.SecureSession.
builder.UseMvvmExpress(o => o
.UseNavigationPage()
.UseDialogs()
.UseAuth<AuthLoginViewModel>());
services.AddSingleton<IAuthState>(sp =>
new SecureSessionAuthState(sp.GetRequiredService<ISecureSession>()));
await Navigator.ResetAsync<AuthHomeViewModel>(); // after sign-in
await Navigator.ResetAsync<AuthLoginViewModel>(); // after sign-out
// Cache-first catalog — adapt Plugin.Maui.ApiCache
var cached = await cache.GetAsync<IReadOnlyList<Product>>("catalog", ct);
if (cached is not null && !probe.IsOnline)
{
return Outcome<IReadOnlyList<Product>>.Success(cached);
}IConnectivityProbe should wrap Plugin.Maui.NetworkMonitor when the app must distinguish validated internet from a captive portal. Do not treat MAUI Connectivity.NetworkAccess as “online enough to sync.”
Testing
Plugin.Maui.MVVMExpress.Testing is net10.0. ViewModels in the sample host live in a shared net10.0 project so they can be tested without MAUI. FakeNavigator is InMemoryNavigator — assert Current, Stack, and CanGoBack. LeakProbe.Track returns a WeakReference; IsCollected takes that reference, not a Func. Drop the strong reference before the assert (the leak snippet uses the first-screen HomeViewModel). AppearAsync / DisappearAsync drive lifecycle without a page. Button + popped-page collection is a Core test scenario (weak CanExecuteChanged), not a LeakProbe Button API. ScopedNavigator covers page-scope push/pop GC.
public sealed class ProductListViewModel : PageViewModel
{
public ProductListViewModel(INavigator navigator)
: base(navigator)
{
}
public Task OpenDetailsAsync(int id, CancellationToken cancellationToken) =>
Navigator!.NavigateToAsync<ProductDetailsViewModel, ProductDetailsArgs>(
new ProductDetailsArgs(id), cancellationToken);
}
[Fact]
public async Task OpenDetails_pushes_details()
{
var navigator = new FakeNavigator()
.Map<ProductDetailsViewModel>("details");
var vm = new ProductListViewModel(navigator);
await vm.AppearAsync();
await vm.OpenDetailsAsync(42);
Assert.Equal(typeof(ProductDetailsViewModel), navigator.Current);
Assert.True(navigator.CanGoBack);
}[Fact]
public void Dispose_makes_the_viewmodel_collectable()
{
Assert.True(LeakProbe.IsCollected(CreateAndDispose()));
}
static WeakReference CreateAndDispose()
{
var vm = new HomeViewModel();
var leak = LeakProbe.Track(vm);
vm.Dispose();
return leak;
}dotnet test tests/Plugin.Maui.MVVMExpress.Core.Tests
dotnet test tests/Plugin.Maui.MVVMExpress.Samples.TestsSample map
There is no separate MVVMExpress.SampleApp repository. Samples ship in the product repo. The 15-minute path is Playground (command, navigation, dialog, form, auth, list). First-run login is AuthApp: sign in → home, plus register and forgot password (demo@mvvmexpress.dev / secret). AuthApp uses UseMvvmExpress(o => o.UseShell().UseDialogs().UseAuth<AuthLoginViewModel>()), ResetAsync replace-root, [RequiresAuth], and FormViewModel dirty confirm. The flyout catalog still lives in Plugin.Maui.MVVMExpress.Sample.
Clone and run: samples/Playground
In-repo sample map: samples/
Standalone reference app: WhatsApp clone using MVVMExpress Framework
| Sample | ViewModels | What it integrates |
|---|---|---|
| Basic | CounterViewModel | ViewModel, SetProperty, NotifyDependsOn, ModelCommand |
| CRUD | ProductList / ProductEdit | FormViewModel dirty / undo / redo, AsyncState, IValidator |
| Navigation | Home / ProductDetails / ScopedCatalog | PageViewModel, INavigator, IAcceptNavArgs<T>, IAcceptNavQuery, INotifier toast, IViewModelScopeFactory |
| Page stack | PageStack / PageStackItem | IPageNavigator, URI query, Stack / CanGoBack / PopToRoot / Replace / Reset |
| Auth (flyout) | Login / SecureHome | IAuthState, GuardedNavigator — push secure; adapt SecureSession |
| Playground | Home / Details / Edit / Login | 15-minute path: UseAuth, command, nav, dialog, form, list |
| AuthApp | AuthLogin / AuthHome / Register / Forgot | UseAuth<AuthLoginViewModel>, ResetAsync replace-root, MustMatch, dirty confirm |
| Offline | OfflineCatalogViewModel | ICachedFetcher + FetchPolicy — adapt ApiCache / OfflineSync |
| Pagination | PagedProductViewModel | DelegatePagedCollection load-more + refresh (not a live inbox) |
| Chat host | ChatHost / ChatInbox | SectionHostViewModel, SnapshotCollection, SearchQuery.CommittedText, CoalescingDispatcher |
| WhatsApp clone | WhatsAppUIClone | Standalone reference app: SectionHost, SnapshotCollection, NavigationPage |
| Reactive | SearchViewModel | SearchQuery debounce + PropertyObservable.CombineLatest |
| Enterprise | EnterpriseShell / CatalogStatus | Child composition, IFeatureSwitch, hub, busy, probe, auth gate |
| Generated | GeneratedCatalogViewModel | [Notify], [ModelCommand], [PersistState], [RegisterViewModel], [Route], [RequiresAuth] |
Pitfalls
- Do not call Page.DisplayAlert or Shell.Current from a ViewModel. Use IDialogs and INavigator.
- Do not Add in a loop for mid or large lists. Use AddRange / ReplaceRange.
- Do not capture this in a MessageHub handler. Use the recipient argument.
- Do not bind a non-virtualized StackLayout to thousands of rows. Pagination + CollectionView virtualization are required at mid/large scale.
- Do not store tokens on the ViewModel. Use Plugin.Maui.SecureSession.
- Do not treat MAUI Connectivity as validated internet. Adapt Plugin.Maui.NetworkMonitor.
- Do not enable convention View/ViewModel scanning as the only AOT registration path. UseMvvmExpress applies generated [Route] / [RequiresAuth] via a ModuleInitializer.
- Do not inject AppShell into App before InitializeComponent(). Resolve the shell in CreateWindow from IServiceProvider.
- Do not reconstruct GuardedNavigator after UseNavigationPage / UseShell. Call UseAuth<TChallenge>() and register IAuthState.
- Do not bind Button.Command to AsyncModelCommand on 0.5.0-preview. Use 0.6.0+ for UI-thread marshal and weak CanExecuteChanged.
- Do not ConfigureAwait(false) then new Page() or Shell.GoToAsync. Navigators hop to IMainThread first.
- Do not pair DelegatePagedCollection with CollectionView RemainingItemsThreshold when the fetch is sync. Use SnapshotCollection.
- Do not bind SearchQuery.Text to Android SearchBar. Use Entry and filter from CommittedText.
- Do not RefreshAsync from OnAppearingAsync on a live inbox. Do not ReplaceRange a visible BindableLayout.
- Do not mix MAUI MainThread statics in ViewModels. IMainThread is the only marshal API.
- Hand-written SetProperty and Map<TViewModel> remain valid. Generators are an accelerator.