Skip to content

RolandKoenig/RolandK.AvaloniaExtensions

Repository files navigation

RolandK.AvaloniaExtensions

Common Information

A .NET library which extends Avalonia with commonly used features like ViewServices, DependencyInjection and some Mvvm sugar

Build

Continuous integration

Nuget

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)

Feature overview

Samples

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.

ViewServices over the popular Mvvm pattern

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. Attention: You also have to set the 'ViewFor' property on MvvmWindow or MvvmUserControl. The reason behind this is that DataContext is also set on child elements automatically. If one of these would also derive from MvvmWindow oder MvvmUserControl, these one would attach to your ViewModel too.

<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);
}

Some default ViewServices

RolandK.AvaloniaExtensions provides the following default ViewServices:

  • IMessageBoxViewService
  • IOpenDirectoryViewService
  • IOpenFileViewService
  • ISaveFileViewService

Notification on ViewModels when view is attaching and detaching

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)
    {
        
    }
}

DependencyInjection for Avalonia based on Microsft.Extensions.DependencyInjection

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>

Error dialog for unhandled exceptions

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: Unhandled exception dialog

Global error handling for unhandled exceptions

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.

About

A .NET library which extends Avalonia with commonly used features like ViewServices, DependencyInjection and some Mvvm sugar

Topics

Resources

License

Stars

Watchers

Forks