[WinUI3] 如何自定义桌面应用标题栏

📢 随着 Windows App SDK 1.0 的发布,Windows 应用开发也进入到了一个新的时期。虽然前景美好,但该框架还有一些不完善的地方,下文所述即是我在折腾 WinUI 3 时遇到的标题栏的坑,分享出来以供大家参考。

P.S. 下文所展示的缺陷可能会在将来的版本中修复,本文仅针对 Windows App SDK 1.0 版本。

场景说明

先上效果图:

Untitled.png

在 UWP 中,往标题栏放控件早已不是什么新鲜事了,比如 Microsoft Store:

Untitled 1.png

自定义的标题栏往往与应用主体更为契合,在代码实现上也不困难,这些在 UWP 文档上有详细的教程:Title bar customization - Windows apps | Microsoft Docs

我在设计应用时也会延续 UWP 的设计思路,使用自定义的标题栏,在里面放上返回按钮、菜单按钮、搜索框之类的控件。

在开发 UWP 时,一切得心应手,但是在桌面应用中,一切突然变得陌生了。

下面,请新建一个空白 WinUI 3 桌面应用,我们一步步来。

❗ 官方文档给出的示例,即 CoreApplication.GetCurrentView().TitleBar 那一套在桌面应用中是不行的,由于应用模型不同,该方法不会返回正确的结果,而是抛出异常 (Element not found)。在桌面应用中,我们只能走窗口管理API这条路。

遇到的困难

  1. 双重标题栏

    对于 WinUI 3 桌面应用来说,它的标题栏不止一个。

    第一个标题栏(位于AppWindow)

    Untitled 2.png

    第二个标题栏(WindowChrome)

    Untitled 3.png

    当我们在 App.xaml.cs 的 OnLaunched 事件回调里加上一句

    m_window.ExtendsContentIntoTitleBar = true;
    

    第二个标题栏就会出现。此时我们调整窗口宽度,就会显示出神奇的一幕:

    dragTitleBar.gif

    注意到了吗?第二个标题栏不光在调整大小时有延迟,而且在它没盖住的地方还会显现出下层“真正的”标题栏,也就是位于 AppWindow 的标题栏。

    这时候的问题在于,我们要在哪一个标题栏上做文章?

  2. 交互拦截

    当我们调用 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);
    }
    

    Untitled 4.png

    标题栏区域可以拖动,但两个按钮均无法点击,即便 Button B 的 ZIndex 高于 Button A。

  3. 背景覆盖

    你可能注意到上面的图片中没有 Windows 应用的“三大金刚”,即最小化/最大化和关闭按钮,原因很简单,我们给 MainWindow 的根元素(Grid)加了个背景色,同时我们又设置了 ExtendsContentIntoTitleBar 为 True,所以作为内容区的 Grid 的背景色就覆盖了位于 WindowChrome 上的 TitleBar,把三大金刚给盖住了。

    就TM离谱。

    为了解决颜色问题,要么把自定义标题栏搞成透明的,要么覆盖默认资源,但若是碰到自定义标题栏高度和默认高度不一样,又不能覆盖默认按钮,那就有意思了,可能会这样:

    Untitled 5.png

其它还有一些开发过程中会碰到的小问题,我们在后文详述。

实现方案

按照文档 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();
}

此时运行应用,你会看到这样的结果:

Untitled 6.png

应用顶部与标题栏等高的区域是可以拖动的哦~

创建自定义标题栏

为了实现我们预期的设计:

Untitled.png

现在需要创建一个自定义控件,名为 AppTitleBar

Untitled 7.png

在 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>

此时运行应用,应该如下所示:

Untitled 8.png

此时我们并没有指定 AppTitleBar 为应用的标题栏,所以你能发现,一个28像素的透明标题栏依然盖在控件上方,被它覆盖的区域我们不能点击到下方的搜索框。但此时我们也可以发现,即便我们给标题栏设置了背景色,它也没有覆盖三大金刚,并且调整窗口大小也不会有奇怪的残影,这非常好!

设置拖拽区域

上一步结束之后,是不是就要把 AppTitleBar 指定为应用的标题栏呢?

非也。

你一旦在 MainWindow 中调用 SetTitleBar(AppTitleBar),你会发现……啥都没变。

因为在 MainWindow 中调用 SetTitleBar 方法,会将指定的 UIElement 设置到 WindowChrome 上,而在此之前,你必须要在 MainWindow 中设置 ExtendsContentIntoTitleBar = true 让 WindowChrome 显示出来才行。

我们既已选择了走 AppWindow 这条路,就忘了 WindowChrome 吧。

那么接下来我们怎么做?

我们现在的问题是什么?

标题栏的区域盖住了本应提供交互的区域,同时我们预期的标题栏高度要大于默认高度,所以默认的标题栏高度又不够,就像下图所示:

default_drag.png

所以说,我们要解决两个问题:

  1. 调整标题栏的高度,让它和控件一致。
  2. 不让标题栏盖住我们预期提供交互的区域(这里指搜索框)。

目前 WinUI 3 文档匮乏,没有文档告诉我们该怎么做。这里就很有意思了,我们要思考一件事,到底是什么盖住了内容区?

是标题栏吗?是,但更进一步,是标题栏的可拖拽区域盖住了内容区。

拖拽区拦截了我们所需要的交互事件,转而为窗口拖拽和窗口快捷操作(比如双击标题栏全屏)服务。

那么想到这里,我们就能把前面的问题转化成另一个问题:如何控制标题栏可拖拽区域的大小和位置?

AppWindowTitleBar.SetDragRectangles(RectInt32[])

方法名很直观的表明了该 API 的用途,所以我们的问题就可以通过该方法得到解决。

再来分析一下我们的布局:

custom_drag.png

由于三大金刚按钮始终置顶,所以我们可以忽略覆盖它们的问题,这样我们就用搜索框分割出了两个拖拽区域。

这两个拖拽区域就是我们要传给 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();
}

在检查到窗口大小更改时延迟调用来处理,但是标题栏会有闪烁,降低用户体验。

希望以后可以解决该问题。

posted @ 2021-11-26 14:48  云之幻  阅读(3443)  评论(6编辑  收藏  举报