mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-15 11:11:48 +00:00
WinUI plugin enhancements and add MVVM Toolkit skill (#1643)
* WinUI plugin enhancements and mvvm toolkit skill * Split mvvm-toolkit skill for slimming
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: mvvm-toolkit-di
|
||||
description: 'Wire CommunityToolkit.Mvvm ViewModels into Microsoft.Extensions.DependencyInjection. Covers the .NET Generic Host composition root, constructor injection, service lifetimes (Singleton / Transient / Scoped), IMessenger registration, resolving ViewModels in Views, keyed services, testing seams, and the legacy Ioc.Default escape hatch. Use across WPF, WinUI 3, .NET MAUI, Uno, and Avalonia.'
|
||||
---
|
||||
|
||||
# CommunityToolkit.Mvvm + `Microsoft.Extensions.DependencyInjection`
|
||||
|
||||
The MVVM Toolkit deliberately ships **no DI container** — it composes with
|
||||
`Microsoft.Extensions.DependencyInjection`, the same container ASP.NET
|
||||
Core, Worker services, and the .NET Generic Host use.
|
||||
|
||||
> **TL;DR.** Build the service provider once at startup (prefer
|
||||
> `Host.CreateDefaultBuilder()`). Register services and ViewModels.
|
||||
> Inject through constructors. Avoid `Ioc.Default.GetService<T>()`
|
||||
> in user code.
|
||||
|
||||
---
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Standing up the composition root for a new XAML app (WPF, WinUI 3,
|
||||
MAUI, Uno, Avalonia)
|
||||
- Choosing service/VM lifetimes
|
||||
- Wiring `IMessenger` once and injecting it into `ObservableRecipient`
|
||||
ViewModels
|
||||
- Resolving a page's ViewModel without coupling to a service locator
|
||||
- Diagnosing "Unable to resolve service for type X while attempting to
|
||||
activate Y"
|
||||
|
||||
For source generators and ViewModel patterns see the **`mvvm-toolkit`**
|
||||
skill. For Messenger pub/sub see **`mvvm-toolkit-messenger`**.
|
||||
|
||||
---
|
||||
|
||||
## Recommended composition root (Generic Host)
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public IHost Host { get; }
|
||||
|
||||
public App()
|
||||
{
|
||||
Host = Microsoft.Extensions.Hosting.Host
|
||||
.CreateDefaultBuilder()
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<IFilesService, FilesService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
|
||||
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
services.AddTransient<ContactViewModel>();
|
||||
services.AddTransient<EditorViewModel>();
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static T GetService<T>() where T : class =>
|
||||
((App)Current).Host.Services.GetRequiredService<T>();
|
||||
}
|
||||
```
|
||||
|
||||
Generic Host benefits:
|
||||
|
||||
- `appsettings.json` binding via `Microsoft.Extensions.Configuration`
|
||||
- Logging via `Microsoft.Extensions.Logging`
|
||||
- Hosted services (`IHostedService`) for background work
|
||||
- Scope validation in development builds
|
||||
|
||||
> WPF and Windows Forms must integrate the host lifetime with the app
|
||||
> lifetime — see
|
||||
> [Use the .NET Generic Host in a WPF app](https://learn.microsoft.com/en-us/dotnet/desktop/wpf/app-development/how-to-use-host-builder).
|
||||
|
||||
### Without Generic Host
|
||||
|
||||
When you only need a service container and want zero extra dependencies:
|
||||
|
||||
```csharp
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IFilesService, FilesService>();
|
||||
services.AddTransient<ContactViewModel>();
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constructor injection
|
||||
|
||||
Inject services and child ViewModels through the constructor:
|
||||
|
||||
```csharp
|
||||
public sealed partial class ContactViewModel(
|
||||
IFilesService files,
|
||||
IMessenger messenger,
|
||||
ILogger<ContactViewModel> logger)
|
||||
: ObservableRecipient(messenger)
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
logger.LogInformation("Saving {Name}", Name);
|
||||
await files.SaveAsync(Name!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Why constructor injection beats a service locator:
|
||||
|
||||
- Dependencies are explicit and visible at the call site
|
||||
- Unit tests inject fakes/mocks directly
|
||||
- The DI container validates the dependency graph at startup
|
||||
- Missing registrations throw immediately, not at first use
|
||||
|
||||
---
|
||||
|
||||
## Lifetimes
|
||||
|
||||
| Lifetime | Method | Typical use in XAML apps |
|
||||
|----------|--------|--------------------------|
|
||||
| Singleton | `AddSingleton<T>` | Shell/main-window VM, settings, file/HTTP services, the shared `IMessenger`, app-wide caches |
|
||||
| Transient | `AddTransient<T>` | Per-page or per-document ViewModels (a fresh instance every resolve) |
|
||||
| Scoped | `AddScoped<T>` | Rarely needed in client apps; useful with explicit `IServiceScope` (e.g., per-window scopes) |
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<ShellViewModel>(); // 1 instance for app lifetime
|
||||
services.AddTransient<NoteViewModel>(); // new instance per resolve
|
||||
services.AddScoped<DialogService>(); // 1 per scope (rare)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resolving in a View
|
||||
|
||||
Resolve the page's root ViewModel in code-behind, then let it pull its
|
||||
own dependencies:
|
||||
|
||||
```csharp
|
||||
public sealed partial class ContactPage : Page
|
||||
{
|
||||
public ContactViewModel ViewModel { get; }
|
||||
|
||||
public ContactPage()
|
||||
{
|
||||
ViewModel = App.GetService<ContactViewModel>();
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in XAML with `{x:Bind ViewModel.Xxx}` (compiled bindings) or
|
||||
`{Binding Xxx}` against `DataContext`.
|
||||
|
||||
For navigation frameworks (WinUI 3 `Frame.Navigate`, MAUI Shell, Prism,
|
||||
MVVMCross), let the framework resolve the page and the page resolves its
|
||||
ViewModel from DI. Don't `new` ViewModels manually.
|
||||
|
||||
---
|
||||
|
||||
## `IMessenger` registration
|
||||
|
||||
Register the messenger you want once, inject `IMessenger` everywhere:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
|
||||
// or
|
||||
services.AddSingleton<IMessenger>(StrongReferenceMessenger.Default);
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```csharp
|
||||
public sealed partial class MyViewModel(IMessenger messenger)
|
||||
: ObservableRecipient(messenger) { }
|
||||
```
|
||||
|
||||
For per-window messengers, register with keyed services or as scoped
|
||||
instances and inject into per-window ViewModels.
|
||||
|
||||
See the **`mvvm-toolkit-messenger`** skill for the messenger surface area.
|
||||
|
||||
---
|
||||
|
||||
## Keyed services (.NET 8+)
|
||||
|
||||
Resolve different implementations of the same interface by key:
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<IExporter, CsvExporter>("csv");
|
||||
services.AddKeyedSingleton<IExporter, JsonExporter>("json");
|
||||
|
||||
public sealed partial class ExportViewModel(
|
||||
[FromKeyedServices("csv")] IExporter csvExporter,
|
||||
[FromKeyedServices("json")] IExporter jsonExporter)
|
||||
: ObservableObject { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing seams
|
||||
|
||||
Constructor-injected dependencies are trivial to swap in tests. With
|
||||
`Moq`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Save_calls_files_service()
|
||||
{
|
||||
var files = new Mock<IFilesService>();
|
||||
var messenger = new WeakReferenceMessenger();
|
||||
var logger = NullLogger<ContactViewModel>.Instance;
|
||||
|
||||
var vm = new ContactViewModel(files.Object, messenger, logger)
|
||||
{
|
||||
Name = "Ada"
|
||||
};
|
||||
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
files.Verify(f => f.SaveAsync("Ada"), Times.Once);
|
||||
}
|
||||
```
|
||||
|
||||
If you're mocking `Ioc.Default` or static state, the ViewModel is using a
|
||||
service locator — refactor to constructor injection.
|
||||
|
||||
---
|
||||
|
||||
## Legacy: `Ioc.Default`
|
||||
|
||||
`CommunityToolkit.Mvvm.DependencyInjection.Ioc` is an escape hatch for
|
||||
cases where constructor injection is impossible — XAML-instantiated VMs
|
||||
for design-time data, `ValueConverter`s, control templates.
|
||||
|
||||
```csharp
|
||||
Ioc.Default.ConfigureServices(
|
||||
new ServiceCollection()
|
||||
.AddSingleton<IFilesService, FilesService>()
|
||||
.AddTransient<ContactViewModel>()
|
||||
.BuildServiceProvider());
|
||||
|
||||
var files = Ioc.Default.GetRequiredService<IFilesService>();
|
||||
```
|
||||
|
||||
Treat it as the last resort. Inside ViewModels, services, and any class
|
||||
the DI container can construct, prefer constructor injection.
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
1. **`Ioc.Default.GetService<T>()` inside a VM constructor.** Hides the
|
||||
dependency, breaks unit tests, prevents startup graph validation.
|
||||
2. **Everything `Singleton`.** A "per-document" VM registered as singleton
|
||||
becomes shared state across all documents — subtle data corruption.
|
||||
Use `AddTransient` for per-instance VMs.
|
||||
3. **Multiple `BuildServiceProvider()` calls.** Each call is a fresh
|
||||
container — singletons aren't shared. Build once at startup.
|
||||
4. **Capturing `IServiceProvider` in long-lived objects.** Indicates a
|
||||
service-locator pattern. Inject the specific dependencies you need.
|
||||
5. **No scope validation in development.** Use `Host.CreateDefaultBuilder()`
|
||||
(which sets `ValidateScopes` and `ValidateOnBuild` in development) so
|
||||
registration mistakes fail at startup, not at first use.
|
||||
6. **Resolving scoped services from the root provider.** They're
|
||||
effectively promoted to singleton lifetime — the warning is silent
|
||||
without scope validation. Either change the lifetime or resolve from
|
||||
an explicit `IServiceScope`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Full deep dive (Generic Host setup, lifetimes, keyed services, testing patterns, legacy Ioc) | [`references/dependency-injection.md`](references/dependency-injection.md) |
|
||||
|
||||
External:
|
||||
|
||||
- DI overview: <https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection>
|
||||
- DI usage: <https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-usage>
|
||||
- MVVM Toolkit Ioc page: <https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/ioc>
|
||||
- Generic Host: <https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host>
|
||||
@@ -0,0 +1,274 @@
|
||||
# Dependency injection
|
||||
|
||||
The MVVM Toolkit deliberately ships **no DI container of its own** — it
|
||||
integrates with `Microsoft.Extensions.DependencyInjection`, the same
|
||||
container used by ASP.NET Core, Worker services, and the .NET Generic Host.
|
||||
|
||||
> **Default to constructor injection.** Resolve services and child
|
||||
> ViewModels through the constructor of the type that needs them. Avoid the
|
||||
> service-locator pattern (`Ioc.Default.GetService<T>()`) in user code.
|
||||
|
||||
---
|
||||
|
||||
## Recommended composition root (Generic Host)
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public IHost Host { get; }
|
||||
|
||||
public App()
|
||||
{
|
||||
Host = Microsoft.Extensions.Hosting.Host
|
||||
.CreateDefaultBuilder()
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<IFilesService, FilesService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
|
||||
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
services.AddTransient<ContactViewModel>();
|
||||
services.AddTransient<EditorViewModel>();
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static T GetService<T>() where T : class =>
|
||||
((App)Current).Host.Services.GetRequiredService<T>();
|
||||
}
|
||||
```
|
||||
|
||||
Generic Host benefits:
|
||||
|
||||
- `appsettings.json` configuration binding via `Microsoft.Extensions.Configuration`
|
||||
- Built-in logging via `Microsoft.Extensions.Logging`
|
||||
- Hosted services (`IHostedService`) for background work
|
||||
- Scope validation in development builds
|
||||
|
||||
> On WPF and Windows Forms, integrate the host lifetime with the
|
||||
> application lifetime — see
|
||||
> [Use the .NET Generic Host in a WPF app](https://learn.microsoft.com/en-us/dotnet/desktop/wpf/app-development/how-to-use-host-builder).
|
||||
|
||||
---
|
||||
|
||||
## Composition root (no Generic Host)
|
||||
|
||||
When you don't need configuration/logging/hosting, build the provider
|
||||
directly:
|
||||
|
||||
```csharp
|
||||
public partial class App : Application
|
||||
{
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public App()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IFilesService, FilesService>();
|
||||
services.AddTransient<ContactViewModel>();
|
||||
Services = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public static T GetService<T>() where T : class =>
|
||||
((App)Current).Services.GetRequiredService<T>();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constructor injection
|
||||
|
||||
Inject services and child ViewModels through the constructor:
|
||||
|
||||
```csharp
|
||||
public sealed partial class ContactViewModel(
|
||||
IFilesService files,
|
||||
IMessenger messenger,
|
||||
ILogger<ContactViewModel> logger)
|
||||
: ObservableRecipient(messenger)
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
logger.LogInformation("Saving {Name}", Name);
|
||||
await files.SaveAsync(Name!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Why constructor injection beats a service locator:
|
||||
|
||||
- Dependencies are explicit and visible at the call site.
|
||||
- Unit tests inject fakes/mocks without resorting to runtime tricks.
|
||||
- The DI container validates the dependency graph at startup
|
||||
(with `BuildServiceProvider(validateScopes: true)` in dev).
|
||||
- No hidden runtime failures — missing registrations throw immediately.
|
||||
|
||||
---
|
||||
|
||||
## Lifetimes
|
||||
|
||||
| Lifetime | Method | Typical use in XAML apps |
|
||||
|----------|--------|--------------------------|
|
||||
| Singleton | `AddSingleton<T>` | Shell/main-window VM, settings, file/HTTP services, the shared `IMessenger`, app-wide caches |
|
||||
| Transient | `AddTransient<T>` | Per-page or per-document ViewModels (a fresh instance every resolve) |
|
||||
| Scoped | `AddScoped<T>` | Rarely needed in client apps; useful when you create explicit `IServiceScope`s (per-window scopes, per-request scopes for embedded HTTP) |
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<ShellViewModel>(); // 1 instance for app lifetime
|
||||
services.AddTransient<NoteViewModel>(); // new instance per resolve
|
||||
services.AddScoped<DialogService>(); // 1 per scope (rare)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resolving in a View
|
||||
|
||||
Resolve the root ViewModel for the page in code-behind, then let it pull
|
||||
its own dependencies:
|
||||
|
||||
```csharp
|
||||
public sealed partial class ContactPage : Page
|
||||
{
|
||||
public ContactViewModel ViewModel { get; }
|
||||
|
||||
public ContactPage()
|
||||
{
|
||||
ViewModel = App.GetService<ContactViewModel>();
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in XAML with `{x:Bind ViewModel.Xxx}` (compiled bindings) or
|
||||
`{Binding Xxx}` against the `DataContext`.
|
||||
|
||||
For navigation frameworks (WinUI 3 `Frame.Navigate`, MAUI Shell, Prism,
|
||||
MVVMCross), let the framework resolve the page and the page resolves its
|
||||
ViewModel from DI. Avoid creating ViewModels manually.
|
||||
|
||||
---
|
||||
|
||||
## `IMessenger` registration
|
||||
|
||||
The toolkit provides two implementations. Register the one you want once,
|
||||
and inject `IMessenger` everywhere:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
|
||||
// or
|
||||
services.AddSingleton<IMessenger>(StrongReferenceMessenger.Default);
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```csharp
|
||||
public sealed partial class MyViewModel(IMessenger messenger)
|
||||
: ObservableRecipient(messenger) { }
|
||||
```
|
||||
|
||||
Multiple messengers (e.g., one per window) are also valid — register them
|
||||
with keyed services or as scoped instances.
|
||||
|
||||
---
|
||||
|
||||
## Keyed services (.NET 8+)
|
||||
|
||||
Useful when you have multiple implementations of the same interface and
|
||||
want to choose one by key:
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<IExporter, CsvExporter>("csv");
|
||||
services.AddKeyedSingleton<IExporter, JsonExporter>("json");
|
||||
|
||||
public sealed partial class ExportViewModel(
|
||||
[FromKeyedServices("csv")] IExporter csvExporter,
|
||||
[FromKeyedServices("json")] IExporter jsonExporter)
|
||||
: ObservableObject
|
||||
{ /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing seams
|
||||
|
||||
Constructor-injected dependencies are trivial to swap in tests. With
|
||||
`Moq` (or `NSubstitute` / `FakeItEasy`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Save_calls_files_service()
|
||||
{
|
||||
var files = new Mock<IFilesService>();
|
||||
var messenger = new WeakReferenceMessenger();
|
||||
var logger = NullLogger<ContactViewModel>.Instance;
|
||||
|
||||
var vm = new ContactViewModel(files.Object, messenger, logger)
|
||||
{
|
||||
Name = "Ada"
|
||||
};
|
||||
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
files.Verify(f => f.SaveAsync("Ada"), Times.Once);
|
||||
}
|
||||
```
|
||||
|
||||
If you find yourself needing to mock `Ioc.Default` or static state, the
|
||||
ViewModel is using a service locator — refactor to constructor injection
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
## Legacy: `Ioc.Default`
|
||||
|
||||
The toolkit ships `CommunityToolkit.Mvvm.DependencyInjection.Ioc` for cases
|
||||
where constructor injection is impossible (e.g., a XAML-instantiated
|
||||
ViewModel for design-time data, a `ValueConverter`, a control template).
|
||||
|
||||
Setup:
|
||||
|
||||
```csharp
|
||||
Ioc.Default.ConfigureServices(
|
||||
new ServiceCollection()
|
||||
.AddSingleton<IFilesService, FilesService>()
|
||||
.AddTransient<ContactViewModel>()
|
||||
.BuildServiceProvider());
|
||||
```
|
||||
|
||||
Resolve:
|
||||
|
||||
```csharp
|
||||
var files = Ioc.Default.GetRequiredService<IFilesService>();
|
||||
```
|
||||
|
||||
Treat this as an escape hatch only. Inside ViewModels, services, and any
|
||||
class you can pass through DI, prefer constructor injection.
|
||||
|
||||
---
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Resolving children from inside a ViewModel constructor via `Ioc`.**
|
||||
Hides the dependency. Inject the child VM (or a factory) through the
|
||||
constructor instead.
|
||||
2. **Registering everything as singleton.** A "per-document" ViewModel
|
||||
registered as singleton becomes shared state across all documents — a
|
||||
subtle data-corruption bug. Use `AddTransient` for per-instance VMs.
|
||||
3. **Building multiple `ServiceProvider` instances.** Each
|
||||
`BuildServiceProvider()` is a fresh container — singletons aren't
|
||||
shared. Build once at startup, then reuse.
|
||||
4. **Capturing the `IServiceProvider` itself in long-lived objects.**
|
||||
Indicates a service-locator pattern. Inject the specific dependencies
|
||||
you need.
|
||||
5. **Forgetting to wire scope validation in development.** Use
|
||||
`Host.CreateDefaultBuilder()` (which sets `ValidateScopes` and
|
||||
`ValidateOnBuild` in development) so registration mistakes fail at
|
||||
startup, not at first use.
|
||||
@@ -0,0 +1,268 @@
|
||||
---
|
||||
name: mvvm-toolkit-messenger
|
||||
description: 'CommunityToolkit.Mvvm Messenger pub/sub for decoupled communication between ViewModels (or any objects). Covers WeakReferenceMessenger vs StrongReferenceMessenger, IRecipient<TMessage>, RequestMessage<T> / AsyncRequestMessage<T> / CollectionRequestMessage<T>, ValueChangedMessage<T>, channels (tokens), and the ObservableRecipient activation lifecycle. Use across WPF, WinUI 3, .NET MAUI, Uno, and Avalonia.'
|
||||
---
|
||||
|
||||
# CommunityToolkit.Mvvm Messenger
|
||||
|
||||
Pub/sub messaging for ViewModels (or any objects) without forcing a shared
|
||||
reference graph. Part of `CommunityToolkit.Mvvm` 8.x.
|
||||
|
||||
> **TL;DR.** Default to `WeakReferenceMessenger.Default`. Register handlers
|
||||
> with the `(recipient, message)` lambda and the `static` modifier so you
|
||||
> never capture `this`. Inherit from `ObservableRecipient` and toggle
|
||||
> `IsActive` at activation/deactivation to get automatic register/unregister.
|
||||
|
||||
---
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Two or more ViewModels need to react to an event (login, theme change,
|
||||
save, navigation) without holding references to each other
|
||||
- A ViewModel needs to ask another VM for a value (request/reply)
|
||||
- You're scoping events to a sub-system or window with channel tokens
|
||||
- Diagnosing "my handler never fires" or weak-reference recipient lifetime
|
||||
problems
|
||||
|
||||
For source generators, base classes, and commands see the **`mvvm-toolkit`**
|
||||
skill. For DI wiring (registering an `IMessenger` instance), see
|
||||
**`mvvm-toolkit-di`**.
|
||||
|
||||
---
|
||||
|
||||
## Choose an implementation
|
||||
|
||||
| Type | When |
|
||||
|------|------|
|
||||
| `WeakReferenceMessenger.Default` | **Default.** Recipients held weakly — eligible for GC even while registered. Internal trimming runs during full GCs; no manual `Cleanup()` needed. |
|
||||
| `StrongReferenceMessenger.Default` | Profiler shows the messenger is hot and allocation matters. Recipients are pinned until you `Unregister`. Forgetting unregistration leaks them. |
|
||||
| Custom `IMessenger` instance | Per-window/per-scope (e.g., one messenger per app window). Construct directly, inject via DI. |
|
||||
|
||||
`ObservableRecipient`'s parameterless constructor uses
|
||||
`WeakReferenceMessenger.Default`. Pass a different `IMessenger` to its
|
||||
constructor to override.
|
||||
|
||||
---
|
||||
|
||||
## Define a message
|
||||
|
||||
The toolkit ships base classes; any class works.
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
// Single-payload broadcast
|
||||
public sealed class LoggedInUserChangedMessage(User user)
|
||||
: ValueChangedMessage<User>(user);
|
||||
|
||||
// Custom shape (records are great for this)
|
||||
public sealed record ThemeChangedMessage(AppTheme NewTheme);
|
||||
|
||||
// Empty signal
|
||||
public sealed record RefreshRequestedMessage;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Register a recipient
|
||||
|
||||
### Lambda style (recommended)
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Register<MyViewModel, ThemeChangedMessage>(
|
||||
this,
|
||||
static (recipient, message) => recipient.OnThemeChanged(message.NewTheme));
|
||||
```
|
||||
|
||||
The `static` modifier prevents accidental closure allocation and keeps
|
||||
`this` out of the lambda — use the `recipient` parameter instead.
|
||||
|
||||
### `IRecipient<TMessage>` interface style
|
||||
|
||||
```csharp
|
||||
public sealed class MyViewModel : ObservableRecipient,
|
||||
IRecipient<ThemeChangedMessage>,
|
||||
IRecipient<RefreshRequestedMessage>
|
||||
{
|
||||
public void Receive(ThemeChangedMessage message) { /* ... */ }
|
||||
public void Receive(RefreshRequestedMessage message) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
`ObservableRecipient.OnActivated()` calls `Messenger.RegisterAll(this)`,
|
||||
which subscribes every `IRecipient<T>` interface implemented by the type.
|
||||
If you're not using `ObservableRecipient`, register manually:
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Send a message
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(AppTheme.Dark));
|
||||
|
||||
// Empty payloads use the parameterless overload:
|
||||
WeakReferenceMessenger.Default.Send<RefreshRequestedMessage>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channels (tokens)
|
||||
|
||||
Scope messages to a sub-system or window with a token (any equatable
|
||||
value — `int`, `string`, `Guid`):
|
||||
|
||||
```csharp
|
||||
const int LeftPaneChannel = 1;
|
||||
|
||||
WeakReferenceMessenger.Default.Register<MyViewModel, RefreshRequestedMessage, int>(
|
||||
this, LeftPaneChannel,
|
||||
static (r, _) => r.RefreshLeft());
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new RefreshRequestedMessage(), LeftPaneChannel);
|
||||
```
|
||||
|
||||
Messages sent without a token use the default shared channel — they are
|
||||
**not** delivered to channel-scoped recipients.
|
||||
|
||||
---
|
||||
|
||||
## Request / reply
|
||||
|
||||
For ask-style scenarios where a recipient provides a value back to the
|
||||
sender, use the `RequestMessage<T>` family.
|
||||
|
||||
### Sync request
|
||||
|
||||
```csharp
|
||||
public sealed class CurrentUserRequest : RequestMessage<User> { }
|
||||
|
||||
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
|
||||
this,
|
||||
static (r, m) => m.Reply(r.CurrentUser));
|
||||
|
||||
User user = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
```
|
||||
|
||||
The implicit conversion from `CurrentUserRequest` to `User` throws if no
|
||||
recipient called `Reply`. Capture the message to check first:
|
||||
|
||||
```csharp
|
||||
var request = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
if (request.HasReceivedResponse)
|
||||
User user = request.Response;
|
||||
```
|
||||
|
||||
### Async request
|
||||
|
||||
```csharp
|
||||
public sealed class CurrentUserRequest : AsyncRequestMessage<User> { }
|
||||
|
||||
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
|
||||
this,
|
||||
static (r, m) => m.Reply(r.GetCurrentUserAsync()));
|
||||
|
||||
User user = await WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
```
|
||||
|
||||
### Collection requests (fan-in)
|
||||
|
||||
`CollectionRequestMessage<T>` and `AsyncCollectionRequestMessage<T>` collect
|
||||
a `Reply` from every responding recipient:
|
||||
|
||||
```csharp
|
||||
public sealed class OpenDocumentsRequest : CollectionRequestMessage<Document> { }
|
||||
|
||||
var docs = WeakReferenceMessenger.Default.Send<OpenDocumentsRequest>();
|
||||
foreach (Document doc in docs) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Even with `WeakReferenceMessenger`, unregister explicitly when a recipient
|
||||
is being torn down — it trims dead entries and improves performance:
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage, int>(this, LeftPaneChannel);
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
```
|
||||
|
||||
`ObservableRecipient.OnDeactivated()` does this automatically when
|
||||
`IsActive` flips to `false`. Set it from your activation hook:
|
||||
|
||||
```csharp
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
ViewModel.IsActive = true;
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
{
|
||||
ViewModel.IsActive = false;
|
||||
base.OnNavigatedFrom(e);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
1. **Capturing `this` in the lambda.** `(r, m) => OnX(m)` implicitly
|
||||
captures `this`; allocates a closure and confuses lifetime. Always use
|
||||
`(r, m) => r.OnX(m)` with `static`.
|
||||
2. **Strong-ref recipients without `Unregister`.** With
|
||||
`StrongReferenceMessenger`, recipients (and their entire object graph)
|
||||
stay pinned forever. Either inherit from `ObservableRecipient`
|
||||
(auto-unregisters in `OnDeactivated`) or call `UnregisterAll(this)`.
|
||||
3. **Inherited message types.** A handler registered for `BaseMessage` is
|
||||
**not** invoked for `DerivedMessage : BaseMessage`. Register each
|
||||
concrete type.
|
||||
4. **Wrong messenger instance.** Sending via `WeakReferenceMessenger.Default`
|
||||
and registering via an injected per-window messenger means the message
|
||||
never arrives. Use the same `IMessenger` everywhere (typically inject
|
||||
it via `ObservableRecipient(messenger)`).
|
||||
5. **`OnActivated` never runs.** `ObservableRecipient` only registers
|
||||
`IRecipient<T>` handlers when `IsActive` flips from `false` to `true`.
|
||||
6. **Cross-thread updates.** The messenger is thread-agnostic. If a
|
||||
handler updates UI, marshal manually
|
||||
(`DispatcherQueue.TryEnqueue` / `Dispatcher.BeginInvoke`).
|
||||
|
||||
---
|
||||
|
||||
## Multiple messengers (per-window scoping)
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default); // app-wide
|
||||
services.AddScoped<WindowScopedMessenger>(); // per-window
|
||||
```
|
||||
|
||||
Inject the appropriate `IMessenger` into the ViewModel constructor:
|
||||
|
||||
```csharp
|
||||
public sealed partial class WindowViewModel(IMessenger messenger)
|
||||
: ObservableRecipient(messenger) { }
|
||||
```
|
||||
|
||||
This isolates broadcasts to a single window — useful for multi-window
|
||||
desktop apps (WinUI 3, WPF, MAUI desktop, Avalonia).
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Full deep dive (more channel/lifecycle examples, diagnostics) | [`references/messenger-patterns.md`](references/messenger-patterns.md) |
|
||||
|
||||
External:
|
||||
|
||||
- Messenger docs: <https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/messenger>
|
||||
- `WeakReferenceMessenger` API: <https://learn.microsoft.com/en-us/dotnet/api/communitytoolkit.mvvm.messaging.weakreferencemessenger>
|
||||
- Source: <https://github.com/CommunityToolkit/dotnet>
|
||||
@@ -0,0 +1,231 @@
|
||||
# Messenger patterns
|
||||
|
||||
`CommunityToolkit.Mvvm.Messaging` provides decoupled pub/sub between
|
||||
ViewModels (or any objects) without forcing a shared reference graph.
|
||||
|
||||
## Choosing an implementation
|
||||
|
||||
| Type | When to use |
|
||||
|------|------------|
|
||||
| `WeakReferenceMessenger.Default` | **Default.** Recipients held weakly — eligible for GC even if still registered. Internal trimming runs during full GCs. No manual `Cleanup()` required. |
|
||||
| `StrongReferenceMessenger.Default` | Use when profiling shows the messenger is hot and allocation matters. Recipients are pinned until you `Unregister`. Forgetting to unregister leaks them. |
|
||||
| Custom `IMessenger` instance | Per-window/per-scope messengers (e.g., one per app window). Construct directly and inject through DI. |
|
||||
|
||||
`ObservableRecipient`'s parameterless constructor uses
|
||||
`WeakReferenceMessenger.Default`. Pass a different `IMessenger` to its
|
||||
constructor to override.
|
||||
|
||||
---
|
||||
|
||||
## Defining messages
|
||||
|
||||
The toolkit ships a few base classes you can inherit from, but any class
|
||||
works.
|
||||
|
||||
### Plain payload
|
||||
|
||||
```csharp
|
||||
public sealed record ThemeChangedMessage(AppTheme NewTheme);
|
||||
```
|
||||
|
||||
### `ValueChangedMessage<T>`
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
public sealed class LoggedInUserChangedMessage(User user)
|
||||
: ValueChangedMessage<User>(user);
|
||||
```
|
||||
|
||||
Access the payload via `.Value`.
|
||||
|
||||
### Empty signal
|
||||
|
||||
```csharp
|
||||
public sealed record RefreshRequestedMessage;
|
||||
```
|
||||
|
||||
Useful for "reload now" or "save now" broadcasts where there is no payload.
|
||||
|
||||
---
|
||||
|
||||
## Registering recipients
|
||||
|
||||
### Lambda style (recommended)
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Register<MyViewModel, ThemeChangedMessage>(
|
||||
this,
|
||||
static (recipient, message) => recipient.OnThemeChanged(message.NewTheme));
|
||||
```
|
||||
|
||||
The `static` modifier ensures the lambda doesn't capture `this` (or any
|
||||
local variable), keeping it allocation-free and preventing accidental strong
|
||||
references back to the recipient through closure capture.
|
||||
|
||||
### `IRecipient<TMessage>` interface style
|
||||
|
||||
```csharp
|
||||
public sealed class MyViewModel : ObservableRecipient,
|
||||
IRecipient<ThemeChangedMessage>,
|
||||
IRecipient<RefreshRequestedMessage>
|
||||
{
|
||||
public void Receive(ThemeChangedMessage message) { /* ... */ }
|
||||
public void Receive(RefreshRequestedMessage message) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
`ObservableRecipient.OnActivated()` calls `Messenger.RegisterAll(this)`,
|
||||
which subscribes every `IRecipient<T>` interface implemented by the type.
|
||||
|
||||
If you're not using `ObservableRecipient`, register manually:
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sending messages
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(AppTheme.Dark));
|
||||
|
||||
// Empty payloads can use the parameterless overload:
|
||||
WeakReferenceMessenger.Default.Send<RefreshRequestedMessage>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channels (tokens)
|
||||
|
||||
Send/receive over a named channel to scope messages to a sub-system. The
|
||||
token is any equatable value (commonly `int`, `string`, or `Guid`).
|
||||
|
||||
```csharp
|
||||
const int LeftPaneChannel = 1;
|
||||
const int RightPaneChannel = 2;
|
||||
|
||||
WeakReferenceMessenger.Default.Register<MyViewModel, RefreshRequestedMessage, int>(
|
||||
this, LeftPaneChannel,
|
||||
static (r, _) => r.RefreshLeft());
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new RefreshRequestedMessage(), LeftPaneChannel);
|
||||
```
|
||||
|
||||
Messages sent without a token use the default shared channel and are
|
||||
**not** delivered to channel-scoped recipients.
|
||||
|
||||
---
|
||||
|
||||
## Request / reply
|
||||
|
||||
For ask-style scenarios where a recipient should provide a value back to
|
||||
the sender, use the `RequestMessage<T>` family.
|
||||
|
||||
### Sync request
|
||||
|
||||
```csharp
|
||||
public sealed class CurrentUserRequest : RequestMessage<User> { }
|
||||
|
||||
// Recipient
|
||||
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
|
||||
this,
|
||||
static (r, m) => m.Reply(r.CurrentUser));
|
||||
|
||||
// Caller
|
||||
User user = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
```
|
||||
|
||||
The implicit conversion from `CurrentUserRequest` to `User` throws if no
|
||||
recipient called `Reply`. To check first, capture the message:
|
||||
|
||||
```csharp
|
||||
var request = WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
if (request.HasReceivedResponse)
|
||||
{
|
||||
User user = request.Response;
|
||||
}
|
||||
```
|
||||
|
||||
### Async request
|
||||
|
||||
```csharp
|
||||
public sealed class CurrentUserRequest : AsyncRequestMessage<User> { }
|
||||
|
||||
WeakReferenceMessenger.Default.Register<UserService, CurrentUserRequest>(
|
||||
this,
|
||||
static (r, m) => m.Reply(r.GetCurrentUserAsync()));
|
||||
|
||||
User user = await WeakReferenceMessenger.Default.Send<CurrentUserRequest>();
|
||||
```
|
||||
|
||||
### Collection requests (fan-in)
|
||||
|
||||
`CollectionRequestMessage<T>` and `AsyncCollectionRequestMessage<T>` collect
|
||||
a `Reply` from every recipient that handles the message:
|
||||
|
||||
```csharp
|
||||
public sealed class OpenDocumentsRequest : CollectionRequestMessage<Document> { }
|
||||
|
||||
var responses = WeakReferenceMessenger.Default.Send<OpenDocumentsRequest>();
|
||||
foreach (Document doc in responses) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unregistering
|
||||
|
||||
Always unregister when a recipient's lifetime ends. With
|
||||
`WeakReferenceMessenger`, this is for performance (trimming dead entries);
|
||||
with `StrongReferenceMessenger`, it's required to avoid leaks.
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage, int>(this, LeftPaneChannel);
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
```
|
||||
|
||||
`ObservableRecipient.OnDeactivated()` unregisters everything for you when
|
||||
`IsActive` flips to `false` — set `IsActive = true` in your activation flow
|
||||
(e.g., page `OnNavigatedTo`) and `IsActive = false` on tear-down.
|
||||
|
||||
---
|
||||
|
||||
## Lifetime pitfalls
|
||||
|
||||
1. **Closure-captured `this`.** Avoid `(r, m) => OnX(m)` lambdas that
|
||||
implicitly capture the enclosing `this`. Use `(r, m) => r.OnX(m)` so the
|
||||
recipient is passed in instead.
|
||||
2. **Long-lived strong-ref recipients.** With `StrongReferenceMessenger`,
|
||||
forgetting `UnregisterAll` keeps the recipient (and its entire object
|
||||
graph) alive forever.
|
||||
3. **Inherited message types.** A handler registered for `BaseMessage` is
|
||||
**not** invoked for `DerivedMessage : BaseMessage`. Register each
|
||||
concrete type you want to handle.
|
||||
4. **Multiple `ObservableRecipient` activations.** Setting `IsActive = true`
|
||||
twice without an intermediate deactivation throws — guard the toggle.
|
||||
5. **UI-thread marshalling.** The messenger is thread-agnostic. If a
|
||||
handler updates UI, marshal manually
|
||||
(`DispatcherQueue.TryEnqueue` / `Dispatcher.BeginInvoke`).
|
||||
|
||||
---
|
||||
|
||||
## Multiple messengers
|
||||
|
||||
A common architecture is one messenger per window or per scope:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default); // app-wide
|
||||
services.AddScoped<WindowScopedMessenger>(); // per-window
|
||||
```
|
||||
|
||||
Inject the appropriate `IMessenger` into the ViewModel constructor:
|
||||
|
||||
```csharp
|
||||
public sealed partial class WindowViewModel(IMessenger messenger)
|
||||
: ObservableRecipient(messenger) { /* ... */ }
|
||||
```
|
||||
|
||||
This isolates broadcasts to a single window — useful for multi-window
|
||||
desktop apps (WinUI 3, WPF, MAUI desktop, Avalonia).
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: mvvm-toolkit
|
||||
description: 'CommunityToolkit.Mvvm (the MVVM Toolkit) core: source generators ([ObservableProperty], [RelayCommand], [NotifyPropertyChangedFor], [NotifyCanExecuteChangedFor], [NotifyDataErrorInfo]), base classes (ObservableObject / ObservableValidator / ObservableRecipient), commands (RelayCommand / AsyncRelayCommand), and validation. Companion skills: mvvm-toolkit-messenger for pub/sub, mvvm-toolkit-di for Microsoft.Extensions.DependencyInjection wiring. Works across WPF, WinUI 3, MAUI, Uno, and Avalonia.'
|
||||
---
|
||||
|
||||
# CommunityToolkit.Mvvm (core)
|
||||
|
||||
Use this skill when authoring or reviewing ViewModels, properties,
|
||||
commands, or validation in apps that use `CommunityToolkit.Mvvm` 8.x.
|
||||
|
||||
> **Companion skills.** Load **`mvvm-toolkit-messenger`** for `IMessenger`
|
||||
> pub/sub patterns. Load **`mvvm-toolkit-di`** for
|
||||
> `Microsoft.Extensions.DependencyInjection` integration.
|
||||
|
||||
> **Quick recap.** `[ObservableProperty]` on private fields in `partial`
|
||||
> classes; `[RelayCommand]` on instance methods; inherit from
|
||||
> `ObservableObject` (or `ObservableValidator` for input forms,
|
||||
> `ObservableRecipient` when using `IMessenger`).
|
||||
|
||||
---
|
||||
|
||||
## Package & setup
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.*" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
Targets: `netstandard2.0`, `netstandard2.1`, `net6.0`+. Works on .NET, .NET
|
||||
Framework, Mono. Source generators ship in the same NuGet — no extra
|
||||
analyzer reference required.
|
||||
|
||||
Namespaces:
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel; // ObservableObject, [ObservableProperty]
|
||||
using CommunityToolkit.Mvvm.Input; // [RelayCommand], RelayCommand, AsyncRelayCommand
|
||||
```
|
||||
|
||||
> **Universal rule.** Every type that uses `[ObservableProperty]` or
|
||||
> `[RelayCommand]` — and every enclosing type, if nested — must be
|
||||
> declared `partial`. Without it, the generators emit
|
||||
> `MVVMTK0008` / `MVVMTK0042`.
|
||||
|
||||
---
|
||||
|
||||
## Source generators cheat sheet
|
||||
|
||||
| Attribute | Applied to | Generates |
|
||||
|-----------|-----------|-----------|
|
||||
| `[ObservableProperty]` | private field | Public `INotifyPropertyChanged` property + `OnXxxChanging`/`OnXxxChanged` partial-method hooks |
|
||||
| `[NotifyPropertyChangedFor(nameof(Other))]` | observable field | Also raises `PropertyChanged` for the listed property |
|
||||
| `[NotifyCanExecuteChangedFor(nameof(MyCommand))]` | observable field | Calls `MyCommand.NotifyCanExecuteChanged()` on change |
|
||||
| `[NotifyDataErrorInfo]` | observable field on `ObservableValidator` | Calls `ValidateProperty(value)` from the setter |
|
||||
| `[NotifyPropertyChangedRecipients]` | observable field on `ObservableRecipient` | `Broadcast(old, new)` after the change |
|
||||
| `[RelayCommand]` | instance method | Lazy `RelayCommand` / `AsyncRelayCommand` exposed as `IRelayCommand` / `IAsyncRelayCommand` |
|
||||
| `[RelayCommand(CanExecute = nameof(CanX))]` | instance method | Wires `CanExecute` to a method or property |
|
||||
| `[RelayCommand(IncludeCancelCommand = true)]` | async method with `CancellationToken` | Also generates `XxxCancelCommand` |
|
||||
| `[RelayCommand(AllowConcurrentExecutions = true)]` | async method | Allows queued/parallel invocations (default disables while running) |
|
||||
| `[RelayCommand(FlowExceptionsToTaskScheduler = true)]` | async method | Surfaces exceptions via `ExecutionTask` instead of awaiting and rethrowing |
|
||||
| `[property: SomeAttr]` | observable field or `[RelayCommand]` method | Forwards `SomeAttr` onto the generated property (e.g., `[JsonIgnore]`) |
|
||||
|
||||
**Naming.** Field `name` / `_name` / `m_name` → `Name`. Method `LoadAsync` →
|
||||
`LoadCommand` (the `Async` suffix is stripped; a leading `On` is also
|
||||
stripped).
|
||||
|
||||
See [`references/source-generators.md`](references/source-generators.md) for
|
||||
the full attribute reference with generated-code samples.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel patterns
|
||||
|
||||
### Simple observable property
|
||||
|
||||
```csharp
|
||||
public partial class ContactViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks: `OnXxxChanging` / `OnXxxChanged`
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
|
||||
partial void OnNameChanged(string? value) =>
|
||||
Logger.LogInformation("Name changed to {Name}", value);
|
||||
```
|
||||
|
||||
Both single-arg `(value)` and two-arg `(oldValue, newValue)` overloads
|
||||
are available. Implement only the ones you need; unimplemented hooks are
|
||||
elided by the compiler (zero runtime cost).
|
||||
|
||||
### Dependent properties + dependent commands
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FullName))]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string? firstName;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FullName))]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string? lastName;
|
||||
|
||||
public string FullName => $"{FirstName} {LastName}".Trim();
|
||||
```
|
||||
|
||||
### Wrapping a non-observable model
|
||||
|
||||
```csharp
|
||||
public sealed class ObservableUser(User user) : ObservableObject
|
||||
{
|
||||
public string Name
|
||||
{
|
||||
get => user.Name;
|
||||
set => SetProperty(user.Name, value, user, (u, n) => u.Name = n);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pass a static lambda (no captured state) to keep the call allocation-free.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void Refresh() => Items.Reset();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
foreach (var item in await service.GetItemsAsync())
|
||||
Items.Add(item);
|
||||
}
|
||||
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task DownloadAsync(CancellationToken token)
|
||||
{
|
||||
await using var stream = await http.GetStreamAsync(url, token);
|
||||
// ...
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private Task SaveAsync() => repo.SaveAsync(Name!);
|
||||
|
||||
private bool CanSave() => !string.IsNullOrWhiteSpace(Name);
|
||||
```
|
||||
|
||||
Reach for manual `RelayCommand` / `AsyncRelayCommand` constructors only
|
||||
when you must own the command's lifetime explicitly or compose it from
|
||||
non-trivial sources. The attribute style covers ~95% of cases.
|
||||
|
||||
See [`references/relaycommand-cookbook.md`](references/relaycommand-cookbook.md)
|
||||
for sync / async / cancellable / concurrency / error-surfacing recipes.
|
||||
|
||||
---
|
||||
|
||||
## Base class selection
|
||||
|
||||
| Base class | Use when |
|
||||
|------------|---------|
|
||||
| `ObservableObject` | Default. `INotifyPropertyChanged` + `INotifyPropertyChanging` + `SetProperty` overloads + `SetPropertyAndNotifyOnCompletion` for `Task` properties |
|
||||
| `ObservableValidator` | The VM needs `INotifyDataErrorInfo` (forms, settings input) |
|
||||
| `ObservableRecipient` | The VM sends or receives `IMessenger` messages — see the **`mvvm-toolkit-messenger`** skill |
|
||||
|
||||
C# is single-inheritance: `ObservableValidator` and `ObservableRecipient`
|
||||
both extend `ObservableObject`, so combining them requires composition
|
||||
(e.g., inject `IMessenger` into an `ObservableValidator`).
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public sealed partial class RegistrationViewModel : ObservableValidator
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required, MinLength(2), MaxLength(100)]
|
||||
private string? name;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required, EmailAddress]
|
||||
private string? email;
|
||||
|
||||
[RelayCommand]
|
||||
private void Submit()
|
||||
{
|
||||
ValidateAllProperties();
|
||||
if (HasErrors) return;
|
||||
// submit...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other entry points: `TrySetProperty`, `ValidateProperty(value, name)`,
|
||||
`ClearAllErrors()`, `GetErrors(propertyName)`. Custom rules support
|
||||
`[CustomValidation]` methods and custom `ValidationAttribute` subclasses.
|
||||
|
||||
See [`references/validation.md`](references/validation.md) for the full
|
||||
validator surface area.
|
||||
|
||||
---
|
||||
|
||||
## Top pitfalls
|
||||
|
||||
1. **Forgetting `partial`.** Class (and every enclosing type) must be
|
||||
`partial`. Compile error `MVVMTK0008` / `MVVMTK0042`.
|
||||
2. **PascalCase field name.** `[ObservableProperty] private string Name;`
|
||||
collides with the generated property. Use `name`, `_name`, or `m_name`.
|
||||
3. **`async void` on `[RelayCommand]`.** The generator only wraps
|
||||
`Task`-returning methods as `IAsyncRelayCommand`. `async void` becomes
|
||||
a sync `RelayCommand` and exceptions are unobserved. Always return
|
||||
`Task`.
|
||||
4. **Forgetting `[NotifyCanExecuteChangedFor]`.** The Save button stays
|
||||
disabled even though `CanSave()` would now return `true`.
|
||||
5. **Mutating the same reference held by an `[ObservableProperty]`
|
||||
field.** `EqualityComparer<T>.Default` returns `true`, no notification
|
||||
fires. Replace the instance instead of mutating it.
|
||||
|
||||
For the full diagnostic table (`MVVMTK0xxx`) and more pitfalls, see
|
||||
[`references/troubleshooting.md`](references/troubleshooting.md).
|
||||
|
||||
---
|
||||
|
||||
## End-to-end mini walkthrough
|
||||
|
||||
A two-pane Notes app demonstrating generators + commands +
|
||||
`[NotifyCanExecuteChangedFor]`:
|
||||
|
||||
```csharp
|
||||
public sealed partial class NoteViewModel(INotesService notes,
|
||||
IMessenger messenger) : ObservableRecipient(messenger)
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
|
||||
private string? filename;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string? text;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private Task SaveAsync()
|
||||
{
|
||||
Messenger.Send(new NoteSavedMessage(Filename!));
|
||||
return notes.SaveAsync(Filename!, Text!);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private Task DeleteAsync() => notes.DeleteAsync(Filename!);
|
||||
|
||||
private bool CanSave() =>
|
||||
!string.IsNullOrWhiteSpace(Filename) && !string.IsNullOrEmpty(Text);
|
||||
private bool CanDelete() => !string.IsNullOrWhiteSpace(Filename);
|
||||
}
|
||||
```
|
||||
|
||||
For the full sample (DI wiring, View code-behind, XAML, unit tests), see
|
||||
[`references/end-to-end-walkthrough.md`](references/end-to-end-walkthrough.md).
|
||||
|
||||
---
|
||||
|
||||
## References & companion skills
|
||||
|
||||
| Topic | Where |
|
||||
|-------|-------|
|
||||
| Source generator attribute reference | [`references/source-generators.md`](references/source-generators.md) |
|
||||
| RelayCommand recipes | [`references/relaycommand-cookbook.md`](references/relaycommand-cookbook.md) |
|
||||
| Validation deep dive | [`references/validation.md`](references/validation.md) |
|
||||
| Full Notes-app walkthrough | [`references/end-to-end-walkthrough.md`](references/end-to-end-walkthrough.md) |
|
||||
| `MVVMTK0xxx` diagnostics & pitfalls | [`references/troubleshooting.md`](references/troubleshooting.md) |
|
||||
| **Messenger pub/sub** | Companion skill: **`mvvm-toolkit-messenger`** |
|
||||
| **`Microsoft.Extensions.DependencyInjection` wiring** | Companion skill: **`mvvm-toolkit-di`** |
|
||||
|
||||
External sources:
|
||||
|
||||
- Toolkit overview: <https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/>
|
||||
- WinUI MVVM Toolkit tutorial: <https://learn.microsoft.com/en-us/windows/apps/tutorials/winui-mvvm-toolkit/intro>
|
||||
- Source: <https://github.com/CommunityToolkit/dotnet>
|
||||
- Samples: <https://github.com/CommunityToolkit/MVVM-Samples>
|
||||
@@ -0,0 +1,398 @@
|
||||
# End-to-end walkthrough: WinUI 3 Notes app
|
||||
|
||||
A minimal Notes app demonstrating the full MVVM Toolkit story:
|
||||
`ObservableObject`/`ObservableRecipient`, `[ObservableProperty]`,
|
||||
`[RelayCommand]`, `[NotifyCanExecuteChangedFor]`, `WeakReferenceMessenger`,
|
||||
and `Microsoft.Extensions.DependencyInjection`.
|
||||
|
||||
This walkthrough mirrors the official tutorial at
|
||||
<https://learn.microsoft.com/en-us/windows/apps/tutorials/winui-mvvm-toolkit/intro>.
|
||||
|
||||
> The same pattern works on WPF, MAUI, Uno, and Avalonia — only the
|
||||
> XAML, navigation, and `App` bootstrap differ.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
MyApp/ ← WinUI 3 app project
|
||||
App.xaml.cs
|
||||
Views/
|
||||
AllNotesPage.xaml
|
||||
NotePage.xaml
|
||||
MyApp.Shared/ ← .NET class library — ViewModels + services
|
||||
ViewModels/
|
||||
AllNotesViewModel.cs
|
||||
NoteViewModel.cs
|
||||
Services/
|
||||
INotesService.cs
|
||||
FileSystemNotesService.cs
|
||||
Messages/
|
||||
NoteSavedMessage.cs
|
||||
NoteDeletedMessage.cs
|
||||
MyApp.Tests/ ← xUnit / MSTest project — VM unit tests
|
||||
```
|
||||
|
||||
Putting ViewModels in a separate library is the recommended pattern: the
|
||||
library has no UI dependencies, so VMs are unit-testable in isolation.
|
||||
|
||||
---
|
||||
|
||||
## 1. The model
|
||||
|
||||
Plain POCO — no toolkit dependencies.
|
||||
|
||||
```csharp
|
||||
public sealed record NoteSummary(string Filename, string Preview, DateTime LastModified);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The service
|
||||
|
||||
```csharp
|
||||
public interface INotesService
|
||||
{
|
||||
Task<IReadOnlyList<NoteSummary>> GetAllAsync();
|
||||
Task<string> LoadAsync(string filename);
|
||||
Task SaveAsync(string filename, string text);
|
||||
Task DeleteAsync(string filename);
|
||||
}
|
||||
|
||||
public sealed class FileSystemNotesService(string rootFolder) : INotesService
|
||||
{
|
||||
public async Task<IReadOnlyList<NoteSummary>> GetAllAsync()
|
||||
{
|
||||
var files = Directory.GetFiles(rootFolder, "*.txt");
|
||||
var summaries = new List<NoteSummary>(files.Length);
|
||||
foreach (var f in files)
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(f);
|
||||
summaries.Add(new NoteSummary(
|
||||
Path.GetFileName(f),
|
||||
text.Length > 60 ? text[..60] : text,
|
||||
File.GetLastWriteTime(f)));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public Task<string> LoadAsync(string filename) =>
|
||||
File.ReadAllTextAsync(Path.Combine(rootFolder, filename));
|
||||
|
||||
public Task SaveAsync(string filename, string text) =>
|
||||
File.WriteAllTextAsync(Path.Combine(rootFolder, filename), text);
|
||||
|
||||
public Task DeleteAsync(string filename)
|
||||
{
|
||||
File.Delete(Path.Combine(rootFolder, filename));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The messages
|
||||
|
||||
```csharp
|
||||
public sealed record NoteSavedMessage(string Filename);
|
||||
public sealed record NoteDeletedMessage(string Filename);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. The list view model
|
||||
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
public sealed partial class AllNotesViewModel : ObservableRecipient,
|
||||
IRecipient<NoteSavedMessage>,
|
||||
IRecipient<NoteDeletedMessage>
|
||||
{
|
||||
private readonly INotesService notes;
|
||||
|
||||
public AllNotesViewModel(INotesService notes, IMessenger messenger)
|
||||
: base(messenger)
|
||||
{
|
||||
this.notes = notes;
|
||||
IsActive = true; // start listening for messages
|
||||
}
|
||||
|
||||
public ObservableCollection<NoteSummary> Notes { get; } = new();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
Notes.Clear();
|
||||
foreach (var n in await notes.GetAllAsync())
|
||||
Notes.Add(n);
|
||||
}
|
||||
|
||||
public void Receive(NoteSavedMessage message) => _ = LoadAsync();
|
||||
public void Receive(NoteDeletedMessage message) => _ = LoadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
`ObservableRecipient`'s `OnActivated` (called when `IsActive` becomes
|
||||
`true`) wires up the `IRecipient<T>` handlers automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. The editor view model
|
||||
|
||||
```csharp
|
||||
public sealed partial class NoteViewModel : ObservableRecipient
|
||||
{
|
||||
private readonly INotesService notes;
|
||||
|
||||
public NoteViewModel(INotesService notes, IMessenger messenger)
|
||||
: base(messenger)
|
||||
{
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
|
||||
private string? filename;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string? text;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync(string filename)
|
||||
{
|
||||
Filename = filename;
|
||||
Text = await notes.LoadAsync(filename);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
await notes.SaveAsync(Filename!, Text!);
|
||||
Messenger.Send(new NoteSavedMessage(Filename!));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
await notes.DeleteAsync(Filename!);
|
||||
Messenger.Send(new NoteDeletedMessage(Filename!));
|
||||
Filename = null;
|
||||
Text = null;
|
||||
}
|
||||
|
||||
private bool CanSave() =>
|
||||
!string.IsNullOrWhiteSpace(Filename) && !string.IsNullOrEmpty(Text);
|
||||
|
||||
private bool CanDelete() => !string.IsNullOrWhiteSpace(Filename);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. The composition root (`App.xaml.cs`)
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public IHost Host { get; }
|
||||
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var notesRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MyApp", "notes");
|
||||
Directory.CreateDirectory(notesRoot);
|
||||
|
||||
Host = Microsoft.Extensions.Hosting.Host
|
||||
.CreateDefaultBuilder()
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<INotesService>(_ => new FileSystemNotesService(notesRoot));
|
||||
services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
|
||||
|
||||
services.AddSingleton<AllNotesViewModel>();
|
||||
services.AddTransient<NoteViewModel>();
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static T GetService<T>() where T : class =>
|
||||
((App)Current).Host.Services.GetRequiredService<T>();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire up the views
|
||||
|
||||
`AllNotesPage.xaml.cs`:
|
||||
|
||||
```csharp
|
||||
public sealed partial class AllNotesPage : Page
|
||||
{
|
||||
public AllNotesViewModel ViewModel { get; } = App.GetService<AllNotesViewModel>();
|
||||
|
||||
public AllNotesPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override async void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
await ViewModel.LoadCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`AllNotesPage.xaml`:
|
||||
|
||||
```xml
|
||||
<Page x:Class="MyApp.Views.AllNotesPage"
|
||||
xmlns:vm="using:MyApp.Shared.ViewModels">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<CommandBar>
|
||||
<AppBarButton Icon="Add" Label="New" Click="OnNewClicked"/>
|
||||
<AppBarButton Icon="Refresh" Label="Refresh"
|
||||
Command="{x:Bind ViewModel.LoadCommand}"/>
|
||||
</CommandBar>
|
||||
<ListView Grid.Row="1"
|
||||
ItemsSource="{x:Bind ViewModel.Notes}"
|
||||
ItemClick="OnNoteClicked"
|
||||
IsItemClickEnabled="True">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:NoteSummary">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{x:Bind Filename}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{x:Bind Preview}"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Grid>
|
||||
</Page>
|
||||
```
|
||||
|
||||
`NotePage.xaml.cs`:
|
||||
|
||||
```csharp
|
||||
public sealed partial class NotePage : Page
|
||||
{
|
||||
public NoteViewModel ViewModel { get; } = App.GetService<NoteViewModel>();
|
||||
|
||||
public NotePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override async void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
if (e.Parameter is string filename)
|
||||
await ViewModel.LoadCommand.ExecuteAsync(filename);
|
||||
ViewModel.IsActive = true;
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
{
|
||||
ViewModel.IsActive = false;
|
||||
base.OnNavigatedFrom(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`NotePage.xaml`:
|
||||
|
||||
```xml
|
||||
<Page x:Class="MyApp.Views.NotePage">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBox Header="Filename" Text="{x:Bind ViewModel.Filename, Mode=TwoWay}"/>
|
||||
<TextBox Grid.Row="1"
|
||||
AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Text="{x:Bind ViewModel.Text, Mode=TwoWay}"/>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{x:Bind ViewModel.SaveCommand}"/>
|
||||
<Button Content="Delete" Command="{x:Bind ViewModel.DeleteCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Page>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. A representative unit test
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
public sealed class NoteViewModelTests
|
||||
{
|
||||
private sealed class FakeNotesService : INotesService
|
||||
{
|
||||
public List<(string filename, string text)> Saved { get; } = new();
|
||||
public Task<IReadOnlyList<NoteSummary>> GetAllAsync() => Task.FromResult<IReadOnlyList<NoteSummary>>(Array.Empty<NoteSummary>());
|
||||
public Task<string> LoadAsync(string filename) => Task.FromResult(string.Empty);
|
||||
public Task SaveAsync(string filename, string text)
|
||||
{
|
||||
Saved.Add((filename, text));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task DeleteAsync(string filename) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCommand_persists_and_broadcasts()
|
||||
{
|
||||
var notes = new FakeNotesService();
|
||||
var messenger = new WeakReferenceMessenger();
|
||||
string? receivedFilename = null;
|
||||
messenger.Register<NoteSavedMessage>(new object(), (_, m) => receivedFilename = m.Filename);
|
||||
|
||||
var vm = new NoteViewModel(notes, messenger)
|
||||
{
|
||||
Filename = "hello.txt",
|
||||
Text = "world"
|
||||
};
|
||||
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Single(notes.Saved);
|
||||
Assert.Equal("hello.txt", notes.Saved[0].filename);
|
||||
Assert.Equal("world", notes.Saved[0].text);
|
||||
Assert.Equal("hello.txt", receivedFilename);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to internalize from this sample
|
||||
|
||||
1. **VMs go in a UI-free class library.** The toolkit's only dependency
|
||||
is `netstandard2.0+`, so VMs are testable without a UI host.
|
||||
2. **Constructor injection everywhere.** The composition root knows how
|
||||
to build everything; ViewModels and services receive their
|
||||
dependencies via parameters.
|
||||
3. **`IMessenger` is the cross-VM glue.** `WeakReferenceMessenger.Default`
|
||||
is the right default. The list VM listens via `IRecipient<T>`; the
|
||||
editor VM publishes via `Messenger.Send`.
|
||||
4. **`[NotifyCanExecuteChangedFor]` keeps Save/Delete buttons in sync**
|
||||
with text input — no manual wiring needed.
|
||||
5. **`ObservableRecipient.IsActive`** controls subscription lifetime —
|
||||
set it from `OnNavigatedTo` / `OnNavigatedFrom` (or an equivalent
|
||||
activation hook in your framework).
|
||||
@@ -0,0 +1,254 @@
|
||||
# RelayCommand cookbook
|
||||
|
||||
Recipes for `RelayCommand` / `AsyncRelayCommand` and the `[RelayCommand]`
|
||||
generator. Defaults to the generator-attribute style; manual constructor
|
||||
patterns are listed at the bottom for advanced cases.
|
||||
|
||||
---
|
||||
|
||||
## Sync command
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void IncrementCounter() => Counter++;
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button Command="{x:Bind ViewModel.IncrementCounterCommand}" Content="+1"/>
|
||||
```
|
||||
|
||||
## Sync command with parameter
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void RemoveItem(Item item) => Items.Remove(item);
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button Command="{x:Bind ViewModel.RemoveItemCommand}"
|
||||
CommandParameter="{x:Bind Item}" Content="Remove"/>
|
||||
```
|
||||
|
||||
The generator picks `IRelayCommand<Item>` based on the parameter type.
|
||||
|
||||
## Sync command with `CanExecute`
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitCommand))]
|
||||
private string? text;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private void Submit() => service.Submit(Text!);
|
||||
|
||||
private bool CanSubmit() => !string.IsNullOrWhiteSpace(Text);
|
||||
```
|
||||
|
||||
`[NotifyCanExecuteChangedFor]` raises `CanExecuteChanged` automatically
|
||||
whenever `Text` changes — without it, the button stays disabled even after
|
||||
the user types.
|
||||
|
||||
---
|
||||
|
||||
## Async command
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
Items.Clear();
|
||||
foreach (var item in await service.GetItemsAsync())
|
||||
Items.Add(item);
|
||||
}
|
||||
```
|
||||
|
||||
Bind the UI to `LoadCommand.IsRunning` to show a spinner:
|
||||
|
||||
```xml
|
||||
<ProgressRing IsActive="{x:Bind ViewModel.LoadCommand.IsRunning, Mode=OneWay}"/>
|
||||
```
|
||||
|
||||
## Async command with cancellation
|
||||
|
||||
```csharp
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task DownloadAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await http.GetStreamAsync(url, token);
|
||||
// ...
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — user cancelled.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button Command="{x:Bind ViewModel.DownloadCommand}" Content="Download"/>
|
||||
<Button Command="{x:Bind ViewModel.DownloadCancelCommand}" Content="Cancel"/>
|
||||
```
|
||||
|
||||
`DownloadCancelCommand.CanExecute` is automatically wired to
|
||||
`DownloadCommand.IsRunning`.
|
||||
|
||||
## Async command with concurrency
|
||||
|
||||
```csharp
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task PingAsync(string host)
|
||||
{
|
||||
await pingService.PingAsync(host);
|
||||
}
|
||||
```
|
||||
|
||||
Default (`AllowConcurrentExecutions = false`) reports the command as
|
||||
disabled while a previous execution is pending. Set to `true` for
|
||||
fire-and-forget patterns where overlapping invocations are safe.
|
||||
|
||||
## Async command that surfaces errors to UI
|
||||
|
||||
```csharp
|
||||
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
|
||||
private async Task SyncAsync(CancellationToken token)
|
||||
{
|
||||
await syncService.SyncAsync(token);
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<TextBlock Text="{x:Bind ViewModel.SyncCommand.ExecutionTask.Exception, Mode=OneWay}"/>
|
||||
```
|
||||
|
||||
Without `FlowExceptionsToTaskScheduler = true`, an uncaught exception in
|
||||
`SyncAsync` will crash the app (mirroring sync commands). With it, the
|
||||
exception is surfaced through `ExecutionTask` and bubbles to
|
||||
`TaskScheduler.UnobservedTaskException`.
|
||||
|
||||
## Showing async command status
|
||||
|
||||
```xml
|
||||
<StackPanel>
|
||||
<ProgressRing IsActive="{x:Bind ViewModel.SyncCommand.IsRunning, Mode=OneWay}"/>
|
||||
<TextBlock Text="{x:Bind ViewModel.SyncCommand.ExecutionTask.Status, Mode=OneWay}"/>
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
Useful properties on `IAsyncRelayCommand`:
|
||||
|
||||
| Property | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| `ExecutionTask` | `Task?` | The currently running (or last completed) task |
|
||||
| `IsRunning` | `bool` | `true` while a task is in flight |
|
||||
| `CanBeCanceled` | `bool` | `true` if the wrapped method takes a `CancellationToken` |
|
||||
| `IsCancellationRequested` | `bool` | `true` after `Cancel()` was called for the in-flight task |
|
||||
|
||||
Methods:
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `Cancel()` | Signals the active `CancellationToken` |
|
||||
| `NotifyCanExecuteChanged()` | Re-evaluates `CanExecute` and raises `CanExecuteChanged` |
|
||||
|
||||
---
|
||||
|
||||
## Forwarding attributes to the generated command property
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
[property: JsonIgnore]
|
||||
[property: Description("Saves the current document")]
|
||||
private Task SaveAsync() => repo.SaveAsync(Text!);
|
||||
```
|
||||
|
||||
The generator emits `SaveCommand` with `[JsonIgnore]` and `[Description]`
|
||||
applied — useful when the VM is serialized.
|
||||
|
||||
---
|
||||
|
||||
## Manual `RelayCommand` / `AsyncRelayCommand`
|
||||
|
||||
Reach for the manual constructors when you need:
|
||||
|
||||
- A command composed from multiple methods or dynamically rebuilt
|
||||
- A `CanExecute` predicate built from external observables
|
||||
- An ICommand instance held in a field (rare; the generator's lazy property
|
||||
is enough for almost every case)
|
||||
|
||||
```csharp
|
||||
public sealed class CounterViewModel : ObservableObject
|
||||
{
|
||||
public CounterViewModel()
|
||||
{
|
||||
IncrementCommand = new RelayCommand(() => Counter++);
|
||||
DecrementCommand = new RelayCommand(() => Counter--, () => Counter > 0);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private int counter;
|
||||
|
||||
public IRelayCommand IncrementCommand { get; }
|
||||
public IRelayCommand DecrementCommand { get; }
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
public sealed class DownloadViewModel : ObservableObject
|
||||
{
|
||||
public DownloadViewModel()
|
||||
{
|
||||
DownloadCommand = new AsyncRelayCommand(DownloadAsync, () => CanDownload);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool canDownload = true;
|
||||
|
||||
public IAsyncRelayCommand DownloadCommand { get; }
|
||||
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
CanDownload = false;
|
||||
try { await http.DownloadAsync(); }
|
||||
finally { CanDownload = true; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Trigger `CanExecute` re-evaluation manually with
|
||||
`SomeCommand.NotifyCanExecuteChanged()`.
|
||||
|
||||
---
|
||||
|
||||
## `Task.WhenAll` from a single command
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task SyncAllAsync(CancellationToken token)
|
||||
{
|
||||
var tasks = providers.Select(p => p.SyncAsync(token));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
```
|
||||
|
||||
If you want individual progress tracking per provider, expose one command
|
||||
per provider instead.
|
||||
|
||||
---
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **`async void` instead of `async Task`.** The generator only wraps
|
||||
`Task`-returning methods as `IAsyncRelayCommand`. `async void` becomes a
|
||||
sync `RelayCommand` and exceptions are unobserved.
|
||||
2. **Forgetting `[NotifyCanExecuteChangedFor]`.** The button stays disabled
|
||||
even though `CanX()` would now return `true`.
|
||||
3. **Calling `Cancel()` on a non-cancellable command.** Only commands whose
|
||||
wrapped method accepts a `CancellationToken` honor `Cancel()`.
|
||||
4. **Catching `OperationCanceledException` and rethrowing as a different
|
||||
type.** Loses cancellation semantics; `ExecutionTask.IsCanceled` will be
|
||||
`false`. Let `OperationCanceledException` propagate (or return).
|
||||
5. **Awaiting `IAsyncRelayCommand.ExecuteAsync()` from inside another
|
||||
`[RelayCommand]`.** Prefer calling the underlying method directly to
|
||||
avoid double-wrapping the cancellation/concurrency semantics.
|
||||
@@ -0,0 +1,352 @@
|
||||
# Source generators reference
|
||||
|
||||
Complete attribute reference for `CommunityToolkit.Mvvm` 8.x source
|
||||
generators, with the code each one produces.
|
||||
|
||||
> **Universal rule.** Every type that uses one of these attributes — and
|
||||
> every enclosing type, if nested — must be declared `partial`. The
|
||||
> generators emit a sibling partial class declaration; without `partial`,
|
||||
> the compiler reports `MVVMTK0008` / `MVVMTK0042`.
|
||||
|
||||
---
|
||||
|
||||
## `[ObservableProperty]`
|
||||
|
||||
Generates an observable property from a private field.
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
public partial class SampleViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
}
|
||||
```
|
||||
|
||||
Generated (simplified):
|
||||
|
||||
```csharp
|
||||
public string? Name
|
||||
{
|
||||
get => name;
|
||||
set
|
||||
{
|
||||
if (!EqualityComparer<string?>.Default.Equals(name, value))
|
||||
{
|
||||
string? oldValue = name;
|
||||
OnNameChanging(value);
|
||||
OnNameChanging(oldValue, value);
|
||||
OnPropertyChanging();
|
||||
name = value;
|
||||
OnNameChanged(value);
|
||||
OnNameChanged(oldValue, value);
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnNameChanging(string? value);
|
||||
partial void OnNameChanging(string? oldValue, string? newValue);
|
||||
partial void OnNameChanged(string? value);
|
||||
partial void OnNameChanged(string? oldValue, string? newValue);
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- Field `name` → property `Name`
|
||||
- Field `_name` → property `Name`
|
||||
- Field `m_name` → property `Name`
|
||||
- Field `Name` (PascalCase) → **error** (collides with generated property)
|
||||
|
||||
### Hooks
|
||||
|
||||
Implement any subset of the partial methods. Unimplemented hooks are
|
||||
elided by the compiler — zero runtime cost.
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
private ChildViewModel? selectedItem;
|
||||
|
||||
partial void OnSelectedItemChanging(ChildViewModel? oldValue, ChildViewModel? newValue)
|
||||
{
|
||||
if (oldValue is not null) oldValue.IsSelected = false;
|
||||
if (newValue is not null) newValue.IsSelected = true;
|
||||
}
|
||||
```
|
||||
|
||||
The hook methods are `partial` with no body declaration — you cannot add
|
||||
an explicit accessibility (no `public`/`private`).
|
||||
|
||||
---
|
||||
|
||||
## `[NotifyPropertyChangedFor(nameof(Other))]`
|
||||
|
||||
Raises `PropertyChanged` for additional properties when this field changes.
|
||||
Stack multiple attributes for multiple targets.
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FullName))]
|
||||
[NotifyPropertyChangedFor(nameof(Initials))]
|
||||
private string? firstName;
|
||||
```
|
||||
|
||||
Use it for derived/computed properties:
|
||||
|
||||
```csharp
|
||||
public string FullName => $"{FirstName} {LastName}";
|
||||
public string Initials => $"{FirstName?[0]}{LastName?[0]}";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[NotifyCanExecuteChangedFor(nameof(MyCommand))]`
|
||||
|
||||
Calls `MyCommand.NotifyCanExecuteChanged()` when this field changes. The
|
||||
target must be an `IRelayCommand` (or `IAsyncRelayCommand`) property.
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitCommand))]
|
||||
private string? name;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private Task SaveAsync() => repo.SaveAsync(Name!);
|
||||
|
||||
private bool CanSave() => !string.IsNullOrWhiteSpace(Name);
|
||||
```
|
||||
|
||||
> **`MVVMTK0016`** is raised if the target is not an accessible
|
||||
> `IRelayCommand` property in the same type.
|
||||
|
||||
---
|
||||
|
||||
## `[NotifyDataErrorInfo]`
|
||||
|
||||
Only valid in types that inherit from `ObservableValidator`. Adds a
|
||||
`ValidateProperty(value)` call inside the generated setter, so DataAnnotation
|
||||
validators run on every assignment.
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public partial class RegistrationViewModel : ObservableValidator
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required, MinLength(2), MaxLength(100)]
|
||||
private string? name;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required, EmailAddress]
|
||||
private string? email;
|
||||
}
|
||||
```
|
||||
|
||||
Only attributes that derive from `ValidationAttribute` are forwarded to the
|
||||
generated property. Other attributes are ignored unless you use
|
||||
`[property: ]` (see below).
|
||||
|
||||
---
|
||||
|
||||
## `[NotifyPropertyChangedRecipients]`
|
||||
|
||||
Only valid in types that inherit from `ObservableRecipient`. Adds a
|
||||
`Broadcast(oldValue, newValue)` call after a successful set, sending a
|
||||
`PropertyChangedMessage<T>` to all recipients of the active `IMessenger`.
|
||||
|
||||
```csharp
|
||||
public partial class SelectionViewModel : ObservableRecipient
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedRecipients]
|
||||
private Item? selectedItem;
|
||||
}
|
||||
```
|
||||
|
||||
Subscribers can listen with:
|
||||
|
||||
```csharp
|
||||
WeakReferenceMessenger.Default.Register<SelectionViewModel, PropertyChangedMessage<Item>>(
|
||||
this,
|
||||
static (r, m) =>
|
||||
{
|
||||
if (m.PropertyName == nameof(SelectionViewModel.SelectedItem))
|
||||
r.Handle(m.NewValue);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[RelayCommand]`
|
||||
|
||||
Generates a lazy `RelayCommand` / `AsyncRelayCommand` from an instance
|
||||
method. Exposes it via the `IRelayCommand` / `IAsyncRelayCommand` interface.
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void Refresh() => Items.Reset();
|
||||
```
|
||||
|
||||
```csharp
|
||||
private RelayCommand? refreshCommand;
|
||||
public IRelayCommand RefreshCommand =>
|
||||
refreshCommand ??= new RelayCommand(Refresh);
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- `Refresh` → `RefreshCommand`
|
||||
- `OnRefresh` → `RefreshCommand` (leading `On` stripped)
|
||||
- `LoadAsync` → `LoadCommand` (trailing `Async` stripped)
|
||||
- `OnLoadAsync` → `LoadCommand` (both stripped)
|
||||
|
||||
### Sync with parameter
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void GreetUser(User user) => Console.WriteLine($"Hello {user.Name}");
|
||||
```
|
||||
|
||||
Generates `IRelayCommand<User> GreetUserCommand` (a typed command).
|
||||
|
||||
### Async without cancellation
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task GreetUserAsync()
|
||||
{
|
||||
var user = await users.GetCurrentAsync();
|
||||
Console.WriteLine($"Hello {user.Name}");
|
||||
}
|
||||
```
|
||||
|
||||
Generates `IAsyncRelayCommand GreetUserCommand` backed by
|
||||
`AsyncRelayCommand`.
|
||||
|
||||
### Async with cancellation
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task GreetUserAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await users.GetCurrentAsync(token);
|
||||
Console.WriteLine($"Hello {user.Name}");
|
||||
}
|
||||
catch (OperationCanceledException) { /* expected */ }
|
||||
}
|
||||
```
|
||||
|
||||
The toolkit propagates a `CancellationToken` to the wrapped method. Calling
|
||||
`GreetUserCommand.Cancel()` signals it.
|
||||
|
||||
### `IncludeCancelCommand = true`
|
||||
|
||||
Generates a paired `XxxCancelCommand` whose `CanExecute` is wired to the
|
||||
underlying async command's `IsRunning` state — bind it to a Cancel button:
|
||||
|
||||
```csharp
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task DownloadAsync(CancellationToken token) { /* ... */ }
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button Command="{x:Bind ViewModel.DownloadCommand}" Content="Download"/>
|
||||
<Button Command="{x:Bind ViewModel.DownloadCancelCommand}" Content="Cancel"/>
|
||||
```
|
||||
|
||||
### `CanExecute = nameof(MethodOrProperty)`
|
||||
|
||||
```csharp
|
||||
[RelayCommand(CanExecute = nameof(CanGreetUser))]
|
||||
private void GreetUser(User? user) => Console.WriteLine($"Hello {user!.Name}");
|
||||
|
||||
private bool CanGreetUser(User? user) => user is not null;
|
||||
```
|
||||
|
||||
The `CanExecute` member is invoked initially when the command is bound, and
|
||||
again every time the command's `NotifyCanExecuteChanged` runs (use
|
||||
`[NotifyCanExecuteChangedFor]` to wire that automatically when bound state
|
||||
changes).
|
||||
|
||||
### `AllowConcurrentExecutions = true`
|
||||
|
||||
Default is `false`: while an invocation is pending, the command reports
|
||||
itself as not executable. Setting `true` allows queued/parallel invocations.
|
||||
|
||||
```csharp
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task PingAsync() { /* fire-and-keep-going */ }
|
||||
```
|
||||
|
||||
When the wrapped method takes a `CancellationToken` and concurrent execution
|
||||
is **not** allowed, requesting a new execution while one is pending cancels
|
||||
the prior token first.
|
||||
|
||||
### `FlowExceptionsToTaskScheduler = true`
|
||||
|
||||
Default is await-and-rethrow (exceptions crash the app, mirroring sync
|
||||
commands). Setting `true` routes exceptions through `ExecutionTask` and
|
||||
`TaskScheduler.UnobservedTaskException` instead — useful when the UI binds
|
||||
to `ExecutionTask.Status` to render error states.
|
||||
|
||||
```csharp
|
||||
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
|
||||
private async Task LoadAsync(CancellationToken token) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[property: SomeAttribute(...)]`
|
||||
|
||||
Forwards an attribute onto the generated property (for either
|
||||
`[ObservableProperty]` fields or `[RelayCommand]` methods).
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[property: JsonRequired]
|
||||
[property: JsonPropertyName("name")]
|
||||
private string? username;
|
||||
|
||||
[RelayCommand]
|
||||
[property: JsonIgnore]
|
||||
private void GreetUser(User user) { /* ... */ }
|
||||
```
|
||||
|
||||
Use this for serialization attributes (`[JsonIgnore]`,
|
||||
`[JsonPropertyName]`, `[XmlElement]`), data attributes (`[Display(Name=...)]`),
|
||||
or any other attribute that needs to live on the property/command instead of
|
||||
on the field/method.
|
||||
|
||||
---
|
||||
|
||||
## `[INotifyPropertyChanged]` (class-level)
|
||||
|
||||
Use only when you can't inherit from `ObservableObject` (e.g., the type
|
||||
already inherits from a different base). Generates the
|
||||
`INotifyPropertyChanged` plumbing on the type itself.
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
[INotifyPropertyChanged]
|
||||
public partial class MyControl : UserControl
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? caption;
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `ObservableObject` (or `ObservableValidator` /
|
||||
`ObservableRecipient`) inheritance whenever possible. The class-level
|
||||
attribute exists primarily for inheritance-locked scenarios such as
|
||||
custom controls and platform base types.
|
||||
|
||||
There is also `[ObservableObject]` (class-level) for the same purpose if
|
||||
you want the full `SetProperty<T>` API surface generated onto the type
|
||||
without inheritance.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Troubleshooting
|
||||
|
||||
Common errors, diagnostics, and gotchas with `CommunityToolkit.Mvvm` 8.x.
|
||||
|
||||
---
|
||||
|
||||
## Source-generator diagnostics (`MVVMTK0xxx`)
|
||||
|
||||
The generators emit numbered diagnostics. The most common ones:
|
||||
|
||||
| Code | Meaning | Fix |
|
||||
|------|---------|-----|
|
||||
| `MVVMTK0008` | The containing type (or an enclosing type) is not `partial` | Add `partial` to the class declaration **and** every enclosing type |
|
||||
| `MVVMTK0016` | `[NotifyCanExecuteChangedFor]` target is not an accessible `IRelayCommand` property | Make sure the target is a `[RelayCommand]`-generated command (or a manually declared `IRelayCommand` property) on the same type |
|
||||
| `MVVMTK0017` | `[NotifyDataErrorInfo]` used outside `ObservableValidator` | Inherit from `ObservableValidator` or remove the attribute |
|
||||
| `MVVMTK0018` | `[NotifyPropertyChangedRecipients]` used outside `ObservableRecipient` | Inherit from `ObservableRecipient` or remove the attribute |
|
||||
| `MVVMTK0030` | `[ObservableProperty]` used in a type that does not implement `INotifyPropertyChanged` (and the class-level `[INotifyPropertyChanged]` / `[ObservableObject]` attributes are also missing) | Inherit from `ObservableObject` or apply `[INotifyPropertyChanged]` / `[ObservableObject]` to the type |
|
||||
| `MVVMTK0042` | The `[ObservableProperty]` field belongs to a generic type without proper `partial` declarations | Same fix as `MVVMTK0008` (add `partial`) |
|
||||
|
||||
Search the full table at:
|
||||
<https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/generators/errors/>
|
||||
|
||||
---
|
||||
|
||||
## "Property name collides with field name"
|
||||
|
||||
```text
|
||||
'SampleViewModel' already contains a definition for 'Name'
|
||||
```
|
||||
|
||||
You named the field with PascalCase:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
private string Name; // ❌ collides with generated property
|
||||
```
|
||||
|
||||
Use lowerCamel (or prefixed) instead:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
private string? name; // ✅ generates Name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## "Setter never raises `PropertyChanged`"
|
||||
|
||||
Possible causes:
|
||||
|
||||
1. **Same reference assigned.** The generator uses
|
||||
`EqualityComparer<T>.Default.Equals` to detect changes. For reference
|
||||
types where you mutated the same instance, the comparer returns `true`
|
||||
and notification is skipped. Replace the instance instead of mutating.
|
||||
2. **Property set to identical value.** Same value → no notification by
|
||||
design.
|
||||
3. **Custom comparer needed.** For value types where default equality is
|
||||
wrong, write the property by hand and call
|
||||
`SetProperty(ref field, value, comparer)`.
|
||||
|
||||
---
|
||||
|
||||
## "ContentDialog throws `InvalidOperationException`" (WinUI 3)
|
||||
|
||||
Not a toolkit issue, but commonly hit from `[RelayCommand]` async methods.
|
||||
Set `XamlRoot` before calling `ShowAsync()`. See the
|
||||
`winui3-migration-guide` skill for details.
|
||||
|
||||
---
|
||||
|
||||
## Async `[RelayCommand]` swallows exceptions
|
||||
|
||||
Default behavior: the wrapped task is awaited and the exception is
|
||||
rethrown on the synchronization context. If your method is `async void`,
|
||||
the generator wraps it as a sync `RelayCommand` and exceptions become
|
||||
unobserved. **Always return `Task` from `[RelayCommand]` methods.**
|
||||
|
||||
If the UI binds to `ExecutionTask.Exception` to render errors, opt into
|
||||
`FlowExceptionsToTaskScheduler = true`:
|
||||
|
||||
```csharp
|
||||
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
|
||||
private async Task LoadAsync(CancellationToken token) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cancellation appears to do nothing
|
||||
|
||||
- Ensure the wrapped method declares a `CancellationToken` parameter.
|
||||
- Pass the token down to the awaited APIs (`HttpClient.GetAsync(url, token)`,
|
||||
`Task.Delay(ms, token)`, etc.).
|
||||
- Catch `OperationCanceledException` so the UI doesn't see an error.
|
||||
|
||||
---
|
||||
|
||||
## Messenger handler never fires
|
||||
|
||||
Checklist:
|
||||
|
||||
1. The recipient is registered for the **exact** message type, not a base
|
||||
type. Inheritance is **not** considered.
|
||||
2. The same `IMessenger` instance is used to send and register
|
||||
(`WeakReferenceMessenger.Default` vs an injected per-window messenger).
|
||||
3. The token (channel) matches between sender and receiver.
|
||||
4. With `WeakReferenceMessenger`, the recipient might already have been
|
||||
garbage-collected. Hold a strong reference somewhere (typically the DI
|
||||
container does this for singleton VMs).
|
||||
5. With `ObservableRecipient`, `IsActive` must be `true` — `OnActivated`
|
||||
is what registers the `IRecipient<T>` handlers.
|
||||
|
||||
---
|
||||
|
||||
## `OnActivated` never runs
|
||||
|
||||
`ObservableRecipient.OnActivated` is invoked when `IsActive` flips from
|
||||
`false` to `true`. If you never set `IsActive = true`, no handlers register.
|
||||
Common pattern:
|
||||
|
||||
```csharp
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
ViewModel.IsActive = true;
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
{
|
||||
base.OnNavigatedFrom(e);
|
||||
ViewModel.IsActive = false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory leak with `StrongReferenceMessenger`
|
||||
|
||||
Strong-ref recipients are pinned until you call `Unregister`. Either:
|
||||
|
||||
- Inherit from `ObservableRecipient` (auto-unregisters in `OnDeactivated`).
|
||||
- Switch to `WeakReferenceMessenger.Default`.
|
||||
- Call `messenger.UnregisterAll(this)` in your dispose / tear-down path.
|
||||
|
||||
---
|
||||
|
||||
## "Cannot inherit from `ObservableValidator` and `ObservableRecipient`"
|
||||
|
||||
C# single inheritance — pick one. If you need both:
|
||||
|
||||
- Inherit from `ObservableRecipient` (or `ObservableValidator`).
|
||||
- Inject `IMessenger` (or implement validation) on the side via
|
||||
composition.
|
||||
|
||||
Or use the class-level `[INotifyPropertyChanged]` / `[ObservableObject]`
|
||||
attribute on a custom base type that wraps both pieces.
|
||||
|
||||
---
|
||||
|
||||
## DI container can't construct ViewModel
|
||||
|
||||
Symptom: `InvalidOperationException` mentioning "Unable to resolve service
|
||||
for type 'X' while attempting to activate 'MyViewModel'".
|
||||
|
||||
Causes:
|
||||
|
||||
- Constructor parameter type wasn't registered. Add `services.AddX(...)`.
|
||||
- Multiple ambiguous constructors — the container picks the longest one
|
||||
whose dependencies are all registered. If two constructors qualify, an
|
||||
exception is thrown. Mark one as the canonical constructor or remove the
|
||||
ambiguity.
|
||||
- Scoped service injected into a singleton (in dev mode with scope
|
||||
validation). Either change the lifetime or inject `IServiceScopeFactory`
|
||||
and resolve from a scope.
|
||||
|
||||
---
|
||||
|
||||
## XAML cannot resolve namespace
|
||||
|
||||
```text
|
||||
The type 'local:ContactViewModel' was not found.
|
||||
```
|
||||
|
||||
XAML namespace mappings need the assembly to be referenced and the
|
||||
namespace to match. If the VM lives in a class library, the mapping needs
|
||||
the assembly name:
|
||||
|
||||
```xml
|
||||
xmlns:vm="using:MyApp.Shared.ViewModels;assembly=MyApp.Shared"
|
||||
```
|
||||
|
||||
(WPF syntax differs slightly: `xmlns:vm="clr-namespace:...;assembly=..."`.)
|
||||
|
||||
---
|
||||
|
||||
## "Design-time data shows nothing"
|
||||
|
||||
Design-time XAML editors instantiate the page without your DI container.
|
||||
Either:
|
||||
|
||||
- Provide a parameterless constructor that bootstraps a design-time VM.
|
||||
- Use `d:DataContext="{d:DesignInstance Type=vm:ContactViewModel, IsDesignTimeCreatable=True}"`.
|
||||
- Use a separate design-time view model class with hard-coded sample data.
|
||||
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
- All `MVVMTK0xxx` errors:
|
||||
<https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/generators/errors/>
|
||||
- Source: <https://github.com/CommunityToolkit/dotnet>
|
||||
- Sample app: <https://aka.ms/mvvmtoolkit/samples>
|
||||
@@ -0,0 +1,252 @@
|
||||
# Validation with `ObservableValidator`
|
||||
|
||||
`ObservableValidator` extends `ObservableObject` with `INotifyDataErrorInfo`
|
||||
support, integrating with
|
||||
`System.ComponentModel.DataAnnotations` validation attributes.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
public sealed partial class RegistrationViewModel : ObservableValidator
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required]
|
||||
[MinLength(2), MaxLength(100)]
|
||||
private string? name;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Required, EmailAddress]
|
||||
private string? email;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyDataErrorInfo]
|
||||
[Range(13, 120)]
|
||||
private int age;
|
||||
|
||||
[RelayCommand]
|
||||
private void Submit()
|
||||
{
|
||||
ValidateAllProperties();
|
||||
if (HasErrors) return;
|
||||
// submit...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`[NotifyDataErrorInfo]` makes the generated setter call
|
||||
`ValidateProperty(value)` after each successful set, so validation runs as
|
||||
the user types.
|
||||
|
||||
---
|
||||
|
||||
## Manual `SetProperty` validation
|
||||
|
||||
If you write the property by hand instead of using `[ObservableProperty]`,
|
||||
opt into validation with the `bool validate` parameter:
|
||||
|
||||
```csharp
|
||||
[Required, MinLength(2), MaxLength(100)]
|
||||
public string? Name
|
||||
{
|
||||
get => name;
|
||||
set => SetProperty(ref name, value, validate: true);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `TrySetProperty`
|
||||
|
||||
Sometimes you want to set a property only if validation succeeds:
|
||||
|
||||
```csharp
|
||||
[Required, EmailAddress]
|
||||
public string? Email
|
||||
{
|
||||
get => email;
|
||||
set
|
||||
{
|
||||
if (TrySetProperty(ref email, value, out IReadOnlyCollection<ValidationResult> errors))
|
||||
{
|
||||
// value passed validation; success
|
||||
}
|
||||
else
|
||||
{
|
||||
// inspect errors
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ValidateAllProperties()`
|
||||
|
||||
Forces validation across every public property in the type that has at
|
||||
least one `ValidationAttribute`. Call before submission:
|
||||
|
||||
```csharp
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private void Submit()
|
||||
{
|
||||
ValidateAllProperties();
|
||||
if (HasErrors) return;
|
||||
submitter.Submit(this);
|
||||
}
|
||||
|
||||
private bool CanSubmit() => !HasErrors;
|
||||
```
|
||||
|
||||
Pair with `[NotifyCanExecuteChangedFor]` on the input fields, plus a
|
||||
listener on `ErrorsChanged` (or override `OnErrorsChanged`) to keep the
|
||||
button state in sync as the user types.
|
||||
|
||||
---
|
||||
|
||||
## `ValidateProperty(value, propertyName)`
|
||||
|
||||
Trigger validation manually for one property — useful when validation of
|
||||
property `A` depends on property `B`:
|
||||
|
||||
```csharp
|
||||
[Range(20, 80)]
|
||||
[ObservableProperty]
|
||||
private int b;
|
||||
|
||||
[Range(10, 100)]
|
||||
[GreaterThan(nameof(B))]
|
||||
[ObservableProperty]
|
||||
private int a;
|
||||
|
||||
partial void OnBChanged(int value)
|
||||
{
|
||||
// Re-run A's validation since it depends on B.
|
||||
ValidateProperty(A, nameof(A));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ClearAllErrors()`
|
||||
|
||||
Reset the error state — common after a successful submit or when resetting
|
||||
a form:
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private void Reset()
|
||||
{
|
||||
Name = null;
|
||||
Email = null;
|
||||
Age = 0;
|
||||
ClearAllErrors();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom validation method (`[CustomValidation]`)
|
||||
|
||||
```csharp
|
||||
[Required, MinLength(3)]
|
||||
[CustomValidation(typeof(RegistrationViewModel), nameof(ValidateUsername))]
|
||||
[ObservableProperty]
|
||||
private string? username;
|
||||
|
||||
public static ValidationResult ValidateUsername(string? value, ValidationContext context)
|
||||
{
|
||||
var vm = (RegistrationViewModel)context.ObjectInstance;
|
||||
if (vm.userService.IsTaken(value!))
|
||||
return new ValidationResult("Username is already taken.");
|
||||
return ValidationResult.Success!;
|
||||
}
|
||||
```
|
||||
|
||||
The method must be `static` and accept `(value, ValidationContext)`. Use
|
||||
`context.ObjectInstance` to reach back into the ViewModel.
|
||||
|
||||
---
|
||||
|
||||
## Custom `ValidationAttribute`
|
||||
|
||||
For reusable rules, subclass `ValidationAttribute`:
|
||||
|
||||
```csharp
|
||||
public sealed class GreaterThanAttribute(string otherPropertyName)
|
||||
: ValidationAttribute
|
||||
{
|
||||
public string OtherPropertyName { get; } = otherPropertyName;
|
||||
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
|
||||
{
|
||||
var instance = ctx.ObjectInstance;
|
||||
var other = instance.GetType().GetProperty(OtherPropertyName)?.GetValue(instance);
|
||||
if (((IComparable)value!).CompareTo(other) > 0)
|
||||
return ValidationResult.Success;
|
||||
return new ValidationResult($"Must be greater than {OtherPropertyName}.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply to the property:
|
||||
|
||||
```csharp
|
||||
[Range(10, 100)]
|
||||
[GreaterThan(nameof(B))]
|
||||
[ObservableProperty]
|
||||
private int a;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reading errors in the View
|
||||
|
||||
`ObservableValidator` implements `INotifyDataErrorInfo`. XAML stacks render
|
||||
`ErrorsChanged` automatically when `ValidatesOnNotifyDataErrors=True` (WPF)
|
||||
or via control templates (WinUI 3, MAUI). To inspect errors in code:
|
||||
|
||||
```csharp
|
||||
foreach (ValidationResult result in vm.GetErrors(nameof(vm.Name)))
|
||||
{
|
||||
Console.WriteLine(result.ErrorMessage);
|
||||
}
|
||||
|
||||
// Across all properties
|
||||
foreach (ValidationResult result in vm.GetErrors())
|
||||
{
|
||||
Console.WriteLine(result.ErrorMessage);
|
||||
}
|
||||
|
||||
bool any = vm.HasErrors;
|
||||
```
|
||||
|
||||
Subscribe to changes:
|
||||
|
||||
```csharp
|
||||
vm.ErrorsChanged += (s, e) =>
|
||||
{
|
||||
Debug.WriteLine($"Errors changed for {e.PropertyName}");
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Combine `ValidateAllProperties()` with `[NotifyCanExecuteChangedFor]` so
|
||||
the Submit button reflects validity in real time.
|
||||
- Keep validation rules in the ViewModel (or via custom attributes), not
|
||||
in the model — the model should be a plain DTO.
|
||||
- For network or async validation (e.g., "is username taken?"), use
|
||||
`[CustomValidation]` calling a synchronous wrapper around an async lookup
|
||||
(or perform the async check separately and surface the result via
|
||||
`AddError(propertyName, ...)`-style helpers if you write your own).
|
||||
- `ObservableValidator` cannot also inherit from `ObservableRecipient` —
|
||||
if you need messaging, inject `IMessenger` and call `Send` directly.
|
||||
Reference in New Issue
Block a user