![]() |
VOOZH | about |
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" />Directory.Packages.props
<PackageReference Include="Huskui.Avalonia.Mvvm" />Project file
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.1Install as a Cake Addin
#tool nuget:?package=Huskui.Avalonia.Mvvm&version=1.2.1Install as a Cake Tool
MVVM integration helpers for Huskui.Avalonia.
This package currently provides four mechanisms that work together:
IViewModel lifecycle binding for any Avalonia ControlIViewActivator) for Frame-based navigationIViewContext)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.
<PackageReference Include="Huskui.Avalonia.Mvvm" Version="*" />
The package is split into independent layers:
ViewModelMixin
Binds Control.Loaded, Control.Unloaded, and DataContextChanged to IViewModel.InitializeAsync / DeinitializeAsync.ViewActivatorBase
Creates the view, resolves the ViewModel from DI, injects navigation parameters through IViewContext, then attaches the lifecycle/state mixins.ViewStateMixin + IViewStateManager
Detects IStatefulViewModel<T>, assigns ViewState, and releases it when the view detaches.IViewStateStore + IViewStatePersistence
Caches 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>());
namespace Huskui.Avalonia.Mvvm.Models;
public interface IViewModel
{
Task InitializeAsync(CancellationToken cancellationToken);
Task DeinitializeAsync();
}
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;
}
using Huskui.Avalonia.Mvvm.Mixins;
var page = new MyUserControl
{
DataContext = new MyViewModel()
};
ViewModelMixin.Attach(page);
When attached, the mixin does the following:
Loaded, if DataContext implements IViewModel, calls InitializeAsync.Unloaded, cancels the active initialization token and calls DeinitializeAsync.DataContextChanged, deinitializes the old ViewModel and initializes the new one when necessary.:loading, :finished, :failed<Style Selector="local|MyPage:loading">
<Setter Property="Opacity" Value="0.6" />
</Style>
<Style Selector="local|MyPage:failed">
<Setter Property="Background" Value="IndianRed" />
</Style>
InitializeAsync receives a token that is cancelled when the control unloads or when the DataContext switches away from the current ViewModel.DeinitializeAsync is called best-effort. Exceptions are swallowed by the mixin.Design.IsDesignMode short-circuits initialization.Attach is idempotent for the same control.InitializeAsync as re-entrant across the lifetime of the same ViewModel instance. A control can be loaded, unloaded, then loaded again.:finished means the page is still current. It only means the latest initialization finished without cancellation or exception.If you use Huskui.Avalonia.Controls.Frame, the recommended setup is an IViewActivator.
services.AddViewModelActivation<MyViewActivator>();
This registers:
IViewActivatorIViewContextAccessorIViewContextIViewContext<T>namespace Huskui.Avalonia.Mvvm.Activation;
public interface IViewActivator
{
object? Activate(Type viewType, object? parameter = null);
}
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:
IViewContextAccessor.view.DataContext.ViewModelMixin and ViewStateMixin.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"))!;
}
Frameusing Huskui.Avalonia.Mvvm.Mixins;
FrameActivationMixin.Install(frame, serviceProvider.GetRequiredService<IViewActivator>());
This is equivalent to:
frame.PageActivator = activator.Activate;
ViewActivatorBase only supports views derived from Avalonia.Controls.Control.FindViewModelType is application-defined. Convention-based mapping is common, but not required.Activate implementation attaches both ViewModelMixin and ViewStateMixin automatically.IViewActivator, you must manually attach ViewModelMixin and, if needed, ViewStateMixin.Activate instead of using the base implementation as-is.ViewActivatorBase creates a temporary DI scope during activation. IViewContext is designed for this flow. Be careful with additional scoped services whose lifetime must outlive activation.Navigation parameters are exposed through IViewContext and IViewContext<T>.
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; }
}
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>();
}
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 == null also means HasParameter == false.View state is for page-level UI state that should survive view recreation, such as:
It is not a replacement for domain state, repositories, or long-lived application services.
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.TryAttach checks whether the ViewModel implements IStatefulViewModel<T>.IViewStateStore.GetOrCreate loads persisted state or creates a new one.ViewState property is assigned.DataContext changes away, the manager detaches and releases the state.services.AddViewState();
By default this uses:
ReflectionViewStateManagerDefaultViewStateFactoryNullStatePersistenceDefaultViewStateStoreWith custom persistence:
services.AddViewState(builder =>
{
builder.WithStatePersistence<MyViewStatePersistence>();
});
With full customization:
services.AddViewState(builder =>
{
builder.WithStateManager<MyViewStateManager>();
builder.WithKeyFactory<MyViewStateKeyFactory>();
builder.WithStatePersistence<MyViewStatePersistence>();
});
IViewStateKeyProviderBy 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.
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);
}
The default store is reference-counted per key.
Save is called when the last attached owner releases that key.IViewStateStore.Flush() forces Save for all still-cached states and clears the in-memory store.This has several consequences:
ViewState instance.ViewStateKey or custom key factory.ViewState are not automatically persisted on every property assignment.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.
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().
Activator.CreateInstance(stateType) when Load(...) returns null. Your state type should therefore be instantiable, typically with a public parameterless constructor.ViewState is assigned by reflection through the IStatefulViewModel<T> interface. Keep the property writable.NullStatePersistence means restore/save is effectively disabled. You still get an in-memory state object during attachment, but nothing is persisted across releases.ViewState. Do not store services, controls, disposable resources, or large graphs tied to live runtime state.// 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();
IPageModel PatternThe 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 net8.0 is compatible. net8.0-android net8.0-android was computed. net8.0-browser net8.0-browser was computed. net8.0-ios net8.0-ios was computed. net8.0-maccatalyst net8.0-maccatalyst was computed. net8.0-macos net8.0-macos was computed. net8.0-tvos net8.0-tvos was computed. net8.0-windows net8.0-windows was computed. net9.0 net9.0 was computed. net9.0-android net9.0-android was computed. net9.0-browser net9.0-browser was computed. net9.0-ios net9.0-ios was computed. net9.0-maccatalyst net9.0-maccatalyst was computed. net9.0-macos net9.0-macos was computed. net9.0-tvos net9.0-tvos was computed. net9.0-windows net9.0-windows was computed. net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
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 -->