Skip to content
Nuvyntra Labs

Getting started

Get started with Uno Platform MVVMExpress

From dotnet new uno-mvvmexpress, a Marketplace IDE extension, or NuGet install to a testable ViewModel: 1.0.1 templates, first screen, UseFrameNavigation, Playground clone, FakeNavigator / LeakProbe, and forms.

Scaffold an app

Visual Studio Code: Uno Platform MVVMExpress on the Marketplace

Visual Studio 2022+: Uno Platform MVVMExpress on the Marketplace

dotnet new install Plugin.Uno.MVVMExpress.Templates
dotnet new uno-mvvmexpress -n MyApp
cd MyApp
dotnet test MyApp.Tests

The template is a Frame host that starts on LoginPage, then ResetAsync to HomeViewModel after sign-in, plus details, a form, and a net10.0 test project. Demo sign-in: demo@mvvmexpress.dev / secret. Register your own IAuthState for production tokens.

dotnet new uno-mvvmexpress-page -n Catalog --namespace MyApp

Then map the route and call services.AddCatalog() in App startup. Move the ViewModel and service into MyApp.Core if you keep that split.

Template pack on nuget.org: Plugin.Uno.MVVMExpress.Templates

Template pack on GitHub Packages: Plugin.Uno.MVVMExpress.Templates

Install into an existing app

Clone the 15-minute path: samples/Playground

git clone https://github.com/nuvyntralabs/Plugin.Uno.MVVMExpress.git
dotnet add package Plugin.Uno.MVVMExpress.Core
dotnet add package Plugin.Uno.MVVMExpress
dotnet add package Plugin.Uno.MVVMExpress.Navigation
dotnet add package Plugin.Uno.MVVMExpress.Dialogs

Plugin.Uno.MVVMExpress.* restores from nuget.org. CI also publishes GitHub Packages. Add Validation, Pagination, and Testing only when the app uses those surfaces.

dotnet add package Plugin.Uno.MVVMExpress.Validation
dotnet add package Plugin.Uno.MVVMExpress.Pagination
dotnet add package Plugin.Uno.MVVMExpress.Testing

Register the host

Shared libraries and tests call AddMvvmExpress. A Uno Platform app calls UseUnoMvvmExpress (or AddUnoMvvmExpress on IServiceCollection), which registers Core services, replaces IMainThread with UnoDispatcherMainThread, maps IWindowContext to UnoWindowContext, and auto-attaches Loaded / Unloaded lifecycle.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;
using Plugin.Uno.MVVMExpress.Hosting;
using Plugin.Uno.MVVMExpress.Navigation;
using Plugin.Uno.MVVMExpress.Dialogs;

protected override void OnLaunched(LaunchActivatedEventArgs args)
{
    var builder = Host.CreateApplicationBuilder();
    builder.Services.AddSingleton<IAuthState, DemoAuthState>();
    builder.Services.AddTransient<LoginViewModel>();
    builder.Services.AddTransient<HomeViewModel>();
    builder.Services.AddTransient<LoginPage>();
    builder.Services.AddTransient<HomePage>();
    builder.Services.AddSingleton<MainWindow>();
    builder.Services.UseUnoMvvmExpress(o => o
        .UseFrameNavigation((nav, _) => nav
            .Map<LoginViewModel, LoginPage>("login")
            .Map<HomeViewModel, HomePage>("home"))
        .UseDialogs()
        .UseAuth<LoginViewModel>());

    _host = builder.Build();
    var window = _host.Services.GetRequiredService<MainWindow>();
    window.Activate();
    _ = _host.Services.GetRequiredService<INavigator>()
        .ResetAsync<LoginViewModel>();
}
// net10.0 tests / shared ViewModel projects
services.AddMvvmExpress();
services.AddAuth<LoginViewModel>();

UseFrameNavigation registers UnoFrameNavigator as INavigator / IPageNavigator. UseDialogs replaces NullDialogs with UnoDialogs and UnoNotifier. UseAuth<TChallenge>() wraps GuardedNavigator — call it after UseFrameNavigation. Do not reconstruct the guard. Put a Frame named NavigationHost in the window. Do not replace Window.Content with a toast host.

ChooseWhen
UseFrameNavigation + ResetAsync / ReplaceRootAsyncLogin → home, or any app that must drop the Frame journal so Back cannot return to login.
Second Window + IWindowContextA tool or inspector window. Register the window, resolve a navigator for that context — do not share one Frame across windows.
Do not invent ShellThere is no UseShell. In-place tabs are SectionHostViewModel + SectionHostView.

First screen

dotnet new uno-mvvmexpress already ships this first screen. After UseUnoMvvmExpress above, a first page is a ViewModel, SetProperty, AsyncModelCommand, and a XAML bind. The command token is the ViewModel token — Dispose cancels ViewModelCancellationToken. Playground remains the cloneable 15-minute path in the product repo.

public sealed class HomeViewModel : PageViewModel
{
    private int _count;

    public HomeViewModel(INavigator navigator)
        : base(navigator)
    {
        IncrementCommand = new AsyncModelCommand(IncrementAsync);
    }

    public int Count
    {
        get => _count;
        private set => SetProperty(ref _count, value);
    }

    public AsyncModelCommand IncrementCommand { get; }

    private async Task IncrementAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(80, cancellationToken);
        Count++;
    }
}
<Page x:Class="MyApp.Pages.HomePage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <StackPanel Margin="24">
    <TextBlock Text="{Binding Count}" />
    <Button Content="Increment" Command="{Binding IncrementCommand}" />
  </StackPanel>
</Page>

First ViewModel

Inherit ViewModel. Bind AsyncState to the page. Put work on AsyncModelCommand so IsRunning, Cancel, and the ViewModel token stay aligned. Do not show a dialog or call Frame.Navigate from the ViewModel.

using Plugin.Uno.MVVMExpress.ComponentModel;
using Plugin.Uno.MVVMExpress.Input;
using Plugin.Uno.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 — ItemsRepeater / ListView virtualization. The framework will not pretend 100,000 realized rows 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.Uno.MVVMExpress.Dialogs and call UseDialogs() on UseUnoMvvmExpress. Inject IDialogs for alerts/confirm and INotifier for toast. UnoDialogs hops to IMainThread like UnoNotifier. UnoToastPresenter draws an overlay — it never wraps or replaces Window.Content, so ResetAsync cannot restore a stale tree. Do not replace Window.Content with a toast host. Tests use FakeDialogs.

public sealed class ProductEditViewModel : PageViewModel
{
    public ProductEditViewModel(IDialogs dialogs, INotifier notifier)
        : base(navigator: null, dialogs)
    {
    }

    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.Uno.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. Uno Platform bindings use UnoFormViewModel (INotifyDataErrorInfo).

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 Uno Platform. UnoFormViewModel adds INotifyDataErrorInfo. Field(name, value) creates a FormField<T>. Bind(field, propertyName, notifyCanExecute) wires the public property and CanExecute. When IDialogs is registered, leaving a dirty form confirms “Discard changes?”. Tests set DirtyNavigation = DirtyNavigationMode.SilentBlock. SubmitAsync(work) calls MarkClean() on success.

public sealed class ProductEditViewModel : UnoFormViewModel
{
    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);
}

Pagination and search

PagedCollection<T> / DelegatePagedCollection<T> own load-more, refresh, and retry — not a live inbox. SnapshotCollection<T> loads once in InitializeAsync; later LoadAsync calls are no-ops unless force is true. SearchQuery.Text binds to a TextBox or MvvmSearch; filter from CommittedText after debounce.

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

await Products.RefreshAsync(ct);
await Products.LoadMoreAsync(ct);

Chat-style host

A desktop messenger is one persistent window, in-place tabs, a filterable inbox, and a thread on the Frame stack. Bind SectionHostView to the host ViewModel — do not write visibility flippers in code-behind. Auth and forms stay on FormViewModel / IAuthState / UseAuth.

builder.Services.UseUnoMvvmExpress(o => o
    .UseFrameNavigation((nav, _) => nav
        .Map<LoginViewModel, LoginPage>("login")
        .Map<ChatHostViewModel, ChatHostPage>("chats")
        .Map<ChatThreadViewModel, ChatThreadPage>("thread"))
    .UseDialogs()
    .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 SectionHostView. DataContext is the host; Current is the visible child.

Auth adapters

In-memory IAuthState exists for samples and tests. Production apps register their own IAuthState. AddSecureSessionAuth() in Core fails closed unless you supply a factory — there is no UseSecureSessionAuth() host helper and no Plugin.Maui.SecureSession PackageReference. GuardedNavigator remains the implementation; UseAuth is the getting-started API.

builder.Services.AddSingleton<IAuthState, DemoAuthState>();
builder.Services.UseUnoMvvmExpress(o => o
    .UseFrameNavigation()
    .UseDialogs()
    .UseAuth<LoginViewModel>());

await Navigator.ResetAsync<HomeViewModel>();  // after sign-in
await Navigator.ResetAsync<LoginViewModel>(); // after sign-out

Testing

Plugin.Uno.MVVMExpress.Testing is net10.0. ViewModels in the template live in a shared net10.0 project so they can be tested without Uno Platform. FakeNavigator is InMemoryNavigator — assert Current, Stack, and CanGoBack. LeakProbe.Track returns a WeakReference; IsCollected takes that reference. Drop the strong reference before the assert. AppearAsync / DisappearAsync drive lifecycle without a page. ScopedNavigator covers page-scope push/pop GC.

[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;
}

Sample map

There is no separate Uno Platform SampleApp repository. New apps start with dotnet new uno-mvvmexpress. The 15-minute path is Playground (command, navigation, dialog, form, auth, list, second window).

Clone and run: samples/Playground

SampleViewModelsWhat it integrates
dotnet new uno-mvvmexpressLogin / Home / Details / EditScaffolded host: Frame, replace-root, form, tests
PlaygroundLogin / Home / Details / Edit15-minute path: UseAuth, command, nav, dialog, form, list, second window

Pitfalls

  • Do not show a dialog or call Frame.Navigate 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 panel to thousands of rows.
  • Do not store tokens on the ViewModel. Register an IAuthState adapter.
  • Do not invent a Shell host. Use Frame + SectionHostView.
  • Do not reconstruct GuardedNavigator after UseFrameNavigation. Call UseAuth<TChallenge>().
  • Do not ConfigureAwait(false) then construct a view or call Frame.Navigate. Navigators hop to IMainThread first.
  • Do not replace Window.Content with a toast host.
  • Do not name the Frame something other than NavigationHost unless you pass a custom frame accessor.
  • Do not add a PackageReference to Plugin.Maui.MVVMExpress.* or Plugin.Wpf.MVVMExpress.*. This is an independent family.
  • Do not mix dispatcher statics in ViewModels. IMainThread is the only marshal API.
  • Hand-written SetProperty and Map<TViewModel, TView> are the 1.0 path. Generators are out of 1.0.

Discussion

Comment on Plugin.Uno.MVVMExpress. The thread lives on this component's GitHub repository (nuvyntralabs/Plugin.Uno.MVVMExpress). Sign in with GitHub — Giscus uses Discussions, Utterances uses Issues.