WinUI 3 踩坑記:第一個視窗

2022-09-18 06:00:28

本文是 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);
        }
    }

}

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

自定義標題列

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

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++ 檢視視窗的屬性,如下圖所示。

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

由此可以得出結論,對自定義標題列的滑鼠操作會傳遞到 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 的選單按鍵會跑到上面,下圖中三橫線按鍵。

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

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

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

答案是藍色方框可以拖動,但是綠色方框既不能拖動,也不能點選。這是 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 珠玉在前,拿什麼跟人家比,不被砍掉就算成功了。