Click here to Skip to main content
15,565,020 members
Articles / Programming Languages / C#
Article
Posted 27 Nov 2022

Stats

9.6K views
15 bookmarked

Embedding Native (Windows and Linux) Views/Controls/Applications into Avalonia Applications in Easy Samples

Rate me:
Please Sign up or sign in to vote.
5.00/5 (11 votes)
4 Dec 2022MIT25 min read
This article describes embedding native Windows and Linux control into an Avalonia application.
Embedding native Windows and Linux control into an Avalonia application is important if one needs to port a native application to multiplatform Avalonia or if one does not have some complex Avalonia controls that had been built natively. I also present the best architecture for using native views within Avalonia via an IoC container and dynamic loading. Finally, the article describes how to make a native window run by a different process into a visual part of an Avalonia application.

Introduction

What is Avalonia?

Avalonia is a great new open source package which closely resembles WPF but, unlike WPF or UWP, works on most platforms - Windows, MacOS and various flavors of Linux and is in many respects, more powerful than WPF.

To learn more about Avalonia, please check the documentation on Avalonia web site and also check my other Avalonia articles on the codeproject.com starting with Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks.

What is Embedding Native Views/Controls/Applications into Avalonia and Why Do We Need It?

If Avalonia application for some reason lacks some complex Visuals (Controls or Views) perhaps custom to a specific company while those visuals are available, say on Windows as WPF controls, one can embed those WPF visuals into an Avalonia application for running on Windows. Of course, they'll work only for the platforms for which such visuals are available. On other platforms without visual implementations, the developers have an option to e.g. provide a message something like "The view is not available yet for Linux."

The following scenarios require hosting native Visuals by Avalonia:

  1. When an existing single platform application (e.g., WPF application) is moved to multiplatform  Avalonia gradually.

    In such case, one does not have to wait for every view and every control to be ported to Avalonia in order to present the results to the client. You can start by porting Avalonia shell and some most important views and the rest of the views can be ported gradually one by one. The views that have not been ported yet will only work on windows and on the rest of the platforms, they can display e.g. a "Work in Progress" message.

  2. Sometimes, the views have to be native because making them multiplatform would take too much time. In that case, the hosting techniques allow displaying a proper native view for each platform, e.g., running the application on Windows will display a WPF view while running it on Linux would display a similar native Linux view.

  3. Amazingly enough, Avalonia can host native Windows created by other processes, for example, if one runs several WPF processes, each one of which controls a single WPF window, we can display all those windows within a single Avalonia application. In a sense, Avalonia can unite multiple native applications visually as a single Avalonia application. My last sample will show how to achieve that.

Platforms used for the Samples in this Article

Because of WSL2, it became very easy to test and debug Linux on Windows 10 and 11. Correspondingly, most of the samples here are built and tested on Windows 11 and Linux (Ubuntu 20.04). Unfortunately, I do not have the ability to easily test my code on Mac, so this article does not contain any Mac samples.

To review running Avalonia applications using WSL, please take a look at Running and Debugging Multiplatform .NET (.NET Core, .NET5 and .NET6) GUI and Console Applications on Windows Subsystem for Linux (WSL) article.

Avalonia, .NET and Visual Studio Versions used for the Samples

For this article, I used Avalonia 11 preview 4, .NET 6.0 and Visual Studio 17.4.0 Preview 5.0. Hopefully soon, Avalonia will become a full version 11 and then, time permitting, I'll port the samples to it.

The main reason I used Avalonia 11 preview 4 is because there are some profound changes between that branch and the latest stable branch 0.10.18 and I want the samples to be easy to convert to the coming Avalonia 11 version.

A Problem I Encountered with Visual Studio 17.4.0 Preview 5.0

Since we are dealing with multiple targets (Windows and Linux), I had to modify the main project file from single to multiple targets and have some projects and package dependencies conditional on the current target. Such modifications to the file were not always taking effect immediately - but sometimes, I had to restart Visual Studio. Please keep it in mind if you are reworking the samples from this article.

Article Organization

  1. First, I show simple samples of Avalonia window hosting Windows and Linux simple native views.
  2. Then, I provide some architectural advice on arranging the code into separate projects, re-using the common functionality via multiplatform View Models, and using an IoC container to make the main project's code almost platform independent.
  3. Finally, I provide a sample showing how to embed a native WPF window running within its own process into an Avalonia application. At this point, I do not have a similar sample for Linux, but plan to add it sometime in the future.

The Samples

Sample Location

All the samples are located under NP.Avalonia.Demos/HostingDemos folder within NP.Avalonia.Demos repository.

Simple Windows and Linux Samples

Simple WinForms Sample

Running the Sample

The first sample shows how to embed a WinForm into Avalonia. The sample is located under

HostingWinFormsDemo/HostingWinFormsDemo/HostingWinFormsDemo.sln

solution.

Open the solution, make the main project HostingWinFormsDemo to be the start-up project, compile and run it. Here is what you'll see:

Image 1

Every time you click the ClickMe button, the number of clicks increases above the button.

Note that the embedded winform occupies only vertical left half of the window. This is to show that it is up to the developers where to place it and how much space to assign to it - it does not have to occupy the whole window. In fact, two or more parts of an Avalonia window can be occupied by different native embedded controls.

Source Code

Take a look at MyWinFormsControl project which defines the WinForms control. It consists of MyControl class that defines the visual control and ClickCounterViewModel class that defines the non-visual view model. (Yes, we are using the View-ViewModel pattern even when coding WinForms).

ClickCounterViewModel class contains two properties:

  • NumberClicks - specifying the number of times that the button had been clicked
  • NumberClicksStr - the resulting string to display

Both properties are notifiable, meaning they are firing INotifyPropertyChanged.PropertyChanged property when change.

There is also a very simple method:

C#
public void IncreaseNumberClicks()
{
    NumberClicks++;
}  

which increments the number of clicks.

The MyControl specifies a button MyButton and a label ClickCounter placed above the button.

There is also an instance of the ClickCounterViewModel object defined within the control:

C#
public partial class MyControl: UserControl
{
    // the view model.
    ClickCounterViewModel _viewModel = new ClickCounterViewModel();
    ...
}

Here is the full code of the simple control with comments:

C#
public partial class MyControl: UserControl
{
    // the view model
    ClickCounterViewModel _viewModel = new ClickCounterViewModel();

    public MyControl()
    {
        InitializeComponent();

        // call _viewModel.IncreaseNumberClicks();
        // on a button click
        MyButton.Click += MyButton_Click!;
            
        // set the initial value for the label
        SetLabel();

        // trigger the label change on NumberClicks change within the view model
        _viewModel.PropertyChanged += _viewModel_PropertyChanged!;
    }

    // calls SetLabel (to set the Label) when NumberClickStr property changes 
    // on the view model
    private void _viewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(ClickCounterViewModel.NumberClicksStr))
        {
            SetLabel();
        }
    }

    // sets the ClickCounter label's text from the NumberClicksStr property
    private void SetLabel()
    {
        this.ClickCounter.Text = _viewModel.NumberClicksStr;
    }

    // button click handler that calls IncreaseNumberClicks on the view model
    private void MyButton_Click(object sender, EventArgs e)
    {
        _viewModel.IncreaseNumberClicks();
    }
}  

Now take a look at the main project HostingWinFormsDemo. The most interesting class here is EmbedSample:

C#
public class EmbedSample : NativeControlHost
{
    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // on Windows, return the win32 handle to MyControl packed
            // as PlatformHandle object
            MyControl myControl = new MyControl();

            return new PlatformHandle(myControl.Handle, "Hndl");
        }

        // otherwise, return default
        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {

            WinApi.DestroyWindow(control.Handle);

            return;
        }

        base.DestroyNativeControlCore(control);
    }
}  

EmbedSample is an Avalonia control, that derives from NativeControlHost and overrides its CreateNativeControlCore(...) method to create the native control and return the native handle to it (in our case, it is win32 handle to the native win-forms control).

It also contains an override of method DestroyNativeControlCore(...) to be called to clear the native handle (in our case, win32 handle) when the control is destroyed.

The EmbedSample control is connected to the window's visual tree by the code within MainWindow's constructor:

C#
public MainWindow()
{
    InitializeComponent();

    EmbedSample embedSample = new EmbedSample();

    embedSample.HorizontalAlignment = HorizontalAlignment.Stretch;
    embedSample.VerticalAlignment = VerticalAlignment.Stretch;

    // connect the EmbedSample
    MyContentControl.Content = new EmbedSample();
}  

Note that we set vertical and horizontal alignments of embedSample control to Stretch, because otherwise the control will not fill the extra space when the window is resized.

The Avalonia XAML code is located within MainWindow.xaml file:

XAML
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="HostingWindowsProcessDemo.MainWindow"
        Width="800"
        Height="300"
        Title="HostingWindowsProcessDemo">
    <Grid ColumnDefinitions="*,*">
         <ContentControl x:Name="WpfAppPlacementControl"/>
    </Grid>
</Window>  

ContentControl will occupy the left half of the Window's Grid panel.

There is also static unsafe class WinApi that imports the win32 DestroyWindow(...) method into the C# making it available for the rest of the functionality:

C#
public static unsafe class WinApi
{
    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool DestroyWindow(IntPtr hwnd);
}  

This method WinApi.DestroyWindow(IntPtr hwnd) is used within EmbedSample.DestronNativeControlCore(...) override to clean up the native handle:

C#
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // destroy win32 window
        WinApi.DestroyWindow(control.Handle);

        return;
    }

    base.DestroyNativeControlCore(control);
}  

Take a look at the XML code of the main project file HostingWinFormsDemo.csproj. There are several important points to remember about it:

  • Property TargetFramework of the project is set to net6.0-windows (not to multiplatform NET6.0). This means that the resulting code will only work for windows.
  • There is a UseWindowsForms property set to true: <UseWindowsForms>true</UseWindowsForms>. This will automatically add WinForms libraries to the project.
  • Property AllowUnsafeBlocks is also set to true: <AllowUnsafeBlocks>true</AllowUnsafeBlocks>. This will allow unsafe class WinApi that imports the windows code for use within the project.

Finally, notice the app.manifest file. It has an important line:

XML
<!-- Windows 10 and 11-->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />

Without this line uncommented, the embedding will not work on Windows 10 and 11.

Important Notes on Architecture

In order to simplify the explanations related directly to embedding the native code, the sample above was built to contain some well known architectural problems described in this subsection.

For a sample close to perfect from the architecture point of view, please take a look at the Multi Target Sample for Windows and Linux with Architecture close to Optimal section below.

Note that for the sake of simplicity and clarity of the example, we placed the View Model class ClickCounterViewModel into the same project as the WinForms control. In general, when working on a real project, it should be avoided. All the View Models should be placed in their own purely non-visual multiplatform .NET projects. This will avoid multiple problems of mixing visual and non-visual code and also will allow us to e.g. re-use the same view models for different platforms.

Another important thing is that the main project HostingWinFormsDemo has a single target framework net6.0-windows (so that it will only run on Windows). When we use native controls on various platforms, the main project is usually written to have multiple target frameworks - instead of <TargetFramework>, <TagetFrameworks> element is used within the project file, e.g.

XML
<TargetFrameworks>net6.0;net6.0-windows</TargetFrameworks>

The EmbedSample control in the sample directly creates the WinForms control MyControl - see the following code:

C#
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
    // on Windows, return the win32 handle to MyControl packed
    // as PlatformHandle object
    MyControl myControl = new MyControl();

    return new PlatformHandle(myControl.Handle, "Hndl");
}  

In fact, we should replace EmbedSample control with something more generic, that does not depend on a particular embedded visual implementation.

The main project HostingWinFormsDemo directly depends on the winforms MyWinFormsControl project containing essentially a view for the demo. Usually, for the purpose of the separation of concerns, it is better to utilize dynamic loading and IoC containers for loading the views and sometimes also for loading the view models so that the shell and the views could be developed, debugged and tested independently.

Simple WPF Sample

Our next sample demonstrates embedding a simple WPF control into Avalonia.

The solution is HostingWpfControlDemo/HostingWpfControlDemo/HostingWpfControlDemo.sln.

Set HostingWpfControlDemo as the startup project for the solution, compile and run the sample, here is what you'll see:

Image 2

And its behavior is exactly the same as that of the Winforms app above - it displays the number of button clicks above the button.

When describing the code and the architecture of the project, we'll emphasize the differences for the WinForms sample in order to avoid repetitions.

The WPF view is built within WpfControl project with the help of MyWpfControl view and ClickCounterViewModel View Model classes.

MyWpfControl.xaml file uses bindings and behaviors (coming from Microsoft.Xaml.Behaviors.Wpf package reference) to bind the TextBlock's Text and the button's Click action to the corresponding property and method defined on the view model.

XAML
<UserControl x:Class="WpfControl.MyWpfUserControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors">
    <Grid Background="LightGray">
        <StackPanel HorizontalAlignment="Center"
                    VerticalAlignment="Center">
            <TextBlock Text="{Binding Path=NumberClicksStr}" 
                       HorizontalAlignment="Center"
                       Margin="20"/>

            <Button HorizontalAlignment="Center"
                    Padding="10,5"
                    Content="ClickMe">
                <i:Interaction.Triggers>
                    <i:EventTrigger EventName="Click">
                        <!-- Call the method IncreaseNumberClicks() on the view model-->
                        <i:CallMethodAction TargetObject="{Binding}"
                                            MethodName="IncreaseNumberClicks" />
                    </i:EventTrigger>
                </i:Interaction.Triggers>
            </Button>
        </StackPanel>
    </Grid>
</UserControl>  

The view's DataContext property is assigned to an object of ClickCounterViewModel type within the MyWpfUserControl.xaml.cs code behind file (within the view's constructor):

C#
public partial class MyWpfUserControl : UserControl
{
    public MyWpfUserControl()
    {
        InitializeComponent();

        DataContext = new ClickCounterViewModel();
    }
}  

The main change from the previous sample within the main project - HostWpfControlDemo, is the content of EmbedSample. WPF controls are not win32 objects and they do not have a win32 handle. Because of that, we place the WPF controls within WinForms' ElementHost control (which has a win32 handle). Here is the resulting code of the CreateNativeControlCore(...) method:

C#
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // create the WPF view
        MyWpfUserControl myControl = new MyWpfUserControl();

        // use ElementHost to produce a win32 Handle for embedding
        ElementHost elementHost = new ElementHost();

        elementHost.Child = myControl;

        return new PlatformHandle(elementHost.Handle, "Hndl");
    }
    return base.CreateNativeControlCore(parent);
}  

Correspondingly, the main csproj file has both flags UseWindowsForms and UseWPF set to true:

XML
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>

Simple Linux Sample

The primitive Linux Sample is located within HostingLinuxControlDemo\HostingLinuxControlDemo\HostingLinuxControlDemo.sln solution. Using Microsoft's WSL, one can run and debug it on Windows 10 and 11. Placeholder describes how to do it in detail. On Windows 11, you still have to install VcSrv from sourceforge.com, configure and start it in the same manner in which it is described in the article.

Make HostLinuxControlDemo project - your startup project within the solution.

After that, switch your Debug target to WSL:

Image 3

Open up the launchSettings.json file and change the value of its WSL/environmentVariable/DISPLAY under WSL2 to match your machine's IP address, as it is described in Running and Debugging Multiplatform .NET (.NET Core, .NET5 and .NET6) GUI and Console Applications on Windows Subsystem for Linux (WSL).

Build and run the application. The following application will start:

Image 4

Clicking on the button will increase the click count displayed above the button.

Our View Model - ClickCounterViewModel is exactly the same as in the previous samples.

The LinuxView itself is built using GtkSharp - a C# object oriented wrapper around Linux gtk functionality. The code is pretty simple and I will not explain it in detail, since we are mostly concerned about embedding.

There are two interesting files within our main project: EmbedSample.cs and GtkApi.cs.

GtkApi class imports two Linux methods that we need:

C#
public static class GtkApi
{
    private const string GdkName = "libgdk-3.so.0";
    private const string GtkName = "libgtk-3.so.0";
    [DllImport(GdkName)]
    // return the X11 handle for the linux window
    public static extern IntPtr gdk_x11_window_get_xid(IntPtr window);

    [DllImport(GtkName)]
    // destroys the gtk window
    public static extern void gtk_widget_destroy(IntPtr gtkWidget);
}  

These two methods are used by our EmbedSample class. gdk_x11_window_get_xid is used to return X11 handle to the window that we need to use for embedding the Linux view, while gtk_widget_destroy - destroys the Linux window at the end.

Here is the EmbedSample code:

C#
public class EmbedSample : NativeControlHost
{
    private IntPtr? WidgetHandleToDestroy { get; set; }

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {

            return GtkInteropHelper.RunOnGlibThread(() =>
            {
                // create the linux view
                LinuxView linuxView = new LinuxView();

                // store the widget handle for the window to destroy at the end
                WidgetHandleToDestroy = linuxView.Handle;

                // get Xid from Gdk window
                IntPtr xid = GtkApi.gdk_x11_window_get_xid(linuxView.Window.Handle);

                return new PlatformHandle(xid, "Xid");
            }).Result;
        }
        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            GtkInteropHelper.RunOnGlibThread(() =>
            {
                if (WidgetHandleToDestroy != null)
                {
                    // destroy the widget handle of the window
                    GtkApi.gtk_widget_destroy(WidgetHandleToDestroy.Value);
                    WidgetHandleToDestroy = null;
                }
                return 0;
            }).Wait();

            return;
        }

        base.DestroyNativeControlCore(control);
    }
}  

Note that all the interactions with Linux are conducted within a special thread that Avalonia's Avalonia.X11.Interop.GtkInterlopHelper.RunOnGlibThread(...) method gives us access to.

Also note that the TargetFramework for both projects is net6.0 (not net6.0-windows as it was in the previous samples).

Multi Target Sample for Windows and Linux

The code for this sample is located under HostingNativeDemo/HostingNativeDemo/HostingNativeDemo.sln solution.

The purpose of this demo is to show how one can combine Linux and WPF views in the same solution. The main solution is correspondingly multi-target - it uses net6.0 for Linux and net6.0-windows for windows.

The sample is created in a most straightforward way - its architecture is not optimized. In the next subsection, we shall present a demo working in the same fashion, but with better architecture, better separation of concerns and less of platform specific code.

First of all, compile and run the main solution for Windows. In order to do it, first choose the target to be "HostingNativeDemo" and the framework net6.0-windows:

Image 5

Make HostingNativeDemo to be the startup project and rebuild it, then run it. You will see a familiar picture:

Image 6

Now switch to Linux by choosing WSL target and net6.0 framework:

Image 7

Rebuild the main project. Also start the VcSrv server and set DISPLAY variable to your current IP address within Properties/launchSettings.json file as described in Running and Debugging Multiplatform .NET (.NET Core, .NET5 and .NET6) GUI and Console Applications on Windows Subsystem for Linux (WSL).

Run the project and you will have a Linux window with Linux text and button:

Image 8

Now look at the code. There are four projects in the solution:

  1. HostingNativeDemo - the main project
  2. WpfControl - project that hosts the WPF control (view)
  3. LinuxView - project that hosts Linux View
  4. ViewModels - project hosting the View Model for both WPF and Linux projects

The only new thing that we have for the native WPF and native Linux projects is that unlike in previous subsections, the View Model (already familiar to us ClickCoutnerViewModel) is factored out in its own platform agnostic project so that it can be re-used both for Windows and Linux. All the platform specific code is exactly the same as in the previously considered WPF and Linux samples.

The new code that needs to be explained is only within the main project.

Take a look at HostingNativeDemo.csproj project file. You can see that it has a lot of statements conditional on whether the framework is net6.0 or net6.0-windows, e.g.

XML
<PropertyGroup Condition=" '$(TargetFramework)' == 'net6.0-windows' " >
    <UseWindowsForms>true</UseWindowsForms>
    <UseWPF>true</UseWPF>
</PropertyGroup>  

Also:

XML
<PackageReference Condition=" '$(TargetFramework)' != 'net6.0-windows' 
 " Include="GtkSharp" Version="3.24.24.38" /> 

and:

XML
<ProjectReference Condition=" '$(TargetFramework)' != 'net6.0-windows' 
 " Include="..\LinuxControl\LinuxControl.csproj" />
<ProjectReference Condition=" '$(TargetFramework)' == 'net6.0-windows' 
 " Include="..\WpfControl\WpfControl.csproj" />  

The purpose of all those conditional statements is to choose dependencies required for Windows (when target framework is net6.0-windows) and for Linux (when target framework is net6.0).

Files WinApi.cs and GtkApi.cs contain functionality similar to the same named files in the previous Windows and Linux samples, only their content is wrapped within preprocessor conditions to only show when compiled for Window or for Linux. Here is the content of WinApi.cs file (it shows only when compiled for Windows):

C#
namespace HostingNativeDemo
{
// Only compile the class when WINDOWS is defined.
#if WINDOWS
    public static unsafe class WinApi
    {
        [DllImport("user32.dll", SetLastError = true)]
        public static unsafe extern bool DestroyWindow(IntPtr hwnd);
    }
#endif
}  

And here is the content of GtkApi.cs file (to show only when compiled for Linux):

C#
namespace HostingNativeDemo
{
// Only compile the class when WINDOWS is not defined.
#if !WINDOWS
    public static unsafe class GtkApi
    {
        private const string GdkName = "libgdk-3.so.0";
        private const string GtkName = "libgtk-3.so.0";
        [DllImport(GdkName)]
        // return the X11 handle for the linux window
        public static extern IntPtr gdk_x11_window_get_xid(IntPtr window);

        [DllImport(GtkName)]
        // destroys the gtk window or widget
        public static extern void gtk_widget_destroy(IntPtr gtkWidget);
    }
#endif
}  

The other important changes are in the file EmbedSample.cs. It has preprocessor conditions all over. Basically, the preprocessor conditions ensure that it works exactly like EmbedSample class in WPF sample under Windows and in Linux sample under Linux:

C#
#if WINDOWS
using System.Windows.Forms.Integration;
using ViewModels;
using WpfControl;
#else
using LinuxControl;
using Avalonia.X11.Interop;
#endif

...
  
public class EmbedSample : NativeControlHost
{
#if !WINDOWS
    private IntPtr? WidgetHandleToDestroy { get; set; }
#endif

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
#if WINDOWS
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {

            MyWpfUserControl control = new MyWpfUserControl();
            control.DataContext = new ClickCounterViewModel();

            ElementHost host = new ElementHost{ Child = control };

            return new PlatformHandle(host.Handle, "Ctrl");

        }
#else
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            return GtkInteropHelper.RunOnGlibThread(() =>
            {
                // create the linux view
                LinuxView linuxView = new LinuxView();

                // store the widget handle for the window to destroy at the end
                WidgetHandleToDestroy = linuxView.Handle;

                // get Xid from Gdk window
                IntPtr xid = GtkApi.gdk_x11_window_get_xid(linuxView.Window.Handle);

                return new PlatformHandle(xid, "Xid");
            }).Result;

        }
#endif

        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
#if WINDOWS
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // destroy the win32 window
            WinApi.DestroyWindow(control.Handle);

            return;
        }
#else
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            GtkInteropHelper.RunOnGlibThread(() =>
            {
                if (WidgetHandleToDestroy != null)
                {
                    // destroy the widget handle of the window
                    GtkApi.gtk_widget_destroy(WidgetHandleToDestroy.Value);
                    WidgetHandleToDestroy = null;
                }
                return 0;
            }).Wait();

            return;
        }
#endif

        base.DestroyNativeControlCore(control);
    }
}  

Multi Target Sample for Windows and Linux with Architecture Close to Optimal

Introduction to Architecture with Shell and Views

The purpose of the previous sample (same as other samples above) was to highlight the functionality related to Avalonia hosting Windows and Linux native controls (views). We sacrificed the architecture to achieve clarity when it comes to embedding.

In this sample, we shall achieve the same goal as in the previous sample (application with Windows and Linux targets), but optimizing its architecture the way it should be in a real project. In particular:

  1. Our main project (imitating a Shell) will not depend on the native views. Instead the main project shall load the native views dynamically using an IoC container.
  2. Instead of control specific EmbedSample (remember it essentially creates the native control), we shell use classes NativeEmbeddingControl and HandleBuilder that will be completely view agnostic and re-usable for different native views.
  3. We assume that our native views are given to us in some projects that are used by some other applications and should not be modified. Correspondingly, to adapt their functionality to our IoC container and Dynamic Loading, we create two very simple adapter projects - one for Linux and another for Windows native projects.
  4. The View Model is factored out in its own non-visual multiplatform project (this was already done in the previous sample).
  5. The main project that imitates the Shell has almost no target framework conditional code. 99% of such code is located within re-usable PolyFills project.

Sample's Code Structure Overview

The sample is located under HostingNativeWithIoCDemo/HostingNativeWithIoCDemo/HostingNativeWithIoCDemo.sln solution. The startup project is HostingNativeWithIoCDemo.csproj.

Here are all the projects and solution folders as they appear within the Solution Explorer:

Image 9

Here are the explanations for all the projects and folders (for simplicity, I exclude reference to multiplatform Avalonia projects).

  1. HostingNativeWithIoCDemo is the startup project imitating the Shell. It depends on the re-usable projects from the Core folder. It also references my very simple, yet powerful IoC package NP.IoCy. All the simple calls to IoCy will be explained below. Most importantly for the separation of concerns, it has no dependencies on the platform specific views. In general, the Shell should not have any knowledge of the views or view models (platforms specific or not).

    Its net6.0-windows target is dependent on Microsoft.Xaml.Behavior.Wpf package (which I use instead of ICommand to call a View Model method on button click). I needed it because IoCy assembly resolver still cannot resolve nuget packages from a dynamically loaded project automatically without it being loaded first into the Shell. This functionality is coming soon into IoCy and then the shell will be completely target agnostic.
  2. Core folder contains two (potentially) re-usable visual projects:
    1. PolyFills - project that contains almost all of the platform dependent code.
    2. Visuals - project containing re-usuable NativeEmbeddingControl. This project depends on PolyFills project.
  3. NativeControls folder contains two projects with native views:
    1. LinuxControls containing LinuxView class
    2. WpfControls containing MyWpfUserControl class
    Both native classes are exactly the same as Linux and WPF native controls (views) described in the previous samples.
  4. ViewModels project contains the View Model (already familiar to us from previous samples ClickCounterViewModel) re-used for both platforms. The project is 100% non-visual and multiplatform (and therefore 100% re-usable for each of the platforms).
  5. NativeAdapters folder contains the IoCy adapters for the native controls. The purpose of the adapters is to adapt the native visual to an IoC container. The native views/controls are often assumed to be non-modifiable because they might be used for different projects. Potentially, the team who incorporates the views into the shell might not even have the source code for them and consume them only as nuget packages.

    Both projects depend on a tiny but useful NP.Utilities package that provides the attributes for IoCy (they do not need the whole IoCy, only the attributes). Also, both projects depend on PolyFills project (to allow them to create the PlatformHandle object).

    1. LinuxAdapters project contains the adapter (a factory method with IoCy attributes) for the LinuxView class. It references LinuxControls project.
    2. WindowsAdapters project contains the adapter for the MyWpfUserControl class. It references WpfControls project.
    The details for the Adapters' code will be given below.

The diagram below shows the project dependencies. Arrows on the diagram point from a referenceD project to a project that references it. The source code projects have a bold frame while nuget packages have a thinner frame:

Image 10

Notes on Building and Running the Sample

There is no direct dependency between the startup project and native controls (together with their adapters). Because of that, you have to build (or better re-build) NativeAdapters folder separately from the startup project HostingNativeWithIoCDemo. The post build events for each of the adapter projects will copy their release (DLL and PDF) files into the directory of the same name as the Adapter project under:

<main-proj-output-folder>\Plugins\Views    

Assuming that the root-folder is the folder containing the HostingNativeWithIoCDemo.sln solution file (the same folder contains also HostingNativeWithIoCDemo.csproj project file), the LinuxAdapters project's release content will be copied to the newly created

<root-folder>\bin\Debug\net6.0\Plugins\Views\LinuxAdapters  

folder, while the WindowsAdapters release content will be copied to:

<root-folder>\bin\Debug\net6.0-windows\Plugins\Views\WindowsAdapters

folder. Make sure that those folders are populated and up to date before trying to run the startup project.

Aside for the above note, running this project should be exactly the same (for both Windows and Linux) as running the project in the previous sample - Multi Target Sample for Window and Linux. Do not forget to run VcSrv and update DISPLAY variable within Properties/launchSettings.json file for Linux environment.

The resulting application will show exactly the same layout and behavior as in the previous sample.

Gory Details about the Code

The native controls and the View Model have exactly the same code as in the previous samples. So, we shall concentrate primarily on the following two topics:

  1. IoCy related code for storing, creating, dynamically loading and consuming the Views as Container created, Dependency Injected, Dynamically Loaded objects.
  2. Re-usable code for creating and destroying IPlatformHandle objects to be embedded as native objects within Avalonia visual trees.

IoCy Related Code

Take a look at WindowsControlsIoCFactory class under NativeAdapters/WindowsAdapters project. The plurality of the project name (WindowsAdapters, not WindowsAdapter, hints that more than a single windows native control (or view) adapter can be placed there (though here we are using only one). Here is the code:

C#
[HasRegisterMethods]
public static class WindowsControlsIoCFactory
{
    [RegisterMethod(typeof(IPlatformHandle), resolutionKey: "ThePlatformHandle")]
    public static IPlatformHandle? CreateView()
    {
        // create the Windows native WPF control
        MyWpfUserControl control = new MyWpfUserControl();

        // assign its data context to our view model
        control.DataContext = new ViewModels.ClickCounterViewModel();

        // use the method from PolyFill project to create 
        return HandleBuilder.BuildHandle(control);
    }
}  

The attributes [HasRegisterMethods] and [RegisterMethod(...)] come from the reference to NP.DependencyInjection package.

[HasRegisterMethods] class attribute means that the class has some IoCy factory methods in it used for creating Container objects. This will make it easier to search for such classes within an injected assembly (DLL) - instead of checking every method within every public class, we first check the public classes and only search for factory methods within the classes marked by [HasRegisterMethods] attribute.

Now take a look at the RegisterMethod(...) attribute of CreateView() method returning IPlatformHandle? object:

C#
[RegisterMethod(typeof(IPlatformHandle), resolutionKey: "ThePlatformHandle")]
public static IPlatformHandle? CreateView()
{
   ...
}

The first argument to the attribute - typeof(IPlatformHandle) makes the container verify that the object is indeed of that type.

The argument resolutionKey can be any object that (together with the type attribute) uniquely identifies the IoC object creation cell within the container. It is better (but not necessary) to use a name or enumeration value unique across all the container objects (not only the objects of the same creation type). We call our object "ClickCounterView".

Note that there is another important parameter that we are not using - isSingleton - by setting it to true, you can create a singleton object. We do not need it (since anyways each view is used only once within the visual tree and cannot be used in two different places within a visual tree).

Now take a look at LinuxControlsIoCFactory within NativeAdapters/LinuxAdapters project:

C#
[HasRegisterMethods]
public static class LinuxControlsIoCFactory
{
    [RegisterMethod(typeof(IPlatformHandle),  resolutionKey: "ClickCounterView")]
    public static IPlatformHandle? CreateView()
    {
        // HandleBuilder.BuildObjAndHandle will run the LinuxView 
        // and IPlatformHandle creation code 
        return HandleBuilder.BuildObjAndHandle(() => new LinuxView());
    }
} 

The code is very similar, only we call return HandleBuilder.BuildObjAndHandle(() => new LinuxView()); to create and return IPlatformHandle object for LinuxView. The reason we use a different method, is because the LinuxView() constructor as well as all related manipulations need to be done within a special Avalonia thread provided by GtkInteropHelper.RunOnGlibThread(...). HandleBuilder.BuildObjAndHandle(...) ensures that all LinuxView related manipulations are done within that thread.

Now I shall describe how the IoCy container is injected with the assemblies containing the views and how the View objects are retrieved by the main project.

Take a look at App.axaml.cs file under HostingNativeWithIoCDemo project. We define the IoCContainer as a static property and the Assembly injection and container creation is done within its OnFrameworkInitializationCompleted() method:

C#
public partial class App : Application
{
    ...

    public static IDependencyInjectionContainer Container { get; }

    public override void OnFrameworkInitializationCompleted()
    {
         var containerBuilder = new ContainerBuilder();

        // Assembly injection
        containerBuilder.InjectPluginsFromSubFolders($"Plugins{Path.DirectorySeparatorChar}Views");

        // build the container. 
        Container = containerBuilder.Build();

        ...
    }
}  

Note that we are injecting the code from all the subfolders within "Plugins/Views" folder under the main project's executable directory. This is where the PostBuild events copy all the Native Adaptor release files.

The code that creates the handle and builds the Visual Tree using the handle is within MainWindow.axaml.cs file as part of MainWindow() constructor:

C#
public MainWindow()
{
    InitializeComponent();

    // create the embedSample control 
    NativeEmbeddingControl embedSample = new NativeEmbeddingControl();

    // create the platform handle from the container. 
    IPlatformHandle? platformHandle = 
            App.Container.Resolve<IPlatformHandle?>("ClickCounterView");

    // assign the embedSample handle to platformHandle
    embedSample.Handle = platformHandle;

    // set the Content of MyContentControl to be embedSample object. 
    MyContentControl.Content = embedSample;
}  

Re-usable Code for Creating and Destroying IPlatformHandle Objects

This code is located within two projects, PolyFills and Visuals under Core folder.

Visuals project contains only one control - NativeEmbeddingControl that inherits from Avalonia's NativeControlHost. It has an Avalonia StyledProperty Handle (similar to WPF's DependencyProperty). It also overrides two NativeControlHost's methods: CreateNativeControlCore(...) and DestroyNativeControlCore(...):

C#
public class NativeEmbeddingControl : NativeControlHost
{
   ...

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle? parent)
    {
        if (Handle != null)
        {
            // if handle property is not null, return it
            return Handle;
        }

        // otherwise call method of the base class
        return base.CreateNativeControlCore(parent!);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle? handle)
    {
        // call extension method HandleBuilder.DestroyHandle() of PolyFill project
        handle.DestroyHandle();
    }
}  

PolyFill project absorbs the complexity of choosing between Windows and Linux implementations. Its classes WinApi and GtkApi import the native Windows and Linux methods needed for creating and destroying the native IPlatformHandle objects. They are exactly the same as the same named classes of the previous samples.

The most complex class is HandleBuilder. It is all peppered with the preprocessor #if, #else and #endif directions. It contains implementations for building (and destroying) IPlatformHandle objects out of WinForm, WPF or GtkSharp controls.

Most of HandleBuilder code is the same as that of the previous samples. The only difference is ControlWrapper class created only for Linux. It implements INativeControlHostDestroyableControlHandle which is IPlatformHandle interface + Destroy() method. The reason for that is that in case of a Linux widget, the returned IPlatformHandle interface needs to have X11 window handle, while when destroying the window, the gtk_widget_destroy(...) should be called on the Gtk handle of the window. Because of that, we have to keep two handles - the Gtk window handle and X11 window handle. In the examples above, we added private IntPtr? WidgetHandleToDestroy { get; set; } to EmbedSample class as the second handle. Here, we want to keep our NativeEmbeddingControl nice, clean and platform independent; so instead, we created a more involved IPlatformHandle object of ControlWrapper type that keeps those two handles and destroys the correct one when needed (similar to how it was done in Avalonia samples).

Displaying Windows Native Applications running as Native Processes in Avalonia

The most interesting example was left for last. Open HostingWindowsProcessDemo/HostingWindowsProcessDemo/HostingWindowsProcessDemo.sln solution. It contains two projects - main project HostingWindowsProcessDemo and another project WpfApp. The interesting part is that WpfApp is a standalone WPF application, not a DLL. You can make it the startup project and run it without main project. It will show the already familiar Click Counter View:

Image 11

Now make HostingWindowsProcessDemo project to be the startup project within the solution. Rebuild WpfApp project; its Post Build event will copy it under:

<HostingWindowsProcessDemo-localtion>/bin/Debug/net6.0-windows/AppsToHost/WpfApp

folder.

Now build and run HostingWindowsProcessDemo project. It will start its own MainWindow, then it will also start WpfApp application and place WpfApp's window into the left half of its main Window:

Image 12

A separate application instance WpfApp.exe still running in a separate process visually became part of the main window!!!

The code to achieve it is concentrated within two files of HostingWindowsProcessDemo project - MainWindow.axaml.cs and EmbeddedProcessWindow.cs files.

MainWindow.axaml.cs file defines the following functionality:

C#
public partial class MainWindow : Window
{
    // path to WpfApp.exe
    public const string WpfAppProcessPath = @"AppsToHost\WpfApp\WpfApp.exe";

    public MainWindow()
    {
        InitializeComponent();

        // handle Opened event for the window
        this.Opened += MainWindow_Opened;
    }

    private async void MainWindow_Opened(object? sender, System.EventArgs e)
    {
        // create EmbeddedProcessWindow object passing the path to it
        var wpfAppEmbeddedProcessWindow = 
            new EmbeddedProcessWindow(WpfAppProcessPath);

        // start the process and wait for the process' 
        // MainWindowHandle to get populated
        await wpfAppEmbeddedProcessWindow.StartProcess();

        // assign the wpfAppEmbeddedProcessWindow to the 
        // content control in the left half of the MainWindow
        WpfAppPlacementControl.Content = wpfAppEmbeddedProcessWindow;
    }
}  

Here is the code for EmbeddedProcessWindow class:

C#
public class EmbeddedProcessWindow : NativeControlHost
{
    public string ProcessPath;
    private Process _p;

    public IntPtr ProcessWindowHandle { get; private set; }

    public EmbeddedProcessWindow(string processPath)
    {
        ProcessPath = processPath;
    }

    public async Task StartProcess()
    {
        // start the process
        Process p = Process.Start(ProcessPath);

        _p = p;

        _p.Exited += _p_Exited;

        // wait until p.MainWindowHandle is non-zero
        while (true)
        {
            await Task.Delay(200);

            if (p.MainWindowHandle != (IntPtr)0)
                break;
        }

        // set ProcessWindowHandle to the MainWindowHandle of the process
        ProcessWindowHandle = p.MainWindowHandle;
    }

    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
    {
        // set the parent of the ProcessWindowHandle to be the main window's handle
        WinApi.SetParent(ProcessWindowHandle, 
               ((Window) e.Root).PlatformImpl.Handle.Handle);

        // modify the style of the child window

        // get the old style of the child window
        long style = WinApi.GetWindowLongPtr(ProcessWindowHandle, -16);

        // modify the style of the ChildWindow - remove the embedded window's 
        // frame and other attributes of a stand alone window. 
        // Add child flag
        style &= ~0x00010000;
        style &= ~0x00800000;
        style &= ~0x80000000;
        style &= ~0x00400000;
        style &= ~0x00080000;
        style &= ~0x00020000;
        style &= ~0x00040000;
        style |= 0x40000000; // child

        HandleRef handleRef =
            new HandleRef(null, ProcessWindowHandle);

        // set the new style of the schild window
        WinApi.SetWindowLongPtr(handleRef, -16, (IntPtr)style);

        base.OnAttachedToVisualTree(e);
    }

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // return the ProcessWindowHandle
            return new PlatformHandle(ProcessWindowHandle, "ProcWinHandle");
        }
        else
        {
            return base.CreateNativeControlCore(parent);
        }
    }

    private void _p_Exited(object? sender, System.EventArgs e)
    {

    }
}  

EmbeddedProcessWindow inherits from NativeControlHost. It receives the path to the executable in its constructor:

C#
public string ProcessPath { get; }

...

public EmbeddedProcessWindow(string processPath)
{
    ProcessPath = processPath;
}

Then within its async void StartProcess(), it waits until Process'es MainWindowHandle property receives the Handle of the main window of the process (becomes non-zero) and assigns it to ProcessWindowHandle property:

C#
public async Task StartProcess()
{
    // start the process
    Process p = Process.Start(ProcessPath);

    _p = p;

    _p.Exited += _p_Exited;

    // wait until p.MainWindowHandle is non-zero
    while (true)
    {
        await Task.Delay(200);

        if (p.MainWindowHandle != (IntPtr)0)
            break;
    }

    // set ProcessWindowHandle to the MainWindowHandle of the process
    ProcessWindowHandle = p.MainWindowHandle;
}  

Then after the EmbeddedProcessWindow control is attached to the visual tree of the main window, it modifies its window style (to remove window frame and buttons, to make the window - a child window, etc.) and sets its parent to the main window by calling WinApi.SetParent(...) method:

C#
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
    // modify the style of the child window

    // get the old style of the child window
    long style = WinApi.GetWindowLongPtr(ProcessWindowHandle, -16);

    // modify the style of the ChildWindow - remove the embedded window's frame, 
    // buttons, etc. and other attributes of a stand alone window. 
    // Add child flag
    style &= ~0x00010000;
    style &= ~0x00800000;
    style &= ~0x80000000;
    style &= ~0x00400000;
    style &= ~0x00080000;
    style &= ~0x00020000;
    style &= ~0x00040000;
    style |= 0x40000000; // child

    HandleRef handleRef =
        new HandleRef(null, ProcessWindowHandle);

    // set the new style of the schild window
    WinApi.SetWindowLongPtr(handleRef, -16, (IntPtr)style);

    // set the parent of the ProcessWindowHandle to be the main window's handle
    WinApi.SetParent(ProcessWindowHandle, 
                    ((Window)e.Root).PlatformImpl.Handle.Handle);

    base.OnAttachedToVisualTree(e);
}  

Finally, the override of NativeControlHost.CreateNativeControlCore(...) will return the new PlatformHandle(ProcessWindowHandle, "ProcWinHandle"); (ProcessWindowHandle had been set before within OnAttachedToVisualTree(...)):

C#
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // return the ProcessWindowHandle
        return new PlatformHandle(ProcessWindowHandle, "ProcWinHandle");
    }
    else
    {
        return base.CreateNativeControlCore(parent);
    }
}

Conclusion

In this article, I provided simple and detailed examples of Avalonia applications embedding Windows and Linux Views/Controls. It started with simple applications demonstrating how to embed WinForms, WPF and GtkSharp applications.

Then I provided two samples demonstrating how to make the same application embed a WPF control on Windows and a similar Linux control on Linux. The first sample was very straightforward to highlight the native embedding features, while the second sample demonstrated embedding native views with near optimal architecture (the views and the main project being independent and the views are being created by an IoC container).

The final sample demonstrates how to embed a window from a different WPF process into an Avalonia application. This last sample was only built for Windows (10 and 11) because I have a problem figuring out how to get a window X11 ID from a process handle on Linux. Time permitting I'll figure it out and then I'll add another section describing how to embed a native Linux window from a different process.

Acknowledgement

A hat tip to Nikita Tsukanov from the Avalonia team for some help with Linux samples.

History

  • 28th November, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Architect AWebPros
United States United States
I am a software architect and a developer with great passion for new engineering solutions and finding and applying design patterns.

I am passionate about learning new ways of building software and sharing my knowledge with others.

I worked with many various languages including C#, Java and C++.

I fell in love with WPF (and later Silverlight) at first sight. After Microsoft killed Silverlight, I was distraught until I found Avalonia - a great multiplatform package for building UI on Windows, Linux, Mac as well as within browsers (using WASM) and for mobile platforms.

I have my Ph.D. from RPI.

here is my linkedin profile

Comments and Discussions

 
GeneralMy vote of 5 Pin
Igor Ladnik6-Dec-22 6:08
professionalIgor Ladnik6-Dec-22 6:08 
GeneralRe: My vote of 5 Pin
Nick Polyak6-Dec-22 7:57
mvaNick Polyak6-Dec-22 7:57 
Questionadding controls for a dashboard Pin
Ken Blaker1-Dec-22 14:40
Ken Blaker1-Dec-22 14:40 
AnswerRe: adding controls for a dashboard Pin
Nick Polyak1-Dec-22 15:00
mvaNick Polyak1-Dec-22 15:00 
AnswerRe: adding controls for a dashboard Pin
Nick Polyak1-Dec-22 15:05
mvaNick Polyak1-Dec-22 15:05 
QuestionWhat is your involvement in Avalonia? Pin
Graeme_Grant28-Nov-22 18:50
mvaGraeme_Grant28-Nov-22 18:50 
AnswerRe: What is your involvement in Avalonia? Pin
Nick Polyak28-Nov-22 19:15
mvaNick Polyak28-Nov-22 19:15 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.