A .NET library which extends Avalonia with commonly used features like ViewServices, DependencyInjection and some Mvvm sugar
Package | Link |
---|---|
RolandK.AvaloniaExtensions | https://www.nuget.org/packages/RolandK.AvaloniaExtensions |
RolandK.AvaloniaExtensions.DependencyInjection | https://www.nuget.org/packages/RolandK.AvaloniaExtensions.DependencyInjection |
RolandK.AvaloniaExtensions.ExceptionHandling | https://www.nuget.org/packages/RolandK.AvaloniaExtensions.ExceptionHandling |
RolandK.AvaloniaExtensions.FluentThemeDetection | (obsolete due to Avalonia 11) |
- ViewServices over the popular Mvvm pattern by not providing an own Mvvm implementation
- Some default ViewServices (FileDialogs, MessageBox)
- Notification on ViewModels when view is attaching and detaching
- DependencyInjection for Avalonia based on Microsft.Extensions.DependencyInjection
- Error dialog for unhandled exceptions
- Global error handling for unhandled exceptions
- MarkupExtensions for primitive values
Here you find samples to the features of RolandK.AvaloniaExtensions. Most of these features work for themselves and are self-contained. They have no dependencies to other features of RolandK.AvaloniaExtensions. As Mvvm framework I use CommunityToolkit.Mvvm in all samples - but you are free to use another one. RolandK.AvaloniaExtensions has no dependencies on any Mvvm library and does not try to be an own implementation.
You can also take a look into the unittest projects. There you find full examples for each provided feature.
Add nuget package RolandK.AvaloniaExtensions.
ViewServices in RolandK.AvaloniaExtensions are interfaces provided by views (Windows, UserControls, etc.). A view attaches itself to a view model using the IAttachableViewModel interface. Therefore, you have to implement this interface on your own view models. The following sample implementation is derived from ObservableObject of CommunityToolkit.Mvvm.
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using RolandK.AvaloniaExtensions.Mvvm;
using RolandK.AvaloniaExtensions.ViewServices.Base;
namespace RolandK.AvaloniaExtensions.TestApp;
public class OwnViewModelBase : ObservableObject, IAttachableViewModel
{
private object? _associatedView;
/// <inheritdoc />
public event EventHandler<CloseWindowRequestEventArgs>? CloseWindowRequest;
/// <inheritdoc />
public event EventHandler<ViewServiceRequestEventArgs>? ViewServiceRequest;
/// <inheritdoc />
public object? AssociatedView
{
get => _associatedView;
set
{
if(_associatedView != value)
{
_associatedView = value;
this.OnAssociatedViewChanged(_associatedView);
}
}
}
protected T? TryGetViewService<T>()
where T : class
{
var requestViewServiceArgs = new ViewServiceRequestEventArgs(typeof(T));
this.ViewServiceRequest?.Invoke(this, requestViewServiceArgs);
return requestViewServiceArgs.ViewService as T;
}
protected T GetViewService<T>()
where T : class
{
var viewService = this.TryGetViewService<T>();
if (viewService == null)
{
throw new InvalidOperationException($"ViewService {typeof(T).FullName} not found!");
}
return viewService;
}
protected void CloseHostWindow(object? dialogResult = null)
{
if (this.CloseWindowRequest == null)
{
throw new InvalidOperationException("Unable to call Close on host window!");
}
this.CloseWindowRequest.Invoke(
this,
new CloseWindowRequestEventArgs(dialogResult));
}
protected void OnAssociatedViewChanged(object? associatedView)
{
}
}
Now you can access ViewServices from within the view model by calling GetViewService or TryGetViewService. The later does not throw an exception, when the ViewService can not be found.
In order for that to work, you also have to use one of the base classes MvvmWindow or MvvmUserControl on the view side. They are responsible for attaching to the view model and detaching again, when the view is closed. Be sure that you also derive from the correct base class in the corresponding code behind.
There is still one problem: The ViewModel (-> DataContext) is also set to all child elements automatically by Avalonia. Because of this, we need some kind of functionality to clearly identify which View belongs to which ViewModel. By default, RolandK.AvaloniaExtensions provides a convention using the AvaloniaExtensionsConventions.IsViewForViewModelFunc delegate. This one checks for the naming, e. g. MainWindowViewModel is the ViewModel for the MainWindow View. If the naming of the ViewModel differs, then you can override the convention or just set the ViewModel type using the ViewFor property of MvvmWindow or MvvmUserControl base classes.
<ext:MvvmWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ext="https://github.com/RolandK.AvaloniaExtensions"
xmlns:local="clr-namespace:RolandK.AvaloniaExtensions.TestApp"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="RolandK.AvaloniaExtensions.TestApp.MainWindow"
ViewFor="{x:Type [YourViewModelTypeHere]}">
</ext:MvvmWindow>
Register own ViewServices using the ViewServices property of MvvmWindow or MvvmUserControl.
The following code snipped is a command implementation within the view model. It uses the ViewServices IOpenFileViewServices and IMessageBoxService. Both of them are provided by default by RolandK.AvaloniaExtensions.
[RelayCommand]
public async Task OpenFileAsync()
{
var srvOpenFile = this.GetViewService<IOpenFileViewService>();
var srvMessageBox = this.GetViewService<IMessageBoxViewService>();
var selectedFile = await srvOpenFile.ShowOpenFileDialogAsync(
Array.Empty<FileDialogFilter>(),
"Open file");
if (string.IsNullOrEmpty(selectedFile)) { return; }
await srvMessageBox.ShowAsync(
"Open file",
$"File {selectedFile} selected", MessageBoxButtons.Ok);
}
RolandK.AvaloniaExtensions provides the following default ViewServices:
- IMessageBoxViewService
- IOpenDirectoryViewService
- IOpenFileViewService
- ISaveFileViewService
As some kind of extension to the provided ViewService feature, the IAttachableViewModel interface can be used to react on attaching / detaching of the view from within the view model. You can use this for example to start and stop a timer in the view model.
The following code snipped shows how to write a OnAssociatedViewChanged method on a view model base class.
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using RolandK.AvaloniaExtensions.Mvvm;
using RolandK.AvaloniaExtensions.ViewServices.Base;
namespace RolandK.AvaloniaExtensions.TestApp;
public class OwnViewModelBase : ObservableObject, IAttachableViewModel
{
private object? _associatedView;
//. ..
/// <inheritdoc />
public object? AssociatedView
{
get => _associatedView;
set
{
if(_associatedView != value)
{
_associatedView = value;
this.OnAssociatedViewChanged(_associatedView);
}
}
}
// ...
protected void OnAssociatedViewChanged(object? associatedView)
{
}
}
Add nuget package RolandK.AvaloniaExtensions.DependencyInjection
Enable DependencyInjection by calling UseDependencyInjection on AppBuilder during startup of your Avalonia application. This method registers the ServiceProvider as a globally available resource on your Application object. You can find the key of the resource within the constant DependencyInjectionConstants.SERVICE_PROVIDER_RESOURCE_KEY.
using RolandK.AvaloniaExtensions.DependencyInjection;
public static class Program
{
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
//...
.UseDependencyInjection(services =>
{
// Services
services.AddSingleton<ITestDataGenerator, BogusTestDataGenerator>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
});
}
Now you can inject ViewModels via the MarkupExtension CreateUsingDependencyInjection in xaml namespace 'https://github.com/RolandK.AvaloniaExtensions'
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ext="https://github.com/RolandK.AvaloniaExtensions"
xmlns:local="clr-namespace:RolandK.AvaloniaExtensions.TestApp"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="RolandK.AvaloniaExtensions.TestApp.MainWindow"
Title="{Binding Path=Title}"
ExtendClientAreaToDecorationsHint="True"
DataContext="{ext:CreateUsingDependencyInjection {x:Type local:MainWindowViewModel}}"
d:DataContext="{x:Static local:MainWindowViewModel.DesignViewModel}">
<!-- ... -->
</Window>
Add nuget package RolandK.AvaloniaExtensions.ErrorHandling
Then use a try-catch block like the following to show a dialog for unhandled exceptions.
try
{
// Some logic
}
catch (Exception ex)
{
await GlobalErrorReporting.ShowGlobalExceptionDialogAsync(ex, this);
}
The method GlobalErrorReporting.ShowGlobalExceptionDialogAsync opens following modal dialog:
One draw back for Avalonia is that is does not offer something similar to Application.DispatcherUnhandledException in WPF. Therefore, you have little change to react anyhow on errors which you never expected to happen. The only way you can handle these kind of exceptions is to wrap the entry point of your application with a global try-catch. In order to show an error dialog in this case I have the following solution.
Add nuget package RolandK.AvaloniaExtensions.ErrorHandling
Now modify the entry point of your application to handle exceptions like in the sample application of this repository.
[STAThread]
public static void Main(string[] args)
{
try
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
GlobalErrorReporting.TryShowBlockingGlobalExceptionDialogInAnotherProcess(
ex,
".<your-technical-app-name-here>",
"<your-technical-app-name-here>.ExceptionViewer");
throw;
}
}
So, what does GlobalErrorReporting.TryShowBlockingGlobalExceptionDialogInAnotherProcess do? The problem here is, that we can't just show a dialog. We don't know in which state the Avalonia application is currently. So, we need something to show the error dialog in a separate process. GlobalErrorReporting.TryShowBlockingGlobalExceptionDialogInAnotherProcess does exactly this. It collects error information, serializes it and sends it to a new instance of the application '.ExceptionViewer'. So, just the application '.ExceptionViewer' is now missing.
In the next step, create a new Avalonia application in your solution that is called '.ExceptionViewer'. There you also reference RolandK.AvaloniaExtensions.ErrorHandling. Then you can remove MainWindow.axaml and modify App.xaml.cs to look like the following:
public partial class App : ExceptionViewerApplication
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
}
The base class ExceptionViewerApplication does the job then. It reads exception information from incoming arguments and shows the error dialog.
One last thing. You also need to add a reference from your application to '.ExceptionViewer'. This ensures that the executable of our exception viewer is copied to the output directory of your application.
RolandK.AvaloniaExtensions provides one MarkupExtension per primitive type in C#. You can use them whenever you need a value to be a specific primitive .NET type.
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ext="https://github.com/RolandK.AvaloniaExtensions"
xmlns:local="clr-namespace:RolandK.AvaloniaExtensions.Tests.MarkupExtensions.PrimitiveValues"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="RolandK.AvaloniaExtensions.Tests.MarkupExtensions.PrimitiveValues.PrimitiveValueUiTestControl">
<UserControl.DataContext>
<local:PrimitiveValueUiTestControlViewModel BoolValueTrue="{ext:Bool true}"
BoolValueFalse="{ext:Bool false}"
ByteValue="{ext:Byte 128}"
CharValue="{ext:Char A}"
DecimalValue="{ext:Decimal 128.12}"
DoubleValue="{ext:Double 128.12}"
FloatValue="{ext:Float 128.12}"
LongValue="{ext:Long 128}"
IntValue="{ext:Int 128}"
NIntValue="{ext:NInt 128}"
SByteValue="{ext:SByte 64}"
ShortValue="{ext:ShortValue 128}"
UIntValue="{ext:UInt 128}"
ULongValue="{ext:ULong 128}"
UShortValue="{ext:UShort 128}" />
</UserControl.DataContext>
</UserControl>