Huskui.Avalonia.Mvvm
1.2.1
dotnet add package Huskui.Avalonia.Mvvm --version 1.2.1
NuGet\Install-Package Huskui.Avalonia.Mvvm -Version 1.2.1
<PackageReference Include="Huskui.Avalonia.Mvvm" Version="1.2.1" />
<PackageVersion Include="Huskui.Avalonia.Mvvm" Version="1.2.1" />
<PackageReference Include="Huskui.Avalonia.Mvvm" />
paket add Huskui.Avalonia.Mvvm --version 1.2.1
#r "nuget: Huskui.Avalonia.Mvvm, 1.2.1"
#:package Huskui.Avalonia.Mvvm@1.2.1
#addin nuget:?package=Huskui.Avalonia.Mvvm&version=1.2.1
#tool nuget:?package=Huskui.Avalonia.Mvvm&version=1.2.1
Huskui.Avalonia.Mvvm
MVVM integration helpers for Huskui.Avalonia.
This package currently provides four mechanisms that work together:
IViewModellifecycle binding for any AvaloniaControl- view activation (
IViewActivator) forFrame-based navigation - activation parameter injection (
IViewContext) - view state attach/persist/restore (
IStatefulViewModel<T>)
The goal is to keep ViewModels UI-agnostic while still giving Views a predictable lifecycle and a simple way to restore page-level state.
Install
<PackageReference Include="Huskui.Avalonia.Mvvm" Version="*" />
Overview
The package is split into independent layers:
ViewModelMixinBindsControl.Loaded,Control.Unloaded, andDataContextChangedtoIViewModel.InitializeAsync/DeinitializeAsync.ViewActivatorBaseCreates the view, resolves the ViewModel from DI, injects navigation parameters throughIViewContext, then attaches the lifecycle/state mixins.ViewStateMixin+IViewStateManagerDetectsIStatefulViewModel<T>, assignsViewState, and releases it when the view detaches.IViewStateStore+IViewStatePersistenceCaches state instances in memory and optionally persists them.
You can use only the parts you need, but most applications will register both activation and state support:
services
.AddViewModelActivation<MyViewActivator>()
.AddViewState(builder => builder.WithStatePersistence<MyViewStatePersistence>());
1. ViewModel Lifecycle
Core interface
namespace Huskui.Avalonia.Mvvm.Models;
public interface IViewModel
{
Task InitializeAsync(CancellationToken cancellationToken);
Task DeinitializeAsync();
}
Recommended base class
using CommunityToolkit.Mvvm.ComponentModel;
using Huskui.Avalonia.Mvvm.Models;
public abstract class ViewModelBase : ObservableObject, IViewModel
{
public virtual Task InitializeAsync(CancellationToken cancellationToken) =>
OnInitializeAsync(cancellationToken);
public virtual Task DeinitializeAsync() =>
OnDeinitializeAsync();
protected virtual Task OnInitializeAsync(CancellationToken cancellationToken) =>
Task.CompletedTask;
protected virtual Task OnDeinitializeAsync() =>
Task.CompletedTask;
}
Attaching lifecycle behavior
using Huskui.Avalonia.Mvvm.Mixins;
var page = new MyUserControl
{
DataContext = new MyViewModel()
};
ViewModelMixin.Attach(page);
When attached, the mixin does the following:
- On
Loaded, ifDataContextimplementsIViewModel, callsInitializeAsync. - On
Unloaded, cancels the active initialization token and callsDeinitializeAsync. - On
DataContextChanged, deinitializes the old ViewModel and initializes the new one when necessary. - Applies pseudo-classes to the control:
:loading,:finished,:failed
Styling loading states
<Style Selector="local|MyPage:loading">
<Setter Property="Opacity" Value="0.6" />
</Style>
<Style Selector="local|MyPage:failed">
<Setter Property="Background" Value="IndianRed" />
</Style>
Lifecycle notes
InitializeAsyncreceives a token that is cancelled when the control unloads or when theDataContextswitches away from the current ViewModel.DeinitializeAsyncis called best-effort. Exceptions are swallowed by the mixin.- The mixin serializes transitions with an internal gate, so overlapping load/unload/data-context events do not run lifecycle methods concurrently.
Design.IsDesignModeshort-circuits initialization.Attachis idempotent for the same control.
Important usage notes
- Treat
InitializeAsyncas re-entrant across the lifetime of the same ViewModel instance. A control can be loaded, unloaded, then loaded again. - Respect the cancellation token in long-running work. If initialization ignores cancellation, stale results can still complete after navigation.
- Do not assume
:finishedmeans the page is still current. It only means the latest initialization finished without cancellation or exception.
2. View Activation
If you use Huskui.Avalonia.Controls.Frame, the recommended setup is an IViewActivator.
Registration
services.AddViewModelActivation<MyViewActivator>();
This registers:
IViewActivatorIViewContextAccessorIViewContextIViewContext<T>
Core interface
namespace Huskui.Avalonia.Mvvm.Activation;
public interface IViewActivator
{
object? Activate(Type viewType, object? parameter = null);
}
Base activator
using Avalonia.Controls;
using Huskui.Avalonia.Mvvm.Activation;
using Huskui.Avalonia.Mvvm.States;
public sealed class MyViewActivator(IServiceProvider provider, IViewStateManager stateManager)
: ViewActivatorBase(provider, stateManager)
{
protected override Type FindViewModelType(Type view)
{
return Type.GetType(view.FullName!.Replace("View", "ViewModel"))!;
}
}
ViewActivatorBase does all of the mechanical work:
- Creates the view.
- Creates a DI scope.
- Stores the navigation parameter in
IViewContextAccessor. - Resolves or creates the ViewModel from DI.
- Assigns
view.DataContext. - Attaches
ViewModelMixinandViewStateMixin.
That attach behavior is the default behavior of ViewActivatorBase.Activate(...).
If you do not want the base activator to attach one or both mixins, override Activate yourself:
public sealed class MyViewActivator(IServiceProvider provider, IViewStateManager stateManager)
: ViewActivatorBase(provider, stateManager)
{
public override object? Activate(Type viewType, object? parameter = null)
{
var view = (Control?)Activator.CreateInstance(viewType);
if (view is null)
{
return null;
}
using var scope = provider.CreateScope();
var accessor = scope.ServiceProvider.GetRequiredService<IViewContextAccessor>();
accessor.Parameter = parameter;
var viewModelType = FindViewModelType(viewType);
var viewModel = ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, viewModelType);
view.DataContext = viewModel;
// Attach only what you want.
ViewModelMixin.Attach(view);
// ViewStateMixin.Attach(view, stateManager);
return view;
}
protected override Type FindViewModelType(Type view) =>
Type.GetType(view.FullName!.Replace("View", "ViewModel"))!;
}
Installing the activator on a Frame
using Huskui.Avalonia.Mvvm.Mixins;
FrameActivationMixin.Install(frame, serviceProvider.GetRequiredService<IViewActivator>());
This is equivalent to:
frame.PageActivator = activator.Activate;
Activation notes
ViewActivatorBaseonly supports views derived fromAvalonia.Controls.Control.FindViewModelTypeis application-defined. Convention-based mapping is common, but not required.- The base
Activateimplementation attaches bothViewModelMixinandViewStateMixinautomatically. - If you manually create views instead of using
IViewActivator, you must manually attachViewModelMixinand, if needed,ViewStateMixin. - If you need different attach behavior, override
Activateinstead of using the base implementation as-is. ViewActivatorBasecreates a temporary DI scope during activation.IViewContextis designed for this flow. Be careful with additional scoped services whose lifetime must outlive activation.
3. Passing Navigation Parameters
Navigation parameters are exposed through IViewContext and IViewContext<T>.
Interfaces
public interface IViewContext
{
object? Parameter { get; }
bool HasParameter { get; }
T? GetParameter<T>() where T : class;
bool TryGetParameter<T>(out T? parameter) where T : class;
T GetRequiredParameter<T>() where T : class;
}
public interface IViewContext<out T> where T : class
{
T? Parameter { get; }
}
Example
public record SearchArguments(string? Query, string? Label);
public sealed class SearchViewModel(
IViewContext<SearchArguments> context,
RepositoryService repositoryService)
: ViewModelBase
{
public string QueryText { get; } = context.Parameter?.Query ?? string.Empty;
}
Or use the untyped API when the parameter type is not fixed:
public sealed class ErrorViewModel(IViewContext context) : ViewModelBase
{
public Exception Exception { get; } = context.GetRequiredParameter<Exception>();
}
Parameter notes
IViewContext<T>is ideal when the page always expects one parameter type.GetRequiredParameter<T>()throws if the parameter is missing or of the wrong type.Parameter == nullalso meansHasParameter == false.- The parameter is only supplied through activation. If you instantiate the ViewModel manually, DI will not magically invent a context value.
4. View State
View state is for page-level UI state that should survive view recreation, such as:
- selected tab
- filter values
- search text
- scroll-related data you store explicitly
- temporary wizard progress
It is not a replacement for domain state, repositories, or long-lived application services.
Minimal stateful ViewModel
using Huskui.Avalonia.Mvvm.States;
public sealed partial class SearchViewModel : ViewModelBase, IStatefulViewModel<SearchViewModel.State>
{
public sealed class State
{
public string Query { get; set; } = string.Empty;
public int SelectedTabIndex { get; set; }
}
public State? ViewState { get; set; }
}
When the view loads and ViewStateMixin is attached:
IViewStateManager.TryAttachchecks whether the ViewModel implementsIStatefulViewModel<T>.- A state key is generated.
IViewStateStore.GetOrCreateloads persisted state or creates a new one.- The
ViewStateproperty is assigned. - When the view unloads or the
DataContextchanges away, the manager detaches and releases the state.
Registration
services.AddViewState();
By default this uses:
ReflectionViewStateManagerDefaultViewStateFactoryNullStatePersistenceDefaultViewStateStore
With custom persistence:
services.AddViewState(builder =>
{
builder.WithStatePersistence<MyViewStatePersistence>();
});
With full customization:
services.AddViewState(builder =>
{
builder.WithStateManager<MyViewStateManager>();
builder.WithKeyFactory<MyViewStateKeyFactory>();
builder.WithStatePersistence<MyViewStatePersistence>();
});
Partitioning state with IViewStateKeyProvider
By default, the key factory uses the ViewModel type as the state identity.
That means all instances of the same ViewModel type resolve to the same state key unless you provide an additional partition key.
Use IViewStateKeyProvider when the same ViewModel type can represent multiple logical pages:
public sealed partial class InstanceViewModel
: ViewModelBase,
IStatefulViewModel<InstanceViewModel.StateView>,
IViewStateKeyProvider
{
public StateView? ViewState { get; set; }
public string ViewStateKey => instanceId;
}
This prevents different instances from accidentally sharing one state object.
Persistence interface
public interface IViewStatePersistence
{
void Save(string key, Type stateType, object value);
object? Load(string key, Type stateType);
}
Example:
public sealed class MyViewStatePersistence(PersistenceService persistenceService)
: IViewStatePersistence
{
public void Save(string key, Type stateType, object value) =>
persistenceService.SetViewState(key, value);
public object? Load(string key, Type stateType) =>
persistenceService.GetViewState(key, stateType);
}
State persistence behavior
The default store is reference-counted per key.
- The first attach for a key loads or creates the state object.
- Additional attaches for the same key reuse the exact same in-memory object.
Saveis called when the last attached owner releases that key.IViewStateStore.Flush()forcesSavefor all still-cached states and clears the in-memory store.
This has several consequences:
- If two live views use the same state key, they share the same
ViewStateinstance. - If you do not want sharing, provide a more specific
ViewStateKeyor custom key factory. - Changes made to
ViewStateare not automatically persisted on every property assignment. - Persistence usually happens on detach or store flush, not immediately.
Shutdown and manual flushing
Applications that use state persistence should flush before shutdown:
serviceProvider.GetRequiredService<IViewStateStore>().Flush();
This is especially important when a view is still attached at application exit, because its state may never reach the final Release call.
Very important note for custom persistence
IViewStateStore.Flush() exists to drain the default store's in-memory cache.
The default store keeps attached states in memory and normally calls IViewStatePersistence.Save(...) only when the last attached owner releases a state key.
If the application shuts down before some attached views unload, those states may never reach the final Release(...) call.
Calling IViewStateStore.Flush() forces the store to push every still-cached state into IViewStatePersistence.Save(...) and then clears the store cache.
So the main reason to call it is not "flush the persistence layer", but "make sure the store does not lose still-attached state during shutdown".
If your custom IViewStatePersistence also has its own buffering or delayed-write mechanism, that is a separate concern. In that case, you may still need to call your persistence layer's own flush/commit API, but that behavior is outside the contract of IViewStateStore.Flush().
State notes and pitfalls
- The default store creates a new state instance with
Activator.CreateInstance(stateType)whenLoad(...)returnsnull. Your state type should therefore be instantiable, typically with a public parameterless constructor. ViewStateis assigned by reflection through theIStatefulViewModel<T>interface. Keep the property writable.NullStatePersistencemeans restore/save is effectively disabled. You still get an in-memory state object during attachment, but nothing is persisted across releases.- Put only serializable and restore-worthy UI data into
ViewState. Do not store services, controls, disposable resources, or large graphs tied to live runtime state.
5. End-to-End Example
// DI
services
.AddViewModelActivation<AppViewActivator>()
.AddViewState(builder => builder.WithStatePersistence<AppViewStatePersistence>());
// ViewModel
public sealed partial class SearchViewModel(
IViewContext<SearchArgs> context,
SearchService searchService)
: ViewModelBase,
IStatefulViewModel<SearchViewModel.State>
{
public sealed class State
{
public string Query { get; set; } = string.Empty;
}
public State? ViewState { get; set; }
protected override Task OnInitializeAsync(CancellationToken token)
{
if (context.Parameter is { Query: { } query } && ViewState is not null)
{
ViewState.Query = query;
}
return Task.CompletedTask;
}
}
// Window / page host
FrameActivationMixin.Install(frame, serviceProvider.GetRequiredService<IViewActivator>());
// Shutdown
serviceProvider.GetRequiredService<IViewStateStore>().Flush();
6. Migration From The Old IPageModel Pattern
The package replaces the older pattern where lifecycle was owned by Huskui.Avalonia.Controls.Page and ViewModels implemented IPageModel directly.
| Old | New |
|---|---|
IPageModel in UI package |
IViewModel in MVVM package |
mutable PageToken property |
CancellationToken parameter |
lifecycle hardcoded in Page |
lifecycle attachable to any Control |
| parameter passing by ad-hoc manual wiring | IViewContext |
| no built-in page state restore model | IStatefulViewModel<T> + state store |
The new approach is more composable, testable, and reusable across different Huskui controls.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Avalonia (>= 12.0.2)
- Huskui.Avalonia (>= 1.2.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
-
net8.0
- Avalonia (>= 12.0.2)
- Huskui.Avalonia (>= 1.2.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
# Changelog
All notable changes to this project will be documented in this file.
## [unreleased]
### ⚙️ Miscellaneous Tasks
- Migrate to per-package conventional commit auto-versioning
<!-- generated by git-cliff -->