Custom Window Chrome mit Avalonia

Moderne Desktop-Applikationen bringen auch ihren eigenen Fenster-Rahmen mit. Das gehört zum guten Ton und lässt sich bei zahlreichen Beispielen beobachten. In den meisten GUI-Frameworks gibt es einen Weg, genau das zu erreichen. So kann bei WPF oder Windows.Forms der Standard-Rahmen komplett ausgeblendet und damit durch das eigene Programm selbst gerendert werden. Ähnliches gilt auch für Avalonia. Aufgrund des plattformübergreifenden Ansatzes von Avalonia müssen aber einige Kleinigkeiten beachtet werden. So wird hier nicht garantiert, dass es auf jeder Plattform funktioniert. Auch die Standard-Buttons für Maximieren, Minimieren etc. sind je nach Plattform wo anders (rechts bei Windows, links bei macOS). In diesem Artikel möchte ich mich somit genauer mit diesem Thema beschäftigen und zeigen, wie ich es dann bei der App MessageCommunicator gelöst habe.

Hinweise geben

Zunächst einmal ist es sehr einfach. Bei Avalonia reicht es, wenn man die Eigenschaft ExtendClientAreaToDecorationsHint der Klasse Window auf treu setzt. Dies kann entweder direkt im XAML oder an anderen Stellen wie dem Code-Behind gemacht werden. Wie der Name der Eigenschaft schon sagt, handelt es sich hierbei um einen Hint (also einen Hinweis). Anders als etwa bei WPF oder Windows.Forms stecken hinter Window verschiedene Implementierungen je Plattform. Avalonia garantiert nicht, dass diese Funktion auf jeder Plattform verfügbar ist. Bei meinen Tests hat es beispielsweise nur bei Windows und macOS funktioniert. Unter Linux (aktuelles Ubuntu) leider nicht.

Hieraus ergibt sich aber ein Problem. Als Entwickler muss man mit beiden Fällen umgehen können, also mit und ohne eigenen Fenster-Rahmen. Wie geht man damit am geschicktesten um? Beide Möglichkeiten können innerhalb der Implementierung des Hauptfensters mit etwas Code-Behind unterschieden werden. Ein geschickterer Weg wäre es, diese Unterscheidungslogik in eine eigene Klasse auszulagern. Im Beispielprojekt unter [1] habe ich genau das mithilfe der Klasse MainWindowFrame gemacht.

Die Klasse MainWindowFrame

Die Klasse MainWindowFrame ist als UserControl implementiert. Es teilt seinen Client-Bereich mittels eines Grids in mehrere Unterbereiche ein: Title, Header, MainContent und Footer. Nachfolgender Codeausschnitt zeigt entsprechend den XAML-Code. Der Title hat hierbei eine Höhe von 30 und soll die spätere, eigene Titelleiste sein. 30 ist aus der Luft gegriffen – bei meinen Tests war das bis dato eine gute Höhe für die Titelleiste. Die Idee ist jetzt, dass dieser Title nur dann eingeblendet wird, wenn Avalonia auch tatsächlich selbst den Fensterrahmen rendern kann. Kann Avalonia das nicht, so kümmert sich die Klasse MainWindowFrame darum, dass der eigene Title-Bereich nicht mehr dargestellt wird.

<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"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="HappyCoding.AvaloniaWindowFrame.MainWindowFrame">
    <Grid x:Name="CtrlMainGrid">
        <Grid.RowDefinitions>
            <RowDefinition Height="30" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <!-- Title area -->
        <StackPanel Grid.Row="0" x:Name="CtrlCustomTitleArea"
                    Orientation="Horizontal" >

        </StackPanel>
    
        <!-- Header area (normally a menu) -->
        <Panel Grid.Row="1" x:Name="CtrlHeaderArea" />

        <!-- MainContent area -->
        <Panel Grid.Row="2" x:Name="CtrlMainContentArea"
               Margin="5" />

        <!-- Footer area-->
        <Panel Grid.Row="3" x:Name="CtrlFooterArea" />
    </Grid>
</UserControl>

Das Füllen der Bereiche des MainWindowFrame wird über Eigenschaften der Klasse ermöglicht. Im nachfolgenden Codeausschnitt ist das in den Zeilen 10 bis 13 zu sehen. Die Eigenschaft Children wird von jedem Bereich über eine eigene Eigenschaft in MainWindowFrame nach außen gereicht. Darüber ist es dann etwa vom XAML-Code des Hauptfensters aus relativ einfach, diese Bereiche mit Inhalten zu füllen.

public class MainWindowFrame : UserControl
{
    private Window? _mainWindow;
    private Grid _ctrlMainGrid;
    private StackPanel _ctrlTitlePanel;
    private Panel _ctrlHeaderContent;
    private Panel _ctrlMainContent;
    private Panel _ctrlFooterContent;

    public Controls CustomTitleContent => _ctrlTitlePanel.Children;
    public Controls HeaderContent => _ctrlHeaderContent.Children;
    public Controls MainContent => _ctrlMainContent.Children;
    public Controls FooterContent => _ctrlFooterContent.Children;

    public MainWindowFrame()
    {
        AvaloniaXamlLoader.Load(this);

        _ctrlMainGrid = this.Find<Grid>("CtrlMainGrid");
        _ctrlCustomTitleArea = this.Find<StackPanel>("CtrlCustomTitleArea");
        _ctrlHeaderArea = this.Find<Panel>("CtrlHeaderArea");
        _ctrlMainContentArea = this.Find<Panel>("CtrlMainContentArea");
        _ctrlFooterArea = this.Find<Panel>("CtrlFooterArea");
    }

    // ...
}

Es fehlt noch ein Blick in die Logik der Klasse MainWindowFrame. Irgendwie muss diese schließlich noch herausfinden, ob sie den eigenen Titel-Bereich darstellen und verstecken soll. Der Schlüssel dahinter ist die Eigenschaft IsExtendedIntoWindowDecorations der Klasse Window. Diese wird durch Avalonia auf true gesetzt, sobald es Avalonia geschafft hat, den Rendering-Bereich über den Rahmen des Fensters zu erweitern. Nachfolgender Codeausschnitt zeigt die interessante Stelle der Klasse MainWindowFrame. Zu beachten ist hier noch die Prüfung auf WindowState.FullScreen. Diese ist für den Vollbild-Modus in macOS gedacht und zeigt die eigene Titel-Liste dort immer an.

if (_mainWindow.IsExtendedIntoWindowDecorations || (_mainWindow.WindowState == WindowState.FullScreen))
{
    _ctrlMainGrid.RowDefinitions[0].Height = new GridLength(30.0);
    _ctrlCustomTitleArea.IsVisible = true;
}
else
{
    _ctrlCustomTitleArea.IsVisible = false;
    _ctrlMainGrid.RowDefinitions[0].Height = new GridLength(0.0);
}

Die Klasse MainWindowFrame geht aktuell davon aus, dass sie direkt das erste Child des Window ist. Der Member _mainWindow wird somit auf den Parent des MainWindowFrame gesetzt. Wirkt zwar etwas unsauber, ist für mich aber völlig ausreichend. In meiner Implementierung lässt das MainWindowFrame einfach alle Bereiche sichtbar, falls das Parent Objekt kein Window ist.

Das Hauptfenster

Im XAML des Hauptfensters sieht das dann wie im nachfolgenden Codeausschnitt aus. Die Klasse MainWindowFrame gibt hier eine saubere Struktur vor, welche sich beim Start der Anwendung dynamisch nach den Möglichkeiten der Plattform ausrichtet. Daneben sind auch die anderen Bereiche Header, MainContent und Footer sauber strukturiert.

<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="clr-namespace:HappyCoding.AvaloniaWindowFrame"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Name="MainWindow"
        x:Class="HappyCoding.AvaloniaWindowFrame.MainWindow"
        ExtendClientAreaToDecorationsHint="True"
        Title="HappyCoding.AvaloniaWindowFrame">
    <local:MainWindowFrame>

        <local:MainWindowFrame.CustomTitleArea>
            <Button Content="Custom Title Button" />
            <TextBlock Text="{Binding ElementName=MainWindow, Path=Title}"
                       VerticalAlignment="Center"
                       IsHitTestVisible="False" />
        </local:MainWindowFrame.CustomTitleArea>

        <local:MainWindowFrame.HeaderArea>
            <Menu Classes="MainMenu">
                <MenuItem Header="File">
                    <MenuItem Header="New" />
                    <MenuItem Header="Open" />
                    <Separator />
                    <MenuItem Header="Exit" />
                </MenuItem>
                <MenuItem Header="Info">
                    <MenuItem Header="About" />
                </MenuItem>
            </Menu>
        </local:MainWindowFrame.HeaderArea>

        <local:MainWindowFrame.MainContentArea>
            <StackPanel Orientation="Vertical">
                <Button Content="Button 1" />
                <Button Content="Button 2" />
                <Button Content="Button 3" />
                <Button Content="Button 4" />
            </StackPanel>
        </local:MainWindowFrame.MainContentArea>

        <local:MainWindowFrame.FooterArea>
            <Grid Classes="Footer" Height="30">
                <StackPanel Orientation="Vertical"
                            Margin="7,0,0,0" VerticalAlignment="Center">
                    <TextBlock Text="Dummy Footer..." />
                </StackPanel>
            </Grid>
        </local:MainWindowFrame.FooterArea>
    </local:MainWindowFrame>
</Window>

Nachfolgender Screenshot zeigt beide Varianten auf macOS. Links mit und rechts ohne selbst gerenderte Titelleiste. Auf der selbst gerenderten Titelleiste wird beispielhaft neben dem Fenstertitel auch ein Button angezeigt. Grundsätzlich hat man hier als Entwickler völlige Flexibilität über die Controls, die man verwendet.

MainWindowFrame unter macOS

Einsatz in der App MessageCommunicator

Ein anderes Beispiel für den Einsatz der Klasse MainWindowFrame ist der aktuelle Stand der App MessageCommunicator [2]. Hier wird zwar kein Button in die Titel-Leiste gerendert, dafür aber wird die schwarze Hintergrundfarbe des Fensters durchgängig verwendet. Nachfolgende Screenshots zeigen das auf Windows und macOS.

Message Communicator - Black Theme
MessageCommunicator maxOS

Fazit

Avalonia bietet eine Vielzahl von Möglichkeiten für das Styling der eigenen Applikation. Die hier vorgestellte Klasse MainWindowFrame bietet darauf aufbauend zwei Vorteile. Zum einen hilft sie bei der Umsetzung eines eigenen Fensterrahmens inklusive der Titelleiste. Zum anderen hilft sie, eine saubere Struktur mit den Bereichen Header, MainContent und Footer vorzugeben.

Quellen

  1. https://github.com/RolandKoenig/HappyCoding/tree/main/2021/HappyCoding.AvaloniaWindowFrame
  2. https://github.com/RolandKoenig/MessageCommunicator

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.