在桌面应用开发中,自定义标题栏是一个非常高频的需求。为了实现无缝的一体化视觉设计,我们往往需要隐藏系统自带的标题栏,并自行绘制最小化、最大化和关闭按钮。
然而,标题栏不仅承载视觉,还牵涉到窗口拖拽、双击最大化、边缘贴边等一系列底层的操作系统行为。本文将基于真实的桌面项目实践,介绍如何在 Avalonia 中实现一个完美的自定义标题栏。
✦ 核心逻辑与配置 (Core Logic and Configuration)
要实现自定义标题栏,我们首先需要配置窗口(Window)的几个核心属性,将 UI 渲染范围延伸至系统标题栏区域,同时确保保留窗口的基本缩放边框和阴影。
1 2 3 4 5 6
| <Window ExtendClientAreaTitleBarHeightHint="48" ExtendClientAreaToDecorationsHint="True" WindowDecorations="BorderOnly" WindowStartupLocation="CenterScreen"> </Window>
|
这里有三个至关重要的属性:
ExtendClientAreaToDecorationsHint="True":开启 [[ExtendClientArea]],允许我们的 UI 画进原本的窗口装饰区。
ExtendClientAreaTitleBarHeightHint="48":告知操作系统与框架,我们自绘的标题栏物理高度为 48px,使贴边交互与命中测试更准确。
WindowDecorations="BorderOnly":配置 [[WindowDecorations]] 为 BorderOnly。如果使用 None,虽然同样隐藏了标题栏,但往往会丢失系统的拖拽缩放边框、窗口阴影以及圆角效果。
✦ 经典布局实现 (Layout Implementation)
在布局上,自定义标题栏必须明确划分为两个逻辑区域:
- 可拖拽区域:需要标记特殊的 [[ElementRole]],使用鼠标按住此处可以拖拽窗口、双击最大化。
- 交互按钮区域:放置最小化、最大化和关闭等按钮,该区域绝对不能标记为标题栏角色,否则点击事件会被操作系统拖拽命中拦截。
下面是 MainWindow 完整的 XAML 结构与样式实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128
| <Window ExtendClientAreaTitleBarHeightHint="48" ExtendClientAreaToDecorationsHint="True" Icon="/Assets/avalonia-logo.ico" MaxHeight="800" MaxWidth="1280" Title="" WindowDecorations="BorderOnly" WindowStartupLocation="CenterScreen" mc:Ignorable="d" x:Class="ArturiaLink.Client.Views.MainWindow" x:DataType="vm:MainWindowViewModel" xmlns="https://github.com/avaloniaui" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:ArturiaLink.Client.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.Styles> <Style Selector="Button.caption-button"> <Setter Property="Background" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderThickness" Value="0" /> <Setter Property="Height" Value="48" /> <Setter Property="Width" Value="46" /> <Setter Property="Padding" Value="0" /> <Setter Property="CornerRadius" Value="0" /> <Setter Property="HorizontalContentAlignment" Value="Center" /> <Setter Property="VerticalContentAlignment" Value="Center" />
<Style Selector="^:pointerover /template/ ContentPresenter"> <Setter Property="Background" Value="{DynamicResource ArturiaBtnSecondaryHover}" /> </Style>
<Style Selector="^:pressed /template/ ContentPresenter"> <Setter Property="Background" Value="{DynamicResource ArturiaCaptionPressed}" /> </Style> </Style>
<Style Selector="Button.caption-button.close-button:pointerover /template/ ContentPresenter"> <Setter Property="Background" Value="{DynamicResource ArturiaDanger}" /> <Setter Property="TextElement.Foreground" Value="{DynamicResource ArturiaOnPrimary}" /> </Style> <Style Selector="Button.caption-button.close-button:pressed /template/ ContentPresenter"> <Setter Property="Background" Value="{DynamicResource ArturiaDangerPressed}" /> <Setter Property="TextElement.Foreground" Value="{DynamicResource ArturiaOnPrimary}" /> </Style>
<Style Selector="PathIcon.caption-icon"> <Setter Property="Width" Value="12" /> <Setter Property="Height" Value="12" /> </Style> </Window.Styles>
<Grid RowDefinitions="48,*"> <Border Background="{DynamicResource ArturiaBgWindow}" BorderBrush="{DynamicResource ArturiaBorderDefault}" BorderThickness="0,0,0,1" Grid.Row="0"> <Grid ColumnDefinitions="*,Auto"> <Border Background="Transparent" Grid.Column="0" Padding="16,0,0,0" WindowDecorationProperties.ElementRole="TitleBar"> <StackPanel HorizontalAlignment="Left" Orientation="Horizontal"> <Border Height="24" Margin="0,0,8,0" Width="24"> <PathIcon Data="{StaticResource Logo}" Foreground="{DynamicResource ArturiaTextMain}" Height="18" VerticalAlignment="Center" Width="18" /> </Border> <TextBlock FontFamily="{StaticResource ArturiaFontFamilyBrand}" FontSize="18" FontWeight="Bold" Foreground="{DynamicResource ArturiaTextMain}" Text="Arturia" VerticalAlignment="Center" /> <TextBlock FontFamily="{StaticResource ArturiaFontFamilyBrand}" FontSize="18" FontWeight="Regular" Foreground="{DynamicResource ArturiaTextMuted}" Text="Link" VerticalAlignment="Center" /> </StackPanel> </Border> <StackPanel Grid.Column="1" HorizontalAlignment="Right" Orientation="Horizontal"> <Button Classes="caption-button" Click="MinimizeButton_OnClick"> <PathIcon Classes="caption-icon" Data="{StaticResource Minimize}" /> </Button> <Button Classes="caption-button" Click="MaximizeButton_OnClick"> <PathIcon Classes="caption-icon" Data="{StaticResource Maximize}" x:Name="MaximizeRestoreIcon" /> </Button> <Button Classes="caption-button close-button" Click="CloseButton_OnClick"> <PathIcon Classes="caption-icon" Data="{StaticResource Close}" /> </Button> </StackPanel> </Grid> </Border> <Grid Grid.Row="1"> </Grid> </Grid> </Window>
|
✦ 核心交互逻辑 (Interaction Logic)
窗口的最小化、最大化、恢复和关闭等状态逻辑,属于窗口外壳控制行为。这类与具体 UI 外壳高度绑定的逻辑应直接置于 Window 的 Code-behind 中,避免让业务 ViewModel 引入平台或窗口实例相关的状态。
同时,我们需要监听窗口状态属性的变化,动态更新最大化与向下还原的按钮图标。
MainWindow.axaml.cs 的具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Media;
namespace ArturiaLink.Client.Views;
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); PropertyChanged += OnWindowPropertyChanged; }
private void MinimizeButton_OnClick(object? sender, RoutedEventArgs e) { WindowState = WindowState.Minimized; }
private void MaximizeButton_OnClick(object? sender, RoutedEventArgs e) { WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; }
private void CloseButton_OnClick(object? sender, RoutedEventArgs e) { Close(); }
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if (e.Property == WindowStateProperty) { UpdateMaximizeRestoreIcon(); } }
private void UpdateMaximizeRestoreIcon() { var resourceKey = WindowState == WindowState.Maximized ? "Restore" : "Maximize";
if (this.TryFindResource(resourceKey, out var resource) && resource is Geometry geometry) { MaximizeRestoreIcon.Data = geometry; } } }
|
✦ 踩坑与避坑指南 (Common Pitfalls and Best Practices)
- 不可将右侧按钮区误判为 TitleBar
如果图省事将整个顶栏容器设置为 WindowDecorationProperties.ElementRole="TitleBar",操作系统的拖拽热区会覆盖掉右侧按钮,导致最小化、最大化和关闭按钮无法被正常点击,或者触发点击时产生奇怪的命中冲突。
- 拒绝硬编码尺寸
自定义标题栏按钮(Caption Button)为了符合系统原生习惯,高度建议完美匹配标题栏高度(如本例中的 48px),宽度在 45px ~ 48px 之间。同时应去除所有的 CornerRadius 和内边距,使悬停背景色块无缝填充。
- 最大化与还原图标的精细化切换
一定要通过监听 WindowStateProperty 动态切换最大化与还原状态的图标,不能只展示静态图标,否则会在窗口状态改变后给用户带来错误的视觉反馈。
作者:Arturia
声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!