WinForms-Controls in Avalonia integrieren

Avalonia ist ein mächtiges UI-Framework, das nach dem ersten Ausprobieren schnell Lust auf mehr macht. Hat man sich für eine Migration von WinForms oder WPF hin zu Avalonia entschieden, kann man in seltenen Fällen auf Controls stoßen, die es in Avalonia nicht gibt – zumindest nicht genauso, wie vorher in der WinForms- oder WPF-Welt. Ein solches Beispiel hatte ich neulich bei einem Kunden. Konkret ging es um die für WinForms bereitgestellte ReportViewer-Komponente [1]. Der längerfristig richtige Schritt in solchen Fällen ist häufig, einen modernen Ersatz zu finden, der sich entsprechend gut in die Avalonia-Applikation einbinden lässt. In diesem konkreten Fall ist ein Wechsel der ReportViewer-Komponente ohnehin überfällig, im Kontext der Migration auf Avalonia hat diese aber zunächst eine geringere Priorität. Übergangsweise kann es in diesen Situationen hilfreich sein, solche Controls direkt in eine Avalonia-Applikation einzubinden – auch wenn das aufgrund von WinForms nur unter Windows funktioniert und damit nicht auf anderen Plattformen wie macOS oder Linux genutzt werden kann.

NativeControlHost als Basis

WinForms ist technisch ein Wrapper um die Win32-API. Alle Controls von WinForms besitzen daher ein HWND. Ein HWND ist ein technischer Zeiger auf ein Fenster oder auf ein Benutzeroberflächen-Element von Windows. Avalonia besitzt mit dem NativeControlHost [2] ein Feature, um solche nativen Controls in eine Avalonia-Applikation einzubinden. Zum Zeitpunkt dieses Artikels gibt uns die Dokumentation von Avalonia ein kleines Beispiel, wie man NativeControlHost nutzen kann [3]. Siehe dazu nachfolgenden Codeausschnitt. Praktischerweise bezieht sich der Beispielcode auf Windows. Es wird aber nicht WinForms verwendet, stattdessen wird die Win32-API CreateWindowEx hier direkt aufgerufen.

public class NativeTextEditor : NativeControlHost
{
    protected override IPlatformHandle CreateNativeControlCore(
        IPlatformHandle parent)
    {
        if (OperatingSystem.IsWindows())
        {
            // Create a Win32 EDIT control
            var hwnd = CreateWindowEx(0, "EDIT", "",
                WS_CHILD | WS_VISIBLE | ES_MULTILINE,
                0, 0, 100, 100,
                parent.Handle, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);

            return new PlatformHandle(hwnd, "HWND");
        }

        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(
        IPlatformHandle control)
    {
        if (OperatingSystem.IsWindows())
        {
            DestroyWindow(control.Handle);
        }
        else
        {
            base.DestroyNativeControlCore(control);
        }
    }
}


Im Beispiel wird die Wrapper-Klasse NativeTextEditor definiert, die von NativeControlHost erbt. Die beiden Methoden CreateNativeControlCore und DestroyNativeControlCore werden implementiert und kümmern sich darum, das Win32-Control nach Bedarf zu erzeugen und auch wieder freizugeben. Nach Bedarf bedeutet hier etwa, wenn das Control an der Oberfläche sichtbar ist. Warum macht man das nach Bedarf? Der Grund dafür ist sehr einfach. Native Controls verbrauchen Speicher und ggf. weitere Ressourcen. Diese werden nicht von der .NET-Runtime verwaltet, daher müssen wir uns in dieser Wrapper-Klasse darum kümmern, dass sie erzeugt und auch wieder freigegeben werden. Der nachfolgende Codeausschnitt aus der Avalonia-Dokumentation zeigt, wie die Wrapper-Klasse NativeTextEditor schließlich im XAML-Code angebunden wird.

<Border BorderBrush="Gray" BorderThickness="1">
    <local:NativeTextEditor MinHeight="200" />
</Border>

Nachteile des NativeControlHost

Obiges Beispiel wirkt zunächst sehr einfach und flexibel einsetzbar. Es gibt aber auch einige Nachteile, die man beachten muss. Ein NativeControlHost bindet sich nur in Bezug auf Größe und Position in den VisualTree ein. Andere Features wie Transparenzen, Transformationen oder Überblendungen funktionieren damit nicht. Der Grund dafür ist, dass native Controls nicht mit dem Rendering von Avalonia integrieren. Stattdessen stellt das NativeControlHost lediglich einen Kasten innerhalb der Avalonia-Applikation zur Verfügung, innerhalb dessen das native Control platziert wird. Position und Größe passt Avalonia je nach Bedarf an, auf andere Dinge hat Avalonia keinen Einfluss.

Für uns bedeutet das: In aller Regel sollte ein Rückgriff auf NativeControlHost eine Übergangslösung sein. Ausnahmen kann es geben. Ein typisches Beispiel für eine solche Ausnahme ist die Integration eines Browsers, eines Media-Players oder sonstiger Controls, welche vom Betriebssystem kommen. Man sollte sich hier aber der Nachteile bewusst sein.

Integration von WinForms via NativeControlHost

Kommen wir zurück zum ursprünglichen Thema dieses Artikels und versuchen wir uns daran, ein WinForms-Control in Avalonia via NativeControlHost einzubinden. Als Beispiel nutzen wir nicht den anfangs angesprochenen ReportViewer, sondern das Control MonthCalendar. MonthCalendar ist bei den Standard-Controls von WinForms dabei und lässt uns grafisch einen Zeitraum zwischen einem Startdatum und einem Enddatum auswählen. Nachfolgend ein Screenshot des vollständigen Beispielprogramms, das den MonthCalendar von WinForms in eine Avalonia-Applikation bringt. Die obere, schwarze Leiste mit den beiden CalendarDatePicker-Elementen ist Avalonia, der weiße Inhaltsbereich ist der MonthCalendar von WinForms.

Zur Umsetzung dieses Beispiels erstellen wir zuerst einen Wrapper für den MonthCalendar von WinForms. Der Wrapper muss für dieses Beispiel die Eigenschaften SelectionStart und SelectionEnd in die Avalonia-Welt überführen – ansonsten hätten wir von Avalonia aus keinen Zugriff auf diese Eigenschaften und könnten diese auch nicht für DataBinding nutzen. Im folgenden Codeausschnitt sehen wir, wie das aussehen kann. Wir definieren zwei DirectProperties, welche im Wesentlichen auf die gleichnamigen Eigenschaften des MonthCalendar-Controls zeigen. Auch anders herum triggern wir ein PropertyChanged-Ereignis, sobald wir über ein Event vom MonthCalendar-Control mitbekommen, dass der Benutzer mindestens einen dieser Werte geändert hat.

Wichtig zu erwähnen ist an dieser Stelle, dass der MonthCalendar als Nullable definiert wird. Der Grund dafür ist denkbar einfach: Die Instanz des MonthCalendar von WinForms erzeugen wir erst, wenn Avalonia sie über die CreateNativeControlCore-Methode angefordert hat. Wurden vorher schon Werte für SelectionStart oder SelectionEnd gesetzt, merken wir uns diese in Membervariablen.

public class WinFormsMonthCalendar : NativeControlHost
{
    public static readonly DirectProperty<WinFormsMonthCalendar, DateTime> SelectionStartProperty =
        AvaloniaProperty.RegisterDirect<WinFormsMonthCalendar, DateTime>(
            nameof(SelectionStart),
            o => o.SelectionStart,
            (o, v) => o.SelectionStart = v);

    public static readonly DirectProperty<WinFormsMonthCalendar, DateTime> SelectionEndProperty =
        AvaloniaProperty.RegisterDirect<WinFormsMonthCalendar, DateTime>(
            nameof(SelectionEnd),
            o => o.SelectionEnd,
            (o, v) => o.SelectionEnd = v);

    private MonthCalendar? _userControl;
    private DateTime _selectionStart = DateTime.Now;
    private DateTime _selectionEnd = DateTime.Now;

    public DateTime SelectionStart
    {
        get => _selectionStart;
        set
        {
            SetAndRaise(SelectionStartProperty, ref _selectionStart, value);
            if (_userControl != null)
            {
                _userControl.SelectionStart = value;
            }
        }
    }

    public DateTime SelectionEnd
    {
        get => _selectionEnd;
        set
        {
            SetAndRaise(SelectionEndProperty, ref _selectionEnd, value);
            if (_userControl != null)
            {
                _userControl.SelectionEnd = value;
            }
        }
    }

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        _userControl = new MonthCalendar();
        _userControl.CreateControl();

        _userControl.SelectionStart = _selectionStart;
        _userControl.SelectionEnd = _selectionEnd;
        _userControl.DateChanged += OnUserControl_SelectedDateChanged;

        return new PlatformHandle(_userControl.Handle, "HWND");
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
        if (_userControl != null)
        {
            _userControl.Dispose();
            _userControl = null;
        }
    }

    private void OnUserControl_SelectedDateChanged(object? sender, System.EventArgs e)
    {
        if (_userControl == null) { return; }

        SetAndRaise(SelectionStartProperty, ref _selectionStart, _userControl.SelectionStart);
        SetAndRaise(SelectionEndProperty, ref _selectionEnd, _userControl.SelectionEnd);
    }
}

Im XAML-Code lässt sich der Wrapper nun relativ einfach integrieren, siehe dazu nachfolgenden Codeausschnitt. Das einzige, was wir brauchen, ist der richtige Namespace-Import. Der Wrapper verhält sich aus XAML-Sicht wie ein normales Control von Avalonia. Dank der DirectProperties können wir sogar Bindings nutzen. Im Beispiel binden wir das ausgewählte Start- und Enddatum des MonthCalendar und die beiden CalendarDatePicker von Avalonia, die wir in der oberen Leiste platziert haben. Dadurch sehen wir, dass das DataBinding in beide Richtungen funktioniert.

<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:local="using:HappyCoding.AvaloniaWithWinForms"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="HappyCoding.AvaloniaWithWinForms.MainWindow"
        Title="HappyCoding.AvaloniaWithWinForms">
    <DockPanel>
         <StackPanel DockPanel.Dock="Top"
                     Orientation="Horizontal"
                     Margin="5"
                     Spacing="5">
             <TextBlock VerticalAlignment="Center" 
                        Text="Selected dates: From" />
             <CalendarDatePicker SelectedDate="{Binding #MonthCalendar.SelectionStart}" />
             <TextBlock VerticalAlignment="Center" 
                        Text="to" />
             <CalendarDatePicker SelectedDate="{Binding #MonthCalendar.SelectionEnd}" />
         </StackPanel>

		 <local:WinFormsMonthCalendar Name="MonthCalendar" />
    </DockPanel>
</Window>

Weitere Voraussetzungen beachten

Es gibt noch einige Kleinigkeiten, die wir bei der Integration von WinForms in Avalonia beachten müssen. Ganz vorne dabei: Wir benötigen Zugriff auf die Klassen von Windows.Forms. Im modernen .NET sind dazu ein paar Einstellungen in der .csproj-Datei nötig. Erster Punkt ist das TargetFramework. Hier reicht ein „net10.0“ nicht, stattdessen müssen wir etwa mit „net10.0-windows“ angeben, dass die Applikation Funktionalitäten nutzt, die nur auf Windows zur Verfügung stehen. Der zweite Punkt ist <UseWindowsForms>true</useWindowsForms>. Damit sagen wir dem .NET SDK, dass wir auf die Klassen von WinForms zugreifen möchten. Nachfolgender Codeauschnitt zeige diese Einstellungen in der .csproj.

<PropertyGroup>
  <OutputType>WinExe</OutputType>
  <TargetFramework>net10.0-windows</TargetFramework>
  <UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>

Ganz fertig sind wir damit noch nicht. Wir können den Code zwar kompilieren und die Applikation starten, das WinForms-Control sieht aber noch etwas seltsam aus. Der Grund dafür sind fehlende Aufrufe der WinForms-API, die bei WinForms-Applikationen typischerweise ganz oben in der Program.cs gemacht werden. Es geht beispielsweise um den Aufruf der Methode Application.EnableVisualStyles. Der nachfolgende Codeausschnitt zeigt diese Aufrufe in der Program.cs unmittelbar vor dem Laden von Avalonia.

[STAThread]
public static void Main(string[] args)
{
    System.Windows.Forms.Application.EnableVisualStyles();
    System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false);
    System.Windows.Forms.Application.SetHighDpiMode(HighDpiMode.SystemAware);

    BuildAvaloniaApp()
        .StartWithClassicDesktopLifetime(args);
}

Fazit

In diesem Artikel haben wir ein einfaches Beispiel gesehen, wie WinForms-Controls in Avalonia eingebunden werden können. Technisch ist das leicht umzusetzen, bringt aber auch Nachteile mit sich, da sich WinForms-Controls nicht nahtlos in den VisualTree von Avalonia integrieren. Vorteile ergeben sich aber insbesondere in Migrationsprojekten von WinForms auf Avalonia. Hier können übergangsweise Controls aus der WinForms-Welt für Avalonia verfügbar gemacht werden, um diese in einem späteren Schritt auf ordentlichere Weise abzulösen.

Verweise

  1. ReportViewer in Windows.Forms
    https://learn.microsoft.com/de-de/sql/reporting-services/application-integration/using-the-winforms-reportviewer-control?view=sql-server-ver17
  2. NativeControlHost von Avalonia
    https://docs.avaloniaui.net/api/avalonia/controls/nativecontrolhost
  3. Native platform interop
    https://docs.avaloniaui.net/docs/app-development/native-interop
  4. Quellcode des Beispiels
    https://github.com/RolandKoenig/HappyCoding/tree/main/2026/HappyCoding.AvaloniaWithWinForms

Ebenfalls interessant

  1. ValueConverter in Avalonia – 3 Wege, sie in XAML einzubinden
    https://www.rolandk.de/blog/2025/09/07/valueconverter-in-avalonia-3-wege-sie-in-xaml-einzubinden
  2. Testautomatisierung mit Avalonia #2
    https://www.rolandk.de/blog/2025/03/09/testautomatisierung-mit-avalonia-2
  3. Schulungen zu Avalonia UI
    https://www.rolandk.de/wp-pages/training/