WinUI 3 踩坑記:第一個窗口

本文是 WinUI 3 踩坑記 的一部分,該系列發佈於 GitHub@Scighost/WinUI3Keng,文中的代碼也在此倉庫中,若內容出現衝突以 GitHub 上的為準。

WinUI 3 應用的入口和 UWP 類似,也是繼承自 Application 的一個類,略有不同的是沒有 UWP 那麼多的啟動方式可供重寫,只有一個 OnLaunched 可以重寫。OnLaunched 中的內容很簡單,就是構造一個主窗口並激活。

// App.xaml.cs

public partial class App : Application
{
    public App()
    {
        this.InitializeComponent();
    }

    protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
    {
        // 構造一個主窗口並激活
        m_window = new MainWindow();
        m_window.Activate();
    }

    private Window m_window;
}

本文將聚焦於主窗口 MainWindow,介紹 設置雲母或亞克力背景調整窗口位置大小自定義標題欄 等內容。

設置雲母或亞克力背景

設置背景材質的方法在官方文檔中有很詳細的方法,不再過多介紹,本文中使用的是我個人封裝的方法,源碼在這

// MainWindow.xaml.cs
using Scighost.WinUILib.Helpers;

private SystemBackdropHelper backdropHelper;

public MainWindow()
{
    this.InitializeComponent();
    backdropHelper = new SystemBackdropHelper(this);
    // 設置雲母背景,如果不支持則設置為亞克力背景
    backdropHelper.TrySetMica(fallbackToAcrylic: true);
}

調整窗口位置大小

創建窗口後的第一件事兒是幹什麼?
沒錯,就是獲取窗口句柄(HWND),這個流程和 WPF/UWP 截然不同,倒是和 Win32 很像。因為窗口類 Microsoft.UI.Xaml.Window 中幾乎沒有與窗口狀態有關的方法,而所謂的 HWND 高級封裝Microsoft.UI.Windowing.AppWindow 包含的方法也很有限,並且需要通過窗口句柄才能獲取。相比之下 WPF 幾乎封裝了所有關於窗口的常見操作,可見 WPF 在開發體驗方面更勝一籌。

// MainWindow.xaml.cs
// 命名空間真™亂
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;

private IntPtr hwnd;
private AppWindow appWindow;

public MainWindow()
{
    this.InitializeComponent();
    hwnd = WindowNative.GetWindowHandle(this);
    WindowId id = Win32Interop.GetWindowIdFromWindow(hwnd);
    appWindow = AppWindow.GetFromWindowId(id);
}

WinUI 3 不會自動保存窗口大小和位置,這個功能需要自己實現,也沒有窗口最大化的方法,需要調用 Win32 Api。

// MainWindow.xaml.cs
using Vanara.PInvoke;
using Windows.Graphics;

// 窗口最大化
User32.ShowWindow(hwnd, ShowWindowCommand.SW_SHOWMAXIMIZED);
// 調整窗口位置和大小,以屏幕像素為單位
appWindow.MoveAndResize(new RectInt32(_X: 560, _Y: 280, _Width: 800, _Height: 600));

一般流程為在窗口關閉時保存位置和大小,啟動時加載保存的設置,這裡我們使用 應用包的設置功能,但是該 Api 能夠存儲的數據類型不包括 Windows.Graphics.RectInt32,稍微對數據模型做一些調整。

注意:非打包應用不能使用應用包的設置功能

// MainWindow.xaml.cs
using Microsoft.UI.Windowing;
using Vanara.PInvoke;
using Windows.Graphics;
using Windows.Storage;
using System.Runtime.InteropServices;

public sealed partial class MainWindow : Window
{
    ......
    
    public MainWindow()
    {
        ......

        // 初始化窗口大小和位置
        this.Closed += MainWindow_Closed;
        if (ApplicationData.Current.LocalSettings.Values["IsMainWindowMaximum"] is true)
        {
            // 最大化
            User32.ShowWindow(hwnd, ShowWindowCommand.SW_SHOWMAXIMIZED);
        }
        else if (ApplicationData.Current.LocalSettings.Values["MainWindowRect"] is ulong value)
        {
            var rect = new WindowRect(value);
            // 屏幕區域
            var area = DisplayArea.GetFromWindowId(windowId: id, DisplayAreaFallback.Primary);
            // 若窗口在屏幕範圍之內
            if (rect.Left > 0 && rect.Top > 0 && rect.Right < area.WorkArea.Width && rect.Bottom < area.WorkArea.Height)
            {
                appWindow.MoveAndResize(rect.ToRectInt32());
            }
        }
    }

    private void MainWindow_Closed(object sender, WindowEventArgs args)
    {
        // 保存窗口狀態
        var wpl = new User32.WINDOWPLACEMENT();
        if (User32.GetWindowPlacement(hwnd, ref wpl))
        {
            ApplicationData.Current.LocalSettings.Values["IsMainWindowMaximum"] = wpl.showCmd == ShowWindowCommand.SW_MAXIMIZE;
            var p = appWindow.Position;
            var s = appWindow.Size;
            var rect = new WindowRect(p.X, p.Y, s.Width, s.Height);
            ApplicationData.Current.LocalSettings.Values["MainWindowRect"] = rect.Value;
        }
    }

    /// <summary>
    /// RectInt32 和 ulong 相互轉換
    /// </summary>
    [StructLayout(LayoutKind.Explicit)]
    private struct WindowRect
    {
        [FieldOffset(0)]
        public short X;
        [FieldOffset(2)]
        public short Y;
        [FieldOffset(4)]
        public short Width;
        [FieldOffset(6)]
        public short Height;
        [FieldOffset(0)]
        public ulong Value;

        public int Left => X;
        public int Top => Y;
        public int Right => X + Width;
        public int Bottom => Y + Height;

        public WindowRect(int x, int y, int width, int height)
        {
            X = (short)x;
            Y = (short)y;
            Width = (short)width;
            Height = (short)height;
        }

        public WindowRect(ulong value)
        {
            Value = value;
        }

        public RectInt32 ToRectInt32()
        {
            return new RectInt32(X, Y, Width, Height);
        }
    }

}

到此為止已經完成了窗口狀態的全部功能。

自定義標題欄

自定義標題欄是每個應用都應該做的事情,畢竟窗口頂部突然出現一個孤零零白條多少有點煞風景。

TitleBarCompare

WinUI 3 提供了兩種方法自定義標題欄,有關這兩種方法更詳細的內容,請看文檔

使用 Window 自帶的屬性

通過設置 Window.ExtendsContentIntoTitleBar = true 將客戶區內容擴展到標題欄,用法比較簡單,然後還需要調用 SetTitleBar(UIElement titleBar) 告訴系統可拖動區域的範圍,這裡的 titleBar 是在 xaml 文件中定義的控件,調用此 Api 後會將控件覆蓋的部分設置為可拖動區域。

// MainWindow.xaml.cs
using Microsoft.UI.Xaml;

this.ExtendsContentIntoTitleBar = true;
this.SetTitleBar(AppTitleBar);
<!-- MainWindow.xaml -->
<Grid>
    <Border x:Name="AppTitleBar"
            Height="48"
            VerticalAlignment="Top">
        <TextBlock VerticalAlignment="Center" Text="WinUI Desktop" />
    </Border>
</Grid>
<!-- App.xaml -->
<!-- 右上角按鍵的背景色設置為透明 -->
<StaticResource x:Key="WindowCaptionBackground" ResourceKey="ControlFillColorTransparentBrush" />
<StaticResource x:Key="WindowCaptionBackgroundDisabled" ResourceKey="ControlFillColorTransparentBrush" />

不過這個方法存在兩個問題:

第一,使用 SetTitleBar 設置的可拖動區域必須是一塊完整的區域,並且處於該範圍內的所有控件不能再被點擊,所以使用這個方法不能實現微軟商店那種標題欄中嵌入搜索框的功能。

為了一探究竟,使用 Spy++ 查看窗口的屬性,如下圖所示。

image-20220917180758423

這裡的 WinUI Desktop 是主窗口,內部有兩個子窗口,從名稱可以看出來 DRAG_BAR_WINDOW_CLASS 和拖動功能相關,查看它的大小和位置,剛好是前面使用 SetTitleBar 設置的範圍(系統縮放率 150%)。第二個 DesktopChildSiteBridge 則是託管 UI 內容的 Xaml Island。

image-20220917181033846

由此可以得出結論,對自定義標題欄的鼠標操作會傳遞到 DRAG_BAR_WINDOW_CLASS,而 DesktopChildSiteBridge 不會收到相關消息,所以該區域下的所以控件都無法被點擊,該原理也決定了可拖動區域只能為矩形。使用 Spy++ 查看窗口消息內容也證明了這一點(圖略)。

第二,點擊右上角三個按鍵後操作無法取消,即使把鼠標移開後,鬆開按鍵時也會觸發操作,具體的行為可以查看這個 issue

使用 AppWindowTitleBar

AppwindowTitleBar 是 Windows 11 上的方法,相比前者可以設置多個可拖動區域,這使得標題欄的控件交互操作成為可能。並且如果不主動設置可拖動區域,那麼原標題欄的區域則會自動成為可拖動區域。

// MainWindow.xaml.cs

// 檢查是否支持此方法
if (AppWindowTitleBar.IsCustomizationSupported())
{
    // 不支持時 titleBar 為 null
    titleBar = appWindow.TitleBar;
    titleBar.ExtendsContentIntoTitleBar = true;
    // 標題欄按鍵背景色設置為透明
    titleBar.ButtonBackgroundColor = Colors.Transparent;
    titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
}

當手動設置可拖動區域時,一定要注意系統縮放率的問題,AppwindowTitleBar 設置的區域是以像素為單位,而 UI 中的控件會受到縮放率的影響變得更大,設置可拖動區域時需要手動乘上縮放率。

// MainWindow.xaml.cs
using Windows.Graphics;
using Vanara.PInvoke;

// 獲取系統縮放率
var scale = (float)User32.GetDpiForWindow(hwnd) / 96;
// 48 這個值是應用標題欄的高度,不是唯一的,根據自己的 UI 設計而定
titleBar.SetDragRectangles(new RectInt32[] { new RectInt32(0, 0, 10000, (int)(48 * scale)) });

為什麼要把可拖動區域的寬度設置為 10000 呢?如果設置小了標題欄右側沒有覆蓋到的部分就會無法拖動,設置大了卻不會影響右上角的三個鍵(不會有人的顯示器像素寬度大於 10000 吧)。

修改可拖動區域

在很多情況下需要修改可拖動區域,最常見的就是窗口寬度變小時,NavigationView 的菜單按鍵會跑到上面,下圖中三橫線按鍵。

未標題-4

如果是使用 Window.SetTitleBar,那麼修改可拖動區域將會非常簡單,直接修改 AppTitleBar 控件的邊界大小就行,還無需考慮系統縮放率的影響。

AppTitleBar.Margin = new Thickness(96, 0, 0, 0);

如果是使用 AppWindowTitleBar.SetDragRectangles,那麼問題就來了,看下面這張圖片,如果先把橙色方框的範圍設置為可拖動區域,然後再把藍色方框的範圍設置為可拖動區域,這時候會發生什麼?

未標題-5

答案是藍色方框可以拖動,但是綠色方框既不能拖動,也不能點擊。這是 WindowsAppSDK v1.1 版本的一個 Bug,這個 Bug 基本上斷絕了在標題欄上修改控件布局的可能性。每次修改可拖動區域前可以通過調用 AppWindowTitleBar.ResetToDefault() 解決這個問題,但是那樣會有系統標題欄突然出現然後消失的情況,非常影響體驗。有關這個 Bug 的更詳細的內容可以查看 愛奇藝 Preview 的開發者 kingcean 提出的 issue,issue 中提到了 v1.2 preview 1 解決了這個 Bug,經過我的測試確實解決了。

v1.2 Preview 1 新功能

v1.2 preview 1 的更新內容中有提到已支持在 Windows 10 中使用 AppWindowTitleBar,在我的測試中 ExtendsContentIntoTitleBar 已可以使用並且成功將客戶區擴展到了標題欄。但是無論是否調用 SetDragRectangles 都無法拖動該窗口(參考這個 issue),等後續修復吧。

總結

WinUI 3 在窗口操作上比 WPF/UWP 麻煩了不少,許多常用的操作都沒有封裝,比如 最大最小化隱藏窗口 等。又因為在窗口上設計思路的不同,使得很多功能需要通過窗口句柄這個本應該被隱藏掉的東西去實現,這就是為什麼我要在前言中寫下了解 Win32 窗口相關知識。

人家微軟也有理由說的,我開發的是什麼框架,是最新一代的框架;你讓我封裝的是什麼東西,是 Win32 的老古董。哦喲,謝天謝地了。WinUI 3 現在什麼水平,改個窗口都這麼麻煩,它能火嗎?火不了,沒這個能力知道嗎。有 WPF 珠玉在前,拿什麼跟人家比,不被砍掉就算成功了。

image-20220917224204935