📢 隨着 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();
}
在檢查到窗口大小更改時延遲調用來處理,但是標題欄會有閃爍,降低用戶體驗。
希望以后可以解決該問題。