Search code examples
c#.net-coreavaloniaui

Easier way in .net Avalonia to change the background color of the Window's System Top bar?


In dotnet's Avalonia-UI framework. I'm using a dark UI and I managed to make everything dark as per this example but one thing: the window's System top bar in Windows OS.

I have seen in this issue in github that I can set the property HasSystemDecorations="false" to make it go away, but then I would have to implement myself the top bar with the drag functionality, title, close, maximize, minimize, etc, which is a pain when all I want is to change the background color.

What would be the easier way to make the window top bar change to a dark background color?

If the only way is using HasSystemDecorations then what would be the minimal example to implement the dark top bar with the common funcionality to close/minimize/maximize/drag?


Solution

  • Yes, you have to set HasSystemDecorations="false" and implement your own title bar. I have a basic template on Github for how to do this using version 0.10 and fluent theme.

    It is actually quite easy, because Avalonia provides a lot of convenience methods for achieving that.

    Overview:

    Set

    ExtendClientAreaToDecorationsHint="True"
    ExtendClientAreaChromeHints="NoChrome"
    ExtendClientAreaTitleBarHeightHint="-1"
    

    for the Window and then implement a titlebar. For example the close button could look something like this:

    <Button Width="46"
            VerticalAlignment="Stretch"
            BorderThickness="0"
            Name="CloseButton"
            ToolTip.Tip="Close">
        <Button.Resources>
          <CornerRadius x:Key="ControlCornerRadius">0</CornerRadius>
        </Button.Resources>
        <Button.Styles>
          <Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
            <Setter Property="Background" Value="Red"/>
          </Style>
          <Style Selector="Button:not(:pointerover) /template/ ContentPresenter#PART_ContentPresenter">
            <Setter Property="Background" Value="Transparent"/>
          </Style>
          <Style Selector="Button:pointerover > Path">
            <Setter Property="Fill" Value="White"/>
          </Style>
          <Style Selector="Button:not(:pointerover) > Path">
            <Setter Property="Fill" Value="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
          </Style>
        </Button.Styles>
        <Path Margin="10,0,10,0"
              Stretch="Uniform"
              Data="M1169 1024l879 -879l-145 -145l-879 879l-879 -879l-145 145l879 879l-879 879l145 145l879 -879l879 879l145 -145z"></Path>
      </Button>
    
    

    If you set IsHitTestVisible="False" on a control, the window below can be dragged in that area. So wrap your whole titlebar in for example a DockPanel:

    <DockPanel Background="Black"
               IsHitTestVisible="False"
               Name="TitleBarBackground"></DockPanel>
    

    Now you obviously still need to mimic the behaviour of the buttons. This can be done in principal like that (again for a concrete example check out the Github repo above):

    minimizeButton = this.FindControl<Button>("MinimizeButton");
    maximizeButton = this.FindControl<Button>("MaximizeButton");
    maximizeIcon = this.FindControl<Path>("MaximizeIcon");
    maximizeToolTip = this.FindControl<ToolTip>("MaximizeToolTip");
    closeButton = this.FindControl<Button>("CloseButton");
    windowIcon = this.FindControl<Image>("WindowIcon");
    
    minimizeButton.Click += MinimizeWindow;
    maximizeButton.Click += MaximizeWindow;
    closeButton.Click += CloseWindow;
    windowIcon.DoubleTapped += CloseWindow;
    
    private void CloseWindow(object sender, Avalonia.Interactivity.RoutedEventArgs e)
    {
          Window hostWindow = (Window)this.VisualRoot;
          hostWindow.Close();
    }
    
    private void MaximizeWindow(object sender, Avalonia.Interactivity.RoutedEventArgs e)
    {
         Window hostWindow = (Window)this.VisualRoot;
    
         if (hostWindow.WindowState == WindowState.Normal)
         {
               hostWindow.WindowState = WindowState.Maximized;
         }
         else
         {
               hostWindow.WindowState = WindowState.Normal;
         }
    }
    
    private void MinimizeWindow(object sender, Avalonia.Interactivity.RoutedEventArgs e)
    {
          Window hostWindow = (Window)this.VisualRoot;
          hostWindow.WindowState = WindowState.Minimized;
    }
    

    Now the last step is that you need to change the icon of the maximize button depending on the window state. For example if you drag a maximized window, it will automatically become restored down and the icon of the maximize button needs to change. Therefore you need to subscribe to the window state of your host window, which can be done by overriding the Window.HandleWindowStateChanged method or by doing something like this:

    private async void SubscribeToWindowState()
    {
        Window hostWindow = (Window)this.VisualRoot;
    
        while (hostWindow == null)
        {
            hostWindow = (Window)this.VisualRoot;
            await Task.Delay(50);
        }
    
        hostWindow.GetObservable(Window.WindowStateProperty).Subscribe(s =>
        {
            hostWindow.Padding = hostWindow.OffScreenMargin;
            if (s != WindowState.Maximized)
            {
                maximizeIcon.Data = Avalonia.Media.Geometry.Parse("M2048 2048v-2048h-2048v2048h2048zM1843 1843h-1638v-1638h1638v1638z");
                maximizeToolTip.Content = "Maximize";
            }
            if (s == WindowState.Maximized)
            {
                maximizeIcon.Data = Avalonia.Media.Geometry.Parse("M2048 1638h-410v410h-1638v-1638h410v-410h1638v1638zm-614-1024h-1229v1229h1229v-1229zm409-409h-1229v205h1024v1024h205v-1229z");
                maximizeToolTip.Content = "Restore Down";
            }
        });
    }
    

    Actually in the snippet above there is one more detail, which needs some attention. At least on windows, a maximized window is actually bigger than the screen. If you dont want your content to go out of the screens' bounds, you need to add a margin to your main control inside the window. Therefore the Padding of the hostWindow is changed accordingly. Avalonia provides an IWindowImpl.OffScreenMargin property that describes the margin around the window that is offscreen. You can directly bind to this property in the window's .axml

    <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"
    
            ExtendClientAreaToDecorationsHint="True"
            ExtendClientAreaChromeHints="NoChrome"
            ExtendClientAreaTitleBarHeightHint="-1"
            Padding="{Binding $self.OffScreenMargin}">