WPF 穩定的全屏化窗口方法

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

本文的方法核心方式是通過 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);
            }
        }

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

以下是具體的實現方法,如不想了解細節,那請到本文最後拷貝代碼即可

先來聊聊 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 文件夾,即可獲取到源代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章