WPF 穩定的全屏化視窗方法

firespeed發表於2024-06-05

本文來告訴大家在 WPF 中,設定視窗全屏化的一個穩定的設定方法。在設定視窗全屏的時候,經常遇到的問題就是應用程式雖然設定最大化加無邊框,但是此方式經常會有工作列冒出來,或者說視窗沒有貼螢幕的邊。本文的方法是基於 Win32 的,由 lsj 提供的方法,當前已在 1000 多萬臺裝置上穩定執行超過三年時間,只有很少的電腦才偶爾出現工作列不消失的情況

簡單的 WPF 全屏視窗只需設定 WindowStyle 和 WindowState 屬性即可,如以下 XAML 程式碼

<Window 
        ...
        Title="MainWindow" Height="450" Width="800"
        WindowStyle="None" WindowState="Maximized">
        ...
</Window>

或如下的後臺 cs 程式碼

        Window window = xxx;
        window.WindowStyle = WindowStyle.None;
        window.WindowState = WindowState.Maximized;

儘管以上的方法足夠簡單且大部分情況下行之有效,然而在很多使用者的裝置上都會常遇到工作列冒出來,或者說視窗沒有貼螢幕的邊等問題

本文提供了基於 win32 的穩定方法,經過了大量裝置的執行測試,基本可以確認本文的方法是非常穩定的全屏視窗的方法,只有很少的電腦才偶爾出現工作列不消失的情況。本文的所使用的方法由 lsj 提供,我只是一個記錄此技術的工具人

本文的方法核心方式是透過 Hook 的方式獲取當前視窗的 Win32 訊息,在訊息裡面獲取顯示器資訊,根據獲取顯示器資訊來設定視窗的尺寸和左上角的值。可以支援在全屏,多屏的裝置上穩定設定全屏。支援在全屏之後,視窗可透過 API 方式(也可以用 Win + Shift + Left/Right)移動,調整大小,但會根據目標矩形尋找顯示器重新調整到全屏狀態

設定全屏在 Windows 的要求就是覆蓋螢幕的每個畫素,也就是要求視窗蓋住整個螢幕、視窗沒有WS_THICKFRAME樣式、視窗不能有標題欄且最大化

使用本文提供的 FullScreenHelper 類的 StartFullScreen 方法即可進入全屏。進入全屏的視窗必須具備的要求如上文所述,不能有標題欄。如以下的演示例子,設定視窗樣式 WindowStyle="None" 如下面程式碼

<Window x:Class="KenafearcuweYemjecahee.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:KenafearcuweYemjecahee"
        mc:Ignorable="d" WindowStyle="None"
        Title="MainWindow" Height="450" Width="800"/>

視窗樣式不是強行要求,可以根據自己的業務決定。但如果有視窗樣式,那將根據視窗的樣式決定全屏的行為。我推薦預設設定為 WindowStyle="None" 用於解決預設的視窗沒有貼邊的問題

為了演示如何呼叫全屏方法,我在視窗新增一個按鈕,在點選按鈕時,在後臺程式碼進入或退出全屏

    <ToggleButton HorizontalAlignment="Center" VerticalAlignment="Center" Click="Button_OnClick">全屏</ToggleButton>

以下是點選按鈕的邏輯

        private void Button_OnClick(object sender, RoutedEventArgs e)
        {
            var toggleButton = (ToggleButton)sender;

            if (toggleButton.IsChecked is true)
            {
                FullScreenHelper.StartFullScreen(this);
            }
            else
            {
                FullScreenHelper.EndFullScreen(this);
            }
        }

本文其實是將原本團隊內部的邏輯抄了一次,雖然我能保證團隊內的版本是穩定的,但是我不能保證在抄的過程中,我寫了一些逗比邏輯,讓這個全屏程式碼不穩定

以下是具體的實現方法,如不想了解細節,那請到本文最後複製程式碼即可。本文的方法已經合入到 https://github.com/HandyOrg/HandyControl 倉庫,不想抄程式碼的夥伴可以直接使用 https://www.nuget.org/packages/HandyControl

先來聊聊 StartFullScreen 方法的實現。此方法需要實現讓沒有全屏的視窗進入全屏,已進入全屏的視窗啥都不做。在視窗退出全屏時,還原進入全屏之前的視窗的狀態。為此,設定兩個附加屬性,用來分別記錄視窗全屏前位置和樣式的附加屬性,在進入全屏視窗的方法嘗試獲取視窗資訊設定到附加屬性

        /// <summary>
        /// 用於記錄視窗全屏前位置的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowPlacementProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowPlacement", typeof(WINDOWPLACEMENT?),
                typeof(Window));

        /// <summary>
        /// 用於記錄視窗全屏前樣式的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowStyleProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowStyle", typeof(WindowStyles?), typeof(Window));

        public static void StartFullScreen(Window window)
        {
            //確保不在全屏模式
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) == null &&
                window.GetValue(BeforeFullScreenWindowStyleProperty) == null)
            {
                var hwnd = new WindowInteropHelper(window).EnsureHandle();
                var hwndSource = HwndSource.FromHwnd(hwnd);

                //獲取當前視窗的位置大小狀態並儲存
                var placement = new WINDOWPLACEMENT();
                placement.Size = (uint) Marshal.SizeOf(placement);
                Win32.User32.GetWindowPlacement(hwnd, ref placement);
                window.SetValue(BeforeFullScreenWindowPlacementProperty, placement);

                //獲取視窗樣式
                var style = (WindowStyles) Win32.User32.GetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE);
                window.SetValue(BeforeFullScreenWindowStyleProperty, style);
            }
            else
            {
                 // 視窗在全屏,啥都不用做
            }
        }

以上程式碼用到的 Win32 方法和型別定義,都可以在本文最後獲取到,在這裡就不詳細寫出

在進入全屏模式時,需要完成的步驟如下

  • 需要將視窗恢復到還原模式,在有標題欄的情況下最大化模式下無法全屏。去掉 WS_MAXIMIZE 樣式,使視窗變成還原狀。不能使用 ShowWindow(hwnd, ShowWindowCommands.SW_RESTORE) 方法,避免看到視窗變成還原狀態這一過程,也避免影響視窗的 Visible 狀態

  • 需要去掉 WS_THICKFRAME 樣式,在有該樣式的情況下不能全屏

  • 去掉 WS_MAXIMIZEBOX 樣式,禁用最大化,如果最大化會退出全屏

   style &= (~(WindowStyles.WS_THICKFRAME | WindowStyles.WS_MAXIMIZEBOX | WindowStyles.WS_MAXIMIZE));
   Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE, (IntPtr) style);

以上寫法是 Win32 函式呼叫的特有方式,習慣就好。在 Win32 的函式設計中,因為當初每個位元組都是十分寶貴的,所以恨不得一個位元組當成兩個來用,這也就是引數為什麼透過列舉的二進位制方式,看起來很複雜的邏輯設定的原因

全屏的過程,如果有 DWM 動畫,將會看到視窗閃爍。因此如果裝置上有開啟 DWM 那麼進行關閉動畫。對應的,需要在退出全屏的時候,重新開啟 DWM 過渡動畫

                //禁用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 1,
                    sizeof(int));

接著就是本文的核心邏輯部分,透過 Hook 的方式修改視窗全屏,使用如下程式碼新增 Hook 用來拿到視窗訊息

                //新增Hook,在視窗尺寸位置等要發生變化時,確保全屏
                hwndSource.AddHook(KeepFullScreenHook);

private static IntPtr KeepFullScreenHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
	// 程式碼忽略,在下文將告訴大家
}       

為了觸發 KeepFullScreenHook 方法進行實際的設定視窗全屏,可以透過設定一下視窗的尺寸的方法,如下面程式碼

                if (Win32.User32.GetWindowRect(hwnd, out var rect))
                {
                    //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                    //使用視窗當前的矩形呼叫下設定視窗位置和尺寸的方法,讓Hook來進行調整視窗位置和尺寸到全屏模式
                    Win32.User32.SetWindowPos(hwnd, (IntPtr) HwndZOrder.HWND_TOP, rect.Left, rect.Top, rect.Width,
                        rect.Height, (int) WindowPositionFlags.SWP_NOZORDER);
                }

這就是 StartFullScreen 的所有程式碼

        /// <summary>
        /// 開始進入全屏模式
        /// 進入全屏模式後,視窗可透過 API 方式(也可以用 Win + Shift + Left/Right)移動,調整大小,但會根據目標矩形尋找顯示器重新調整到全屏狀態。
        /// 進入全屏後,不要修改樣式等視窗屬性,在退出時,會恢復到進入前的狀態
        /// 進入全屏模式後會禁用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void StartFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保不在全屏模式
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) == null &&
                window.GetValue(BeforeFullScreenWindowStyleProperty) == null)
            {
                var hwnd = new WindowInteropHelper(window).EnsureHandle();
                var hwndSource = HwndSource.FromHwnd(hwnd);

                //獲取當前視窗的位置大小狀態並儲存
                var placement = new WINDOWPLACEMENT();
                placement.Size = (uint) Marshal.SizeOf(placement);
                Win32.User32.GetWindowPlacement(hwnd, ref placement);
                window.SetValue(BeforeFullScreenWindowPlacementProperty, placement);

                //修改視窗樣式
                var style = (WindowStyles) Win32.User32.GetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE);
                window.SetValue(BeforeFullScreenWindowStyleProperty, style);
                //將視窗恢復到還原模式,在有標題欄的情況下最大化模式下無法全屏,
                //這裡採用還原,不修改標題欄的方式
                //在退出全屏時,視窗原有的狀態會恢復
                //去掉WS_THICKFRAME,在有該樣式的情況下不能全屏
                //去掉WS_MAXIMIZEBOX,禁用最大化,如果最大化會退出全屏
                //去掉WS_MAXIMIZE,使視窗變成還原狀態,不使用ShowWindow(hwnd, ShowWindowCommands.SW_RESTORE),避免看到視窗變成還原狀態這一過程(也避免影響視窗的Visible狀態)
                style &= (~(WindowStyles.WS_THICKFRAME | WindowStyles.WS_MAXIMIZEBOX | WindowStyles.WS_MAXIMIZE));
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE, (IntPtr) style);

                //禁用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 1,
                    sizeof(int));

                //新增Hook,在視窗尺寸位置等要發生變化時,確保全屏
                hwndSource.AddHook(KeepFullScreenHook);

                if (Win32.User32.GetWindowRect(hwnd, out var rect))
                {
                    //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                    //使用視窗當前的矩形呼叫下設定視窗位置和尺寸的方法,讓Hook來進行調整視窗位置和尺寸到全屏模式
                    Win32.User32.SetWindowPos(hwnd, (IntPtr) HwndZOrder.HWND_TOP, rect.Left, rect.Top, rect.Width,
                        rect.Height, (int) WindowPositionFlags.SWP_NOZORDER);
                }
            }
        }

在 KeepFullScreenHook 方法就是核心的邏輯,透過收到 Win 訊息,判斷是 WM_WINDOWPOSCHANGING 訊息,獲取當前螢幕範圍,設定給視窗

        /// <summary>
        /// 確保視窗全屏的Hook
        /// 使用HandleProcessCorruptedStateExceptions,防止訪問記憶體過程中因為一些致命異常導致程式崩潰
        /// </summary>
        [HandleProcessCorruptedStateExceptions]
        private static IntPtr KeepFullScreenHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            //處理WM_WINDOWPOSCHANGING訊息
            const int WINDOWPOSCHANGING = 0x0046;
            if (msg != WINDOWPOSCHANGING) return IntPtr.Zero;

            // 忽略程式碼
        }

此方法會用到一些 Win32 的記憶體訪問,雖然以上程式碼在實際測試中和在實際的使用者裝置上執行沒有發現問題,但是當時在寫的時候,為了防止訪問記憶體過程中因為一些致命異常導致程式崩潰,就加上了 HandleProcessCorruptedStateExceptions 特性。在 dotnet core 下,此 HandleProcessCorruptedStateExceptionsAttribute 特性已失效。詳細請看 升級到 dotnet core 之後 HandleProcessCorruptedStateExceptions 無法接住異常

按照 Win32 訊息的定義,可以先獲取WINDOWPOS結構體

                //得到WINDOWPOS結構體
                var pos = (WindowPosition) Marshal.PtrToStructure(lParam, typeof(WindowPosition));

                if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) != 0 &&
                    (pos.Flags & WindowPositionFlags.SWP_NOSIZE) != 0)
                {
                    //既然你既不改變位置,也不改變尺寸,我就不管了...
                    return IntPtr.Zero;
                }

透過 IsIconic 方法判斷當前視窗是否被最小化,如果最小化也不做全屏

                if (Win32.User32.IsIconic(hwnd))
                {
                    // 如果在全屏期間最小化了視窗,那麼忽略後續的位置調整。
                    // 否則按後續邏輯,會根據視窗在 -32000 的位置,計算出錯誤的目標位置,然後就跳到主屏了。
                    return IntPtr.Zero;
                }

如果在最小化也做全屏,將會因為最小化的視窗的 Y 座標在 -32000 的位置,在全屏的裝置上,如果是在副屏最小化的,將會計算出錯誤的目標位置,然後就跳到主屏了

獲取視窗的現在的矩形,用來計算視窗所在顯示器資訊,然後將顯示器的範圍設定給視窗

                //獲取視窗現在的矩形,下面用來參考計算目標矩形
                if (Win32.User32.GetWindowRect(hwnd, out var rect))
                {
                    var targetRect = rect; //視窗想要變化的目標矩形

                    if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) == 0)
                    {
                        //需要移動
                        targetRect.Left = pos.X;
                        targetRect.Top = pos.Y;
                    }

                    if ((pos.Flags & WindowPositionFlags.SWP_NOSIZE) == 0)
                    {
                        //要改變尺寸
                        targetRect.Right = targetRect.Left + pos.Width;
                        targetRect.Bottom = targetRect.Top + pos.Height;
                    }
                    else
                    {
                        //不改變尺寸
                        targetRect.Right = targetRect.Left + rect.Width;
                        targetRect.Bottom = targetRect.Top + rect.Height;
                    }

                    //使用目標矩形獲取顯示器資訊
                    var monitor = Win32.User32.MonitorFromRect(targetRect, MonitorFlag.MONITOR_DEFAULTTOPRIMARY);
                    var info = new MonitorInfo();
                    info.Size = (uint) Marshal.SizeOf(info);
                    if (Win32.User32.GetMonitorInfo(monitor, ref info))
                    {
                        //基於顯示器資訊設定視窗尺寸位置
                        pos.X = info.MonitorRect.Left;
                        pos.Y = info.MonitorRect.Top;
                        pos.Width = info.MonitorRect.Right - info.MonitorRect.Left;
                        pos.Height = info.MonitorRect.Bottom - info.MonitorRect.Top;
                        pos.Flags &= ~(WindowPositionFlags.SWP_NOSIZE | WindowPositionFlags.SWP_NOMOVE |
                                       WindowPositionFlags.SWP_NOREDRAW);
                        pos.Flags |= WindowPositionFlags.SWP_NOCOPYBITS;

                        if (rect == info.MonitorRect)
                        {
                            var hwndSource = HwndSource.FromHwnd(hwnd);
                            if (hwndSource?.RootVisual is Window window)
                            {
                                //確保視窗的 WPF 屬性與 Win32 位置一致,防止有逗比全屏後改 WPF 的屬性,發生一些詭異的行為
                                //下面這樣做其實不太好,會再次觸發 WM_WINDOWPOSCHANGING 來著.....但是又沒有其他時機了
                                // WM_WINDOWPOSCHANGED 不能用 
                                //(例如:在進入全屏後,修改 Left 屬性,會進入 WM_WINDOWPOSCHANGING,然後在這裡將訊息裡的結構體中的 Left 改回,
                                // 使對 Left 的修改無效,那麼將不會進入 WM_WINDOWPOSCHANGED,視窗尺寸正常,但視窗的 Left 屬性值錯誤。)
                                var logicalPos =
                                    hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                        new System.Windows.Point(pos.X, pos.Y));
                                var logicalSize =
                                    hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                        new System.Windows.Point(pos.Width, pos.Height));
                                window.Left = logicalPos.X;
                                window.Top = logicalPos.Y;
                                window.Width = logicalSize.X;
                                window.Height = logicalSize.Y;
                            }
                            else
                            {
                                //這個hwnd是前面從Window來的,如果現在他不是Window...... 你信麼
                            }
                        }

                        //將修改後的結構體複製回去
                        Marshal.StructureToPtr(pos, lParam, false);
                    }
                }

這就是在 Hook 裡面的邏輯,接下來看退出全屏的方法

在退出全屏需要設定為視窗進入全屏之前的樣式等資訊

        /// <summary>
        /// 退出全屏模式
        /// 視窗會回到進入全屏模式時儲存的狀態
        /// 退出全屏模式後會重新啟用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void EndFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保在全屏模式並獲取之前儲存的狀態
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) is WINDOWPLACEMENT placement
                && window.GetValue(BeforeFullScreenWindowStyleProperty) is WindowStyles style)
            {
                var hwnd = new WindowInteropHelper(window).Handle;

                if (hwnd == IntPtr.Zero)
                {
                    // 控制代碼為 0 只有兩種情況:
                    //  1. 雖然視窗已進入全屏,但視窗已被關閉;
                    //  2. 視窗初始化前,在還沒有呼叫 StartFullScreen 的前提下就呼叫了此方法。
                    // 所以,直接 return 就好。
                    return;
                }

                var hwndSource = HwndSource.FromHwnd(hwnd);

                //去除hook
                hwndSource.RemoveHook(KeepFullScreenHook);

                //恢復儲存的狀態
                //不要改變Style裡的WS_MAXIMIZE,否則會使視窗變成最大化狀態,但是尺寸不對
                //也不要設定回Style裡的WS_MINIMIZE,否則會導致視窗最小化按鈕顯示成還原按鈕
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE,
                    (IntPtr) (style & (~(WindowStyles.WS_MAXIMIZE | WindowStyles.WS_MINIMIZE))));

                if ((style & WindowStyles.WS_MINIMIZE) != 0)
                {
                    //如果視窗進入全屏前是最小化的,這裡不讓視窗恢復到之前的最小化狀態,而是到還原的狀態。
                    //大多數情況下,都不期望在退出全屏的時候,恢復到最小化。
                    placement.ShowCmd = Win32.ShowWindowCommands.SW_RESTORE;
                }

                if ((style & WindowStyles.WS_MAXIMIZE) != 0)
                {
                    //提前呼叫 ShowWindow 使視窗恢復最大化,若透過 SetWindowPlacement 最大化會導致閃爍,只靠其恢復 RestoreBounds.
                    Win32.User32.ShowWindow(hwnd, Win32.ShowWindowCommands.SW_MAXIMIZE);
                }

                Win32.User32.SetWindowPlacement(hwnd, ref placement);

                if ((style & WindowStyles.WS_MAXIMIZE) ==
                    0) //如果視窗是最大化就不要修改WPF屬性,否則會破壞RestoreBounds,且WPF視窗自身在最大化時,不會修改 Left Top Width Height 屬性
                {
                    if (Win32.User32.GetWindowRect(hwnd, out var rect))
                    {
                        //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                        //確保視窗的 WPF 屬性與 Win32 位置一致
                        var logicalPos =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Left, rect.Top));
                        var logicalSize =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Width, rect.Height));
                        window.Left = logicalPos.X;
                        window.Top = logicalPos.Y;
                        window.Width = logicalSize.X;
                        window.Height = logicalSize.Y;
                    }
                }

                //重新啟用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 0,
                    sizeof(int));

                //刪除儲存的狀態
                window.ClearValue(BeforeFullScreenWindowPlacementProperty);
                window.ClearValue(BeforeFullScreenWindowStyleProperty);
            }
        }

下面是 FullScreenHelper 的核心程式碼,此型別依賴一些 Win32 方法的定義,這部分我就不在部落格中寫出,大家可以從本文最後獲取所有原始碼

    /// <summary>
    /// 用來使視窗變得全屏的輔助類
    /// 採用設定視窗位置和尺寸,確保蓋住整個螢幕的方式來實現全屏
    /// 目前已知需要滿足的條件是:視窗蓋住整個螢幕、視窗沒有WS_THICKFRAME樣式、視窗不能有標題欄且最大化
    /// </summary>
    public static partial class FullScreenHelper
    {
        /// <summary>
        /// 用於記錄視窗全屏前位置的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowPlacementProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowPlacement", typeof(WINDOWPLACEMENT?),
                typeof(Window));

        /// <summary>
        /// 用於記錄視窗全屏前樣式的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowStyleProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowStyle", typeof(WindowStyles?), typeof(Window));

        /// <summary>
        /// 開始進入全屏模式
        /// 進入全屏模式後,視窗可透過 API 方式(也可以用 Win + Shift + Left/Right)移動,調整大小,但會根據目標矩形尋找顯示器重新調整到全屏狀態。
        /// 進入全屏後,不要修改樣式等視窗屬性,在退出時,會恢復到進入前的狀態
        /// 進入全屏模式後會禁用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void StartFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保不在全屏模式
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) == null &&
                window.GetValue(BeforeFullScreenWindowStyleProperty) == null)
            {
                var hwnd = new WindowInteropHelper(window).EnsureHandle();
                var hwndSource = HwndSource.FromHwnd(hwnd);

                //獲取當前視窗的位置大小狀態並儲存
                var placement = new WINDOWPLACEMENT();
                placement.Size = (uint) Marshal.SizeOf(placement);
                Win32.User32.GetWindowPlacement(hwnd, ref placement);
                window.SetValue(BeforeFullScreenWindowPlacementProperty, placement);

                //修改視窗樣式
                var style = (WindowStyles) Win32.User32.GetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE);
                window.SetValue(BeforeFullScreenWindowStyleProperty, style);
                //將視窗恢復到還原模式,在有標題欄的情況下最大化模式下無法全屏,
                //這裡採用還原,不修改標題欄的方式
                //在退出全屏時,視窗原有的狀態會恢復
                //去掉WS_THICKFRAME,在有該樣式的情況下不能全屏
                //去掉WS_MAXIMIZEBOX,禁用最大化,如果最大化會退出全屏
                //去掉WS_MAXIMIZE,使視窗變成還原狀態,不使用ShowWindow(hwnd, ShowWindowCommands.SW_RESTORE),避免看到視窗變成還原狀態這一過程(也避免影響視窗的Visible狀態)
                style &= (~(WindowStyles.WS_THICKFRAME | WindowStyles.WS_MAXIMIZEBOX | WindowStyles.WS_MAXIMIZE));
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE, (IntPtr) style);

                //禁用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 1,
                    sizeof(int));

                //新增Hook,在視窗尺寸位置等要發生變化時,確保全屏
                hwndSource.AddHook(KeepFullScreenHook);

                if (Win32.User32.GetWindowRect(hwnd, out var rect))
                {
                    //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                    //使用視窗當前的矩形呼叫下設定視窗位置和尺寸的方法,讓Hook來進行調整視窗位置和尺寸到全屏模式
                    Win32.User32.SetWindowPos(hwnd, (IntPtr) HwndZOrder.HWND_TOP, rect.Left, rect.Top, rect.Width,
                        rect.Height, (int) WindowPositionFlags.SWP_NOZORDER);
                }
            }
        }

        /// <summary>
        /// 退出全屏模式
        /// 視窗會回到進入全屏模式時儲存的狀態
        /// 退出全屏模式後會重新啟用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void EndFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保在全屏模式並獲取之前儲存的狀態
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) is WINDOWPLACEMENT placement
                && window.GetValue(BeforeFullScreenWindowStyleProperty) is WindowStyles style)
            {
                var hwnd = new WindowInteropHelper(window).Handle;

                if (hwnd == IntPtr.Zero)
                {
                    // 控制代碼為 0 只有兩種情況:
                    //  1. 雖然視窗已進入全屏,但視窗已被關閉;
                    //  2. 視窗初始化前,在還沒有呼叫 StartFullScreen 的前提下就呼叫了此方法。
                    // 所以,直接 return 就好。
                    return;
                }


                var hwndSource = HwndSource.FromHwnd(hwnd);

                //去除hook
                hwndSource.RemoveHook(KeepFullScreenHook);

                //恢復儲存的狀態
                //不要改變Style裡的WS_MAXIMIZE,否則會使視窗變成最大化狀態,但是尺寸不對
                //也不要設定回Style裡的WS_MINIMIZE,否則會導致視窗最小化按鈕顯示成還原按鈕
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE,
                    (IntPtr) (style & (~(WindowStyles.WS_MAXIMIZE | WindowStyles.WS_MINIMIZE))));

                if ((style & WindowStyles.WS_MINIMIZE) != 0)
                {
                    //如果視窗進入全屏前是最小化的,這裡不讓視窗恢復到之前的最小化狀態,而是到還原的狀態。
                    //大多數情況下,都不期望在退出全屏的時候,恢復到最小化。
                    placement.ShowCmd = Win32.ShowWindowCommands.SW_RESTORE;
                }

                if ((style & WindowStyles.WS_MAXIMIZE) != 0)
                {
                    //提前呼叫 ShowWindow 使視窗恢復最大化,若透過 SetWindowPlacement 最大化會導致閃爍,只靠其恢復 RestoreBounds.
                    Win32.User32.ShowWindow(hwnd, Win32.ShowWindowCommands.SW_MAXIMIZE);
                }

                Win32.User32.SetWindowPlacement(hwnd, ref placement);

                if ((style & WindowStyles.WS_MAXIMIZE) ==
                    0) //如果視窗是最大化就不要修改WPF屬性,否則會破壞RestoreBounds,且WPF視窗自身在最大化時,不會修改 Left Top Width Height 屬性
                {
                    if (Win32.User32.GetWindowRect(hwnd, out var rect))
                    {
                        //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                        //確保視窗的 WPF 屬性與 Win32 位置一致
                        var logicalPos =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Left, rect.Top));
                        var logicalSize =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Width, rect.Height));
                        window.Left = logicalPos.X;
                        window.Top = logicalPos.Y;
                        window.Width = logicalSize.X;
                        window.Height = logicalSize.Y;
                    }
                }

                //重新啟用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 0,
                    sizeof(int));

                //刪除儲存的狀態
                window.ClearValue(BeforeFullScreenWindowPlacementProperty);
                window.ClearValue(BeforeFullScreenWindowStyleProperty);
            }
        }

        /// <summary>
        /// 確保視窗全屏的Hook
        /// 使用HandleProcessCorruptedStateExceptions,防止訪問記憶體過程中因為一些致命異常導致程式崩潰
        /// </summary>
        [HandleProcessCorruptedStateExceptions]
        private static IntPtr KeepFullScreenHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            //處理WM_WINDOWPOSCHANGING訊息
            const int WINDOWPOSCHANGING = 0x0046;
            if (msg == WINDOWPOSCHANGING)
            {
                try
                {
                    //得到WINDOWPOS結構體
                    var pos = (WindowPosition) Marshal.PtrToStructure(lParam, typeof(WindowPosition));

                    if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) != 0 &&
                        (pos.Flags & WindowPositionFlags.SWP_NOSIZE) != 0)
                    {
                        //既然你既不改變位置,也不改變尺寸,我就不管了...
                        return IntPtr.Zero;
                    }

                    if (Win32.User32.IsIconic(hwnd))
                    {
                        // 如果在全屏期間最小化了視窗,那麼忽略後續的位置調整。
                        // 否則按後續邏輯,會根據視窗在 -32000 的位置,計算出錯誤的目標位置,然後就跳到主屏了。
                        return IntPtr.Zero;
                    }

                    //獲取視窗現在的矩形,下面用來參考計算目標矩形
                    if (Win32.User32.GetWindowRect(hwnd, out var rect))
                    {
                        var targetRect = rect; //視窗想要變化的目標矩形

                        if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) == 0)
                        {
                            //需要移動
                            targetRect.Left = pos.X;
                            targetRect.Top = pos.Y;
                        }

                        if ((pos.Flags & WindowPositionFlags.SWP_NOSIZE) == 0)
                        {
                            //要改變尺寸
                            targetRect.Right = targetRect.Left + pos.Width;
                            targetRect.Bottom = targetRect.Top + pos.Height;
                        }
                        else
                        {
                            //不改變尺寸
                            targetRect.Right = targetRect.Left + rect.Width;
                            targetRect.Bottom = targetRect.Top + rect.Height;
                        }

                        //使用目標矩形獲取顯示器資訊
                        var monitor = Win32.User32.MonitorFromRect(targetRect, MonitorFlag.MONITOR_DEFAULTTOPRIMARY);
                        var info = new MonitorInfo();
                        info.Size = (uint) Marshal.SizeOf(info);
                        if (Win32.User32.GetMonitorInfo(monitor, ref info))
                        {
                            //基於顯示器資訊設定視窗尺寸位置
                            pos.X = info.MonitorRect.Left;
                            pos.Y = info.MonitorRect.Top;
                            pos.Width = info.MonitorRect.Right - info.MonitorRect.Left;
                            pos.Height = info.MonitorRect.Bottom - info.MonitorRect.Top;
                            pos.Flags &= ~(WindowPositionFlags.SWP_NOSIZE | WindowPositionFlags.SWP_NOMOVE |
                                           WindowPositionFlags.SWP_NOREDRAW);
                            pos.Flags |= WindowPositionFlags.SWP_NOCOPYBITS;

                            if (rect == info.MonitorRect)
                            {
                                var hwndSource = HwndSource.FromHwnd(hwnd);
                                if (hwndSource?.RootVisual is Window window)
                                {
                                    //確保視窗的 WPF 屬性與 Win32 位置一致,防止有逗比全屏後改 WPF 的屬性,發生一些詭異的行為
                                    //下面這樣做其實不太好,會再次觸發 WM_WINDOWPOSCHANGING 來著.....但是又沒有其他時機了
                                    // WM_WINDOWPOSCHANGED 不能用 
                                    //(例如:在進入全屏後,修改 Left 屬性,會進入 WM_WINDOWPOSCHANGING,然後在這裡將訊息裡的結構體中的 Left 改回,
                                    // 使對 Left 的修改無效,那麼將不會進入 WM_WINDOWPOSCHANGED,視窗尺寸正常,但視窗的 Left 屬性值錯誤。)
                                    var logicalPos =
                                        hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                            new System.Windows.Point(pos.X, pos.Y));
                                    var logicalSize =
                                        hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                            new System.Windows.Point(pos.Width, pos.Height));
                                    window.Left = logicalPos.X;
                                    window.Top = logicalPos.Y;
                                    window.Width = logicalSize.X;
                                    window.Height = logicalSize.Y;
                                }
                                else
                                {
                                    //這個hwnd是前面從Window來的,如果現在他不是Window...... 你信麼
                                }
                            }

                            //將修改後的結構體複製回去
                            Marshal.StructureToPtr(pos, lParam, false);
                        }
                    }
                }
                catch
                {
                    // 這裡也不需要日誌啥的,只是為了防止上面有逗比邏輯,在訊息迴圈裡面炸了
                }
            }

            return IntPtr.Zero;
        }
    }

本文所有程式碼在 githubgitee 上完全開源

不嫌棄麻煩的話,還請自行下載程式碼,自己構建。可以透過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 5b0440c6617b87cdd9953dc68e706b22c5939ccb

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

獲取程式碼之後,進入 KenafearcuweYemjecahee 資料夾

特別感謝 lsj 提供的邏輯


透過 lsj 閱讀 Avalonia 的邏輯,找到了 ITaskbarList2::MarkFullscreenWindow 方法,透過此方式可以通知工作列不要顯示到最頂,以下是我測試的行為

當呼叫 ITaskbarList2::MarkFullscreenWindow 方法設定給到某個視窗時,如此視窗處於啟用狀態,此視窗所在的螢幕的工作列將不會置頂,工作列將會在其他視窗下方。這裡的其他視窗指的是任意的視窗,即工作列不再具備最頂層的特性。換句話說就是這個方法不會輔助視窗本身進入全屏,僅僅只是用於處理工作列在全屏視窗的行為,這也符合 ITaskbarList 介面的含義。而至於設定給到的某個視窗,此視窗是否真的全屏,那 MarkFullscreenWindow 方法也管不了了,也就是說即使設定給一個普通的非全屏的視窗,甚至非最大化的視窗,也是可以的

先編寫簡單的程式碼,用於測試 ITaskbarList2::MarkFullscreenWindow 的行為

先定義 ITaskbarList2 這個 COM 介面,程式碼如下

        [ComImport]
        [Guid("602D4995-B13A-429b-A66E-1935E44F4317")]
        [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
        private interface ITaskbarList2
        {
            [PreserveSig]
            int HrInit();

            [PreserveSig]
            int AddTab(IntPtr hwnd);

            [PreserveSig]
            int DeleteTab(IntPtr hwnd);

            [PreserveSig]
            int ActivateTab(IntPtr hwnd);

            [PreserveSig]
            int SetActiveAlt(IntPtr hwnd);

            [PreserveSig]
            int MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen);
        }

以上程式碼裡面的 InterfaceType 特性是必須的,需要加上 InterfaceIsIUnknown 引數。因為根據官方文件的如下描述可知道 ITaskbarList2 是繼承 ITaskbarList 的,而 ITaskbarList 是繼承 IUnknown 的

The ITaskbarList2 interface inherits from ITaskbarList. ITaskbarList2 also has these types of members The ITaskbarList interface inherits from the IUnknown interface.

在 dotnet 裡面,需要標記 [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] 特性,否則將會缺失 IUnknown 的預設幾個方法,導致實際 C# 程式碼呼叫的程式碼非預期,可能導致程序炸掉

以上程式碼裡面,咱需要關注使用的只有 MarkFullscreenWindow 方法。為了更好的進行測試,接下來編輯 MainWindow.xaml 新增一個按鈕,用於點選時進入或退出全屏模式,即呼叫 MarkFullscreenWindow 方法時,傳入的 fFullscreen 引數的值

        <ToggleButton HorizontalAlignment="Center" VerticalAlignment="Center" Click="Button_OnClick">全屏</ToggleButton>

編輯後臺程式碼,實現 Button_OnClick 功能

        private void Button_OnClick(object sender, RoutedEventArgs e)
        {
            var toggleButton = (ToggleButton) sender;

            FullScreenHelper.MarkFullscreenWindowTaskbarList(new WindowInteropHelper(this).Handle, toggleButton.IsChecked is true);
        }

以上的 FullScreenHelper.MarkFullscreenWindowTaskbarList 封裝方法的實現如下

    public static partial class FullScreenHelper
    {
        public static void MarkFullscreenWindowTaskbarList(IntPtr hwnd, bool isFullscreen)
        {
            try
            {
                var CLSID_TaskbarList = new Guid("56FDF344-FD6D-11D0-958A-006097C9A090");
                var obj = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_TaskbarList));
                (obj as ITaskbarList2)?.MarkFullscreenWindow(hwnd, isFullscreen);
            }
            catch
            {
                //應該不會掛
            }
        }
    }

完成以上程式碼執行的介面如下,可以看到這是一個非全屏也非最大化的視窗

以上程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin c8b8e6550b38d7fe109da5ed8fc63ab90c4dd7c5

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin c8b8e6550b38d7fe109da5ed8fc63ab90c4dd7c5

獲取程式碼之後,進入 KenafearcuweYemjecahee 資料夾,即可獲取到原始碼

接下來可以做一個測試實現,測試其行為

  1. 啟動程序視窗,即此視窗為主視窗,拖動主視窗在工作列位置。 此時可見工作列在主視窗上方
  2. 點選 全屏 按鈕,此時可見主視窗在工作列上方,即工作列在主視窗下方不會擋住主視窗
  3. 啟動記事本,拿到記事本視窗。此時可見主視窗失去焦點,顯示在工作列下方,即工作列擋住主視窗。此時拖動記事本視窗在工作列位置,再點選啟用主視窗,讓主視窗獲取焦點,可見工作列顯示在最下方,即工作列在主視窗和記事本視窗下方

透過以上行為測試,大概可以知道,此 MarkFullscreenWindow 方法的作用只是處理工作列是否在最頂層而已。只要設定給到 MarkFullscreenWindow 的控制代碼的視窗處於啟用獲取焦點狀態,那麼工作列就不會處於最頂層,將可能處於其他視窗的下方,即使其他視窗沒有呼叫 MarkFullscreenWindow 方法。因為此時完全就是靠視窗層級處理

另外 MarkFullscreenWindow 方法也沒有真的判斷傳入的視窗控制代碼對應的視窗是否真的處於全屏狀態,僅僅只是判斷傳入的視窗控制代碼對應處於啟用獲取焦點時就將工作列設定為非最頂層模式而已

估計在微軟底層實現是為了規避一些坑而作出如此詭異的行為。在此行為之下反而可以用在某些有趣的情況下,讓工作列不要處於最頂層,和是否全屏需求可能沒有強關係。但此方法也可以更好的處理全屏視窗時,工作列冒出來的問題

歡迎大家獲取我的程式碼進行更多的測試

在雙屏裝置下的 MarkFullscreenWindow 方法就更有趣了,簡單說就是雙屏模式下 MarkFullscreenWindow 隻影響主視窗所在的螢幕的工作列的狀態,另一個螢幕不受影響

在有雙屏的裝置上可以繼續上述測試行為,即上述測試行為在螢幕1上進行,現在還有螢幕2另一個螢幕

  1. 記原本啟動的記事本視窗為記事本1視窗,在螢幕1 啟動新的記事本,獲取記事本2視窗。此時主視窗自然丟失焦點,前臺視窗為剛啟動的記事本2視窗。工作列在最上層,即工作列蓋住主視窗
  2. 拖動記事本2視窗,從螢幕1 拖動到螢幕2 上,且沿著工作列拖動。可見當記事本2視窗拖動到螢幕2 時,螢幕1 的工作列回到主視窗下方,即螢幕1 的工作列沒有擋住主視窗和記事本1視窗。再將記事本2視窗從螢幕2 拖回螢幕1 上,可見當記事本2視窗拖回螢幕1 時,螢幕1 的工作列回到了最頂層狀態,即使工作列蓋住主視窗和兩個記事本的視窗
  3. 將記事本2視窗拖到螢幕2 上,點選螢幕1 的主視窗,讓螢幕1 的主視窗獲取焦點。此時符合預期的是主視窗在工作列之上,工作列沒有處於最頂層狀態。接著再點選螢幕2 的記事本2視窗,讓記事本2視窗獲取焦點啟用作為前臺視窗。此時可見螢幕1 的工作列依舊處於非最上層狀態,即主視窗在工作列之上,工作列沒有擋住主視窗。在以上過程中,螢幕2 的工作列都是保持最上層,即會擋住記事本2視窗。再將主視窗從螢幕1 拖動到螢幕2 上,可以看到當主視窗從螢幕1 拖動到螢幕2 時,螢幕1 的工作列處於最頂層狀態,可以擋住記事本1視窗,螢幕2 的工作列沒有處於最頂層狀態,在記事本2視窗下方

透過以上的測試可以看到,在 MarkFullscreenWindow 方法的判斷,其實只是判斷當前螢幕的啟用順序最高的視窗是否設定了 MarkFullscreenWindow 方法。如果是則讓此螢幕的工作列處於非最頂層的模式,相對來說多個螢幕下的邏輯會更加的複雜,從這個方面也能想象微軟在這個方法實現上有多少坑

基於 MarkFullscreenWindow 的機制,最佳化 FullScreenHelper 的程式碼,最佳化之後的程式碼如下

    /// <summary>
    /// 用來使視窗變得全屏的輔助類
    /// 採用設定視窗位置和尺寸,確保蓋住整個螢幕的方式來實現全屏
    /// 目前已知需要滿足的條件是:視窗蓋住整個螢幕、視窗沒有WS_THICKFRAME樣式、視窗不能有標題欄且最大化
    /// </summary>
    public static partial class FullScreenHelper
    {
        public static void MarkFullscreenWindowTaskbarList(IntPtr hwnd, bool isFullscreen)
        {
            try
            {
                var CLSID_TaskbarList = new Guid("56FDF344-FD6D-11D0-958A-006097C9A090");
                var obj = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_TaskbarList));
                (obj as ITaskbarList2)?.MarkFullscreenWindow(hwnd, isFullscreen);
            }
            catch
            {
                //應該不會掛
            }
        }

        /// <summary>
        /// 用於記錄視窗全屏前位置的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowPlacementProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowPlacement", typeof(WINDOWPLACEMENT?),
                typeof(Window));

        /// <summary>
        /// 用於記錄視窗全屏前樣式的附加屬性
        /// </summary>
        private static readonly DependencyProperty BeforeFullScreenWindowStyleProperty =
            DependencyProperty.RegisterAttached("BeforeFullScreenWindowStyle", typeof(WindowStyles?), typeof(Window));

        /// <summary>
        /// 開始進入全屏模式
        /// 進入全屏模式後,視窗可透過 API 方式(也可以用 Win + Shift + Left/Right)移動,調整大小,但會根據目標矩形尋找顯示器重新調整到全屏狀態。
        /// 進入全屏後,不要修改樣式等視窗屬性,在退出時,會恢復到進入前的狀態
        /// 進入全屏模式後會禁用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void StartFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保不在全屏模式
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) == null &&
                window.GetValue(BeforeFullScreenWindowStyleProperty) == null)
            {
                var hwnd = new WindowInteropHelper(window).EnsureHandle();
                var hwndSource = HwndSource.FromHwnd(hwnd);

                //獲取當前視窗的位置大小狀態並儲存
                var placement = new WINDOWPLACEMENT();
                placement.Size = (uint) Marshal.SizeOf(placement);
                Win32.User32.GetWindowPlacement(hwnd, ref placement);
                window.SetValue(BeforeFullScreenWindowPlacementProperty, placement);

                //修改視窗樣式
                var style = (WindowStyles) Win32.User32.GetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE);
                window.SetValue(BeforeFullScreenWindowStyleProperty, style);
                //將視窗恢復到還原模式,在有標題欄的情況下最大化模式下無法全屏,
                //這裡採用還原,不修改標題欄的方式
                //在退出全屏時,視窗原有的狀態會恢復
                //去掉WS_THICKFRAME,在有該樣式的情況下不能全屏
                //去掉WS_MAXIMIZEBOX,禁用最大化,如果最大化會退出全屏
                //去掉WS_MAXIMIZE,使視窗變成還原狀態,不使用ShowWindow(hwnd, ShowWindowCommands.SW_RESTORE),避免看到視窗變成還原狀態這一過程(也避免影響視窗的Visible狀態)
                style &= (~(WindowStyles.WS_THICKFRAME | WindowStyles.WS_MAXIMIZEBOX | WindowStyles.WS_MAXIMIZE));
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE, (IntPtr) style);

                //禁用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 1,
                    sizeof(int));

                //新增Hook,在視窗尺寸位置等要發生變化時,確保全屏
                hwndSource.AddHook(KeepFullScreenHook);

                if (Win32.User32.GetWindowRect(hwnd, out var rect))
                {
                    //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                    //使用視窗當前的矩形呼叫下設定視窗位置和尺寸的方法,讓Hook來進行調整視窗位置和尺寸到全屏模式
                    Win32.User32.SetWindowPos(hwnd, (IntPtr) HwndZOrder.HWND_TOP, rect.Left, rect.Top, rect.Width,
                        rect.Height, (int) WindowPositionFlags.SWP_NOZORDER);
                }

                MarkFullscreenWindowTaskbarList(hwnd, true);
            }
        }

        /// <summary>
        /// 退出全屏模式
        /// 視窗會回到進入全屏模式時儲存的狀態
        /// 退出全屏模式後會重新啟用 DWM 過渡動畫
        /// </summary>
        /// <param name="window"></param>
        public static void EndFullScreen(Window window)
        {
            if (window == null)
            {
                throw new ArgumentNullException(nameof(window), $"{nameof(window)} 不能為 null");
            }

            //確保在全屏模式並獲取之前儲存的狀態
            if (window.GetValue(BeforeFullScreenWindowPlacementProperty) is WINDOWPLACEMENT placement
                && window.GetValue(BeforeFullScreenWindowStyleProperty) is WindowStyles style)
            {
                var hwnd = new WindowInteropHelper(window).Handle;

                if (hwnd == IntPtr.Zero)
                {
                    // 控制代碼為 0 只有兩種情況:
                    //  1. 雖然視窗已進入全屏,但視窗已被關閉;
                    //  2. 視窗初始化前,在還沒有呼叫 StartFullScreen 的前提下就呼叫了此方法。
                    // 所以,直接 return 就好。
                    return;
                }

                var hwndSource = HwndSource.FromHwnd(hwnd);

                //去除hook
                hwndSource.RemoveHook(KeepFullScreenHook);

                //恢復儲存的狀態
                //不要改變Style裡的WS_MAXIMIZE,否則會使視窗變成最大化狀態,但是尺寸不對
                //也不要設定回Style裡的WS_MINIMIZE,否則會導致視窗最小化按鈕顯示成還原按鈕
                Win32.User32.SetWindowLongPtr(hwnd, GetWindowLongFields.GWL_STYLE,
                    (IntPtr) (style & (~(WindowStyles.WS_MAXIMIZE | WindowStyles.WS_MINIMIZE))));

                if ((style & WindowStyles.WS_MINIMIZE) != 0)
                {
                    //如果視窗進入全屏前是最小化的,這裡不讓視窗恢復到之前的最小化狀態,而是到還原的狀態。
                    //大多數情況下,都不期望在退出全屏的時候,恢復到最小化。
                    placement.ShowCmd = Win32.ShowWindowCommands.SW_RESTORE;
                }

                if ((style & WindowStyles.WS_MAXIMIZE) != 0)
                {
                    //提前呼叫 ShowWindow 使視窗恢復最大化,若透過 SetWindowPlacement 最大化會導致閃爍,只靠其恢復 RestoreBounds.
                    Win32.User32.ShowWindow(hwnd, Win32.ShowWindowCommands.SW_MAXIMIZE);
                }

                Win32.User32.SetWindowPlacement(hwnd, ref placement);

                if ((style & WindowStyles.WS_MAXIMIZE) ==
                    0) //如果視窗是最大化就不要修改WPF屬性,否則會破壞RestoreBounds,且WPF視窗自身在最大化時,不會修改 Left Top Width Height 屬性
                {
                    if (Win32.User32.GetWindowRect(hwnd, out var rect))
                    {
                        //不能用 placement 的座標,placement是工作區座標,不是螢幕座標。

                        //確保視窗的 WPF 屬性與 Win32 位置一致
                        var logicalPos =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Left, rect.Top));
                        var logicalSize =
                            hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                new System.Windows.Point(rect.Width, rect.Height));
                        window.Left = logicalPos.X;
                        window.Top = logicalPos.Y;
                        window.Width = logicalSize.X;
                        window.Height = logicalSize.Y;
                    }
                }

                //重新啟用 DWM 過渡動畫 忽略返回值,若DWM關閉不做處理
                Win32.Dwmapi.DwmSetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 0,
                    sizeof(int));

                //刪除儲存的狀態
                window.ClearValue(BeforeFullScreenWindowPlacementProperty);
                window.ClearValue(BeforeFullScreenWindowStyleProperty);
                MarkFullscreenWindowTaskbarList(hwnd, false);
            }
        }

        /// <summary>
        /// 確保視窗全屏的Hook
        /// 使用HandleProcessCorruptedStateExceptions,防止訪問記憶體過程中因為一些致命異常導致程式崩潰
        /// </summary>
        [HandleProcessCorruptedStateExceptions]
        private static IntPtr KeepFullScreenHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            //處理WM_WINDOWPOSCHANGING訊息
            const int WINDOWPOSCHANGING = 0x0046;
            if (msg == WINDOWPOSCHANGING)
            {
                try
                {
                    //得到WINDOWPOS結構體
                    var pos = (WindowPosition) Marshal.PtrToStructure(lParam, typeof(WindowPosition));

                    if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) != 0 &&
                        (pos.Flags & WindowPositionFlags.SWP_NOSIZE) != 0)
                    {
                        //既然你既不改變位置,也不改變尺寸,我就不管了...
                        return IntPtr.Zero;
                    }

                    if (Win32.User32.IsIconic(hwnd))
                    {
                        // 如果在全屏期間最小化了視窗,那麼忽略後續的位置調整。
                        // 否則按後續邏輯,會根據視窗在 -32000 的位置,計算出錯誤的目標位置,然後就跳到主屏了。
                        return IntPtr.Zero;
                    }

                    //獲取視窗現在的矩形,下面用來參考計算目標矩形
                    if (Win32.User32.GetWindowRect(hwnd, out var rect))
                    {
                        var targetRect = rect; //視窗想要變化的目標矩形

                        if ((pos.Flags & WindowPositionFlags.SWP_NOMOVE) == 0)
                        {
                            //需要移動
                            targetRect.Left = pos.X;
                            targetRect.Top = pos.Y;
                        }

                        if ((pos.Flags & WindowPositionFlags.SWP_NOSIZE) == 0)
                        {
                            //要改變尺寸
                            targetRect.Right = targetRect.Left + pos.Width;
                            targetRect.Bottom = targetRect.Top + pos.Height;
                        }
                        else
                        {
                            //不改變尺寸
                            targetRect.Right = targetRect.Left + rect.Width;
                            targetRect.Bottom = targetRect.Top + rect.Height;
                        }

                        //使用目標矩形獲取顯示器資訊
                        var monitor = Win32.User32.MonitorFromRect(targetRect, MonitorFlag.MONITOR_DEFAULTTOPRIMARY);
                        var info = new MonitorInfo();
                        info.Size = (uint) Marshal.SizeOf(info);
                        if (Win32.User32.GetMonitorInfo(monitor, ref info))
                        {
                            //基於顯示器資訊設定視窗尺寸位置
                            pos.X = info.MonitorRect.Left;
                            pos.Y = info.MonitorRect.Top;
                            pos.Width = info.MonitorRect.Right - info.MonitorRect.Left;
                            pos.Height = info.MonitorRect.Bottom - info.MonitorRect.Top;
                            pos.Flags &= ~(WindowPositionFlags.SWP_NOSIZE | WindowPositionFlags.SWP_NOMOVE |
                                           WindowPositionFlags.SWP_NOREDRAW);
                            pos.Flags |= WindowPositionFlags.SWP_NOCOPYBITS;

                            if (rect == info.MonitorRect)
                            {
                                var hwndSource = HwndSource.FromHwnd(hwnd);
                                if (hwndSource?.RootVisual is Window window)
                                {
                                    //確保視窗的 WPF 屬性與 Win32 位置一致,防止有逗比全屏後改 WPF 的屬性,發生一些詭異的行為
                                    //下面這樣做其實不太好,會再次觸發 WM_WINDOWPOSCHANGING 來著.....但是又沒有其他時機了
                                    // WM_WINDOWPOSCHANGED 不能用 
                                    //(例如:在進入全屏後,修改 Left 屬性,會進入 WM_WINDOWPOSCHANGING,然後在這裡將訊息裡的結構體中的 Left 改回,
                                    // 使對 Left 的修改無效,那麼將不會進入 WM_WINDOWPOSCHANGED,視窗尺寸正常,但視窗的 Left 屬性值錯誤。)
                                    var logicalPos =
                                        hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                            new System.Windows.Point(pos.X, pos.Y));
                                    var logicalSize =
                                        hwndSource.CompositionTarget.TransformFromDevice.Transform(
                                            new System.Windows.Point(pos.Width, pos.Height));
                                    window.Left = logicalPos.X;
                                    window.Top = logicalPos.Y;
                                    window.Width = logicalSize.X;
                                    window.Height = logicalSize.Y;
                                }
                                else
                                {
                                    //這個hwnd是前面從Window來的,如果現在他不是Window...... 你信麼
                                }
                            }

                            //將修改後的結構體複製回去
                            Marshal.StructureToPtr(pos, lParam, false);
                        }
                    }
                }
                catch
                {
                    // 這裡也不需要日誌啥的,只是為了防止上面有逗比邏輯,在訊息迴圈裡面炸了
                }
            }

            return IntPtr.Zero;
        }
    }

以上程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 46e400d9d4601730c1f5c974ea568416453fe48d

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 46e400d9d4601730c1f5c974ea568416453fe48d

獲取程式碼之後,進入 KenafearcuweYemjecahee 資料夾,即可獲取到原始碼


本文會經常更新,請閱讀原文: https://blog.lindexi.com/post/WPF-%E7%A8%B3%E5%AE%9A%E7%9A%84%E5%85%A8%E5%B1%8F%E5%8C%96%E7%AA%97%E5%8F%A3%E6%96%B9%E6%B3%95.html ,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。

如果你想持續閱讀我的最新部落格,請點選 RSS 訂閱,推薦使用RSS Stalker訂閱部落格,或者收藏我的部落格導航

知識共享許可協議本作品採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名林德熙(包含連結: https://blog.lindexi.com ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。如有任何疑問,請 與我聯絡

相關文章