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,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