[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();
}

在檢查到窗口大小更改時延遲調用來處理,但是標題欄會有閃爍,降低用戶體驗。

希望以后可以解決該問題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM