[WinUI3] 如何自定义桌面应用标题栏
📢 随着 Windows App SDK 1.0 的发布,Windows 应用开发也进入到了一个新的时期。虽然前景美好,但该框架还有一些不完善的地方,下文所述即是我在折腾 WinUI 3 时遇到的标题栏的坑,分享出来以供大家参考。
P.S. 下文所展示的缺陷可能会在将来的版本中修复,本文仅针对 Windows App SDK 1.0 版本。
场景说明
先上效果图:
在 UWP 中,往标题栏放控件早已不是什么新鲜事了,比如 Microsoft Store:
自定义的标题栏往往与应用主体更为契合,在代码实现上也不困难,这些在 UWP 文档上有详细的教程:Title bar customization - Windows apps | Microsoft Docs
我在设计应用时也会延续 UWP 的设计思路,使用自定义的标题栏,在里面放上返回按钮、菜单按钮、搜索框之类的控件。
在开发 UWP 时,一切得心应手,但是在桌面应用中,一切突然变得陌生了。
下面,请新建一个空白 WinUI 3 桌面应用,我们一步步来。
❗ 官方文档给出的示例,即 CoreApplication.GetCurrentView().TitleBar 那一套在桌面应用中是不行的,由于应用模型不同,该方法不会返回正确的结果,而是抛出异常 (Element not found)。在桌面应用中,我们只能走窗口管理API这条路。
遇到的困难
-
双重标题栏
对于 WinUI 3 桌面应用来说,它的标题栏不止一个。
第一个标题栏(位于AppWindow)
第二个标题栏(WindowChrome)
当我们在 App.xaml.cs 的 OnLaunched 事件回调里加上一句
m_window.ExtendsContentIntoTitleBar = true;
第二个标题栏就会出现。此时我们调整窗口宽度,就会显示出神奇的一幕:
注意到了吗?第二个标题栏不光在调整大小时有延迟,而且在它没盖住的地方还会显现出下层“真正的”标题栏,也就是位于 AppWindow 的标题栏。
这时候的问题在于,我们要在哪一个标题栏上做文章?
-
交互拦截
当我们调用 Window.SetTitleBar(A) 这一方法设置标题栏时,A 控件的所有内部控件及位于 A 渲染范围内的控件的交互全都会被拦截,比如下面的代码:
<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition /> </Grid.RowDefinitions> <Grid x:Name="AppTitleBar" Background="Transparent"> <Button Content="Button A" /> </Grid> <Button Margin="90,0,0,0" Content="Button B" /> </Grid>
public MainWindow() { this.InitializeComponent(); ExtendsContentIntoTitleBar = true; SetTitleBar(AppTitleBar); }
标题栏区域可以拖动,但两个按钮均无法点击,即便 Button B 的 ZIndex 高于 Button A。
-
背景覆盖
你可能注意到上面的图片中没有 Windows 应用的“三大金刚”,即最小化/最大化和关闭按钮,原因很简单,我们给 MainWindow 的根元素(Grid)加了个背景色,同时我们又设置了 ExtendsContentIntoTitleBar 为 True,所以作为内容区的 Grid 的背景色就覆盖了位于 WindowChrome 上的 TitleBar,把三大金刚给盖住了。
就TM离谱。
为了解决颜色问题,要么把自定义标题栏搞成透明的,要么覆盖默认资源,但若是碰到自定义标题栏高度和默认高度不一样,又不能覆盖默认按钮,那就有意思了,可能会这样:
其它还有一些开发过程中会碰到的小问题,我们在后文详述。
实现方案
按照文档 Window.SetTitleBar(UIElement) Method (Microsoft.UI.Xaml) - WinUI | Microsoft Docs 的说法,使用自定义标题栏的第一步就是调用 Window.ExtendsContentIntoTitleBar = true。
如果按着文档走,接下来你会面临我上面列举的诸多恼人的问题,且基本没有解决方法,除非你改设计。
所以,让我们回到上节的第一个问题,两个标题栏,选谁?
选第一个,即 AppWindowTitleBar。
扩展标题栏
Microsoft.UI.Xaml.Window 类中有 ExtendsContentIntoTitleBar 属性,Microsoft.UI.Windowing.AppWindowTitleBar 上也有。我们要修改的就是 AppWindowTitleBar.ExtendsContentIntoTitleBar 属性。
在 App.xaml.cs 中添加如下代码:
private IntPtr _windowHandle;
/// <summary>
/// 应用窗口对象.
/// </summary>
public static AppWindow AppWindow { get; private set; }
/// <summary>
/// 主窗口.
/// </summary>
public static Window MainWindow { get; private set; }
/// <summary>
/// Invoked when the application is launched normally by the end user. Other entry points
/// will be used such as when the application is launched to open a specific file.
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
MainWindow = new MainWindow();
// 获取当前窗口句柄
_windowHandle = WindowNative.GetWindowHandle(MainWindow);
var windowId = Win32Interop.GetWindowIdFromWindow(_windowHandle);
// 获取应用窗口对象
AppWindow = AppWindow.GetFromWindowId(windowId);
AppWindow.TitleBar.ExtendsContentIntoTitleBar = true;
MainWindow.Activate();
}
此时运行应用,你会看到这样的结果:
应用顶部与标题栏等高的区域是可以拖动的哦~
创建自定义标题栏
为了实现我们预期的设计:
现在需要创建一个自定义控件,名为 AppTitleBar
在 AppTitleBar.xaml 中创建UI:
<Grid
Height="48"
Padding="16,0,0,0"
Background="Wheat"
RequestedTheme="Light">
<Grid.ColumnDefinitions>
<!-- Logo -->
<ColumnDefinition Width="Auto" />
<!-- 搜索 -->
<ColumnDefinition x:Name="SearchColumn" Width="*" />
<!-- 右侧区域(留给最小化/最大化/关闭按钮) -->
<ColumnDefinition Width="120" />
</Grid.ColumnDefinitions>
<StackPanel
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="16">
<Image
Width="16"
Height="16"
VerticalAlignment="Center"
Source="Assets/StoreLogo.png" />
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="测试应用" />
</StackPanel>
<AutoSuggestBox
Grid.Column="1"
MinWidth="300"
x:Name="SearchBox"
MaxWidth="500"
VerticalAlignment="Center"
PlaceholderText="搜索内容" />
</Grid>
接下来,在 MainWindow.xaml 中引入该控件。
<Grid>
<Grid.RowDefinitions>
<!-- 标题栏 -->
<RowDefinition Height="Auto" />
<!-- 内容区 -->
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<local:AppTitleBar />
</Grid>
此时运行应用,应该如下所示:
此时我们并没有指定 AppTitleBar 为应用的标题栏,所以你能发现,一个28像素的透明标题栏依然盖在控件上方,被它覆盖的区域我们不能点击到下方的搜索框。但此时我们也可以发现,即便我们给标题栏设置了背景色,它也没有覆盖三大金刚,并且调整窗口大小也不会有奇怪的残影,这非常好!
设置拖拽区域
上一步结束之后,是不是就要把 AppTitleBar 指定为应用的标题栏呢?
非也。
你一旦在 MainWindow 中调用 SetTitleBar(AppTitleBar),你会发现……啥都没变。
因为在 MainWindow 中调用 SetTitleBar 方法,会将指定的 UIElement 设置到 WindowChrome 上,而在此之前,你必须要在 MainWindow 中设置 ExtendsContentIntoTitleBar = true 让 WindowChrome 显示出来才行。
我们既已选择了走 AppWindow 这条路,就忘了 WindowChrome 吧。
那么接下来我们怎么做?
我们现在的问题是什么?
标题栏的区域盖住了本应提供交互的区域,同时我们预期的标题栏高度要大于默认高度,所以默认的标题栏高度又不够,就像下图所示:
所以说,我们要解决两个问题:
- 调整标题栏的高度,让它和控件一致。
- 不让标题栏盖住我们预期提供交互的区域(这里指搜索框)。
目前 WinUI 3 文档匮乏,没有文档告诉我们该怎么做。这里就很有意思了,我们要思考一件事,到底是什么盖住了内容区?
是标题栏吗?是,但更进一步,是标题栏的可拖拽区域盖住了内容区。
拖拽区拦截了我们所需要的交互事件,转而为窗口拖拽和窗口快捷操作(比如双击标题栏全屏)服务。
那么想到这里,我们就能把前面的问题转化成另一个问题:如何控制标题栏可拖拽区域的大小和位置?
AppWindowTitleBar.SetDragRectangles(RectInt32[])
方法名很直观的表明了该 API 的用途,所以我们的问题就可以通过该方法得到解决。
再来分析一下我们的布局:
由于三大金刚按钮始终置顶,所以我们可以忽略覆盖它们的问题,这样我们就用搜索框分割出了两个拖拽区域。
这两个拖拽区域就是我们要传给 AppWindowTitleBar.SetDragRectangles() 的参数了。
如何计算拖拽区域呢?
将下面的代码加入 AppTitleBar.xaml.cs
public AppTitleBar()
{
this.InitializeComponent();
this.Loaded += OnLoaded;
this.SizeChanged += OnSizeChanged;
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
=> UpdateDragRects();
private void OnLoaded(object sender, RoutedEventArgs e)
=> UpdateDragRects();
private void UpdateDragRects()
{
var titleBar = App.AppWindow.TitleBar;
// 当前控件的实际宽度.
var totalSpace = ActualWidth;
var height = ActualHeight;
// 搜索框的左边界相对于整个控件左边界的偏移值.
var searchLeftOffset = SearchBox.ActualOffset.X;
// 搜索框的右边界相对于整个控件左边界的偏移值.
var searchRightOffset = searchLeftOffset + SearchBox.ActualWidth;
var leftSpace = searchLeftOffset;
var rightSpace = totalSpace - searchLeftOffset - SearchBox.ActualWidth;
var leftRect = new RectInt32(0, 0, Convert.ToInt32(leftSpace), Convert.ToInt32(height));
var rightRect = new RectInt32(Convert.ToInt32(searchRightOffset), 0, Convert.ToInt32(rightSpace), Convert.ToInt32(height));
titleBar.SetDragRectangles(new RectInt32[] { leftRect, rightRect });
}
📌 UpdateDragRects 方法表明了计算过程。由于作为示例,这里的矩形计算相对简单,如果你的标题栏中包含更多控件,需要划分更多区域,请按照这个思路继续。如果你的应用有更高的设计规范要求,也别忘了考虑 AppWindowTitleBar.LeftInset 和 AppWindowTitleBar.RightInset 造成的影响,这里就不展开了。
DPI 问题
如果你不在 100% 标准比例下运行应用,你会发现一件很坑的事情,即你写的算法没有问题,但是拖拽区域就是对不上。
比如你在 125% 放大的环境中运行上面的代码,你会发现搜索框后面半截依然被拖拽区域覆盖,且搜索框左侧的空白区域有一段不可拖拽,看上去像是我给错区域了。
在我踩坑时,我并没有意识到这是 DPI 的问题。我想从 UWP 转过来的开发者脑子里面可能都不会想到是 DPI,谁让在开发 UWP 的时候完全不用考虑这种事呢?
直到我做匹配测试(即在传入矩形区域前手动调整矩形参数),得出的多组数值都显示预期数值是传入数值的1.25倍左右我才意识到可能是放大比例的问题。
所以,同志们,我们需要修改上面的计算方法,以考虑 DPI 的影响。
先引入 PInvoke.User32 nuget 包,再加一个转换方法:
/// <summary>
/// 在设置拖动区域时,需要考虑到系统缩放比例对像素的影响.
/// </summary>
/// <param name="pixel">像素值.</param>
/// <returns>转换后的结果.</returns>
private static int GetActualPixel(double pixel)
{
var windowHandle = WindowNative.GetWindowHandle(App.MainWindow);
var currentDpi = PInvoke.User32.GetDpiForWindow(windowHandle);
return Convert.ToInt32(pixel * (currentDpi / 96.0));
}
private void UpdateDragRects()
{
var titleBar = App.AppWindow.TitleBar;
// 当前控件的实际宽度.
var totalSpace = ActualWidth;
var height = ActualHeight;
// 搜索框的左边界相对于整个控件左边界的偏移值.
var searchLeftOffset = SearchBox.ActualOffset.X;
// 搜索框的右边界相对于整个控件左边界的偏移值.
var searchRightOffset = searchLeftOffset + SearchBox.ActualWidth;
var leftSpace = searchLeftOffset;
var rightSpace = totalSpace - searchLeftOffset - SearchBox.ActualWidth;
var leftRect = new RectInt32(0, 0, GetActualPixel(leftSpace), GetActualPixel(height));
var rightRect = new RectInt32(GetActualPixel(searchRightOffset), 0, GetActualPixel(rightSpace), GetActualPixel(height));
titleBar.SetDragRectangles(new RectInt32[] { leftRect, rightRect });
}
P.S. 96 是一个参考的标准数值,指在 100% 缩放下的DPI,但该值并不是固定不变的,只能说适用于绝大多数情况。
这样,拖拽区域的问题就解决啦!你也不必担心设置拖拽区域会影响到正常标题栏的功能。在设置的拖拽区域内,标题栏的快捷操作依然正常进行。
修改按钮颜色
解决了最大的拖拽问题后,还有一个小问题,就是三大金刚按钮的颜色。
这个反而是好解决的,因为 API 很完备,和 UWP 几乎一致。
我们可以把设置方式整理成一个方法,里面包含扩展标题栏的设置:
public static void InitializeTitleBar(AppWindowTitleBar bar, ApplicationTheme theme)
{
bar.ExtendsContentIntoTitleBar = true;
if (theme == ApplicationTheme.Light)
{
// 设置成自己预期的颜色即可
bar.ButtonBackgroundColor = Colors.Wheat;
bar.ButtonForegroundColor = Colors.DarkGray;
bar.ButtonHoverBackgroundColor = Colors.LightGray;
bar.ButtonHoverForegroundColor = Colors.DarkGray;
bar.ButtonPressedBackgroundColor = Colors.Gray;
bar.ButtonPressedForegroundColor = Colors.DarkGray;
bar.ButtonInactiveBackgroundColor = Colors.Wheat;
bar.ButtonInactiveForegroundColor = Colors.Gray;
}
else
{
// 暗黑模式自行设置
}
}
在 App.xaml.cs 的 OnLaunched 事件回调中调用即可。
遗留问题
在开发中,我还碰到一个棘手的问题,到现在还没有找到合适的解决方法,也可能是 bug。
在上述代码完成后,启动应用,一切正常,但是当我调整窗口大小到一个较小值后,我发现无法再点击搜索框了,即便回到较大的窗口大小也一样。
通过简单的点击拖拽判断,此时的拖拽区域也并没有覆盖搜索框。
我被迫写了一个重置方法:
private void ResetTitleBar()
{
var titleBar = App.AppWindow.TitleBar;
titleBar.ResetToDefault();
App.InitializeTitleBar(titleBar);
UpdateDragRects();
}
在检查到窗口大小更改时延迟调用来处理,但是标题栏会有闪烁,降低用户体验。
希望以后可以解决该问题。