1. 程式人生 > >[WPF自定義控制元件庫]使用WindowChrome的問題

[WPF自定義控制元件庫]使用WindowChrome的問題

1. 前言

上一篇文章介紹了使用WindowChrome自定義Window,實際使用下來總有各種各樣的問題,這些問題大部分都不影響使用,可能正是因為不影響使用所以一直沒得到修復(也有可能別人根本不覺得這些是問題)。

這篇文章我總結了一些實際遇到的問題及其解決方案。

2. WindowChrome最大化的問題

2.1 影響Chrome尺寸的幾個值

上一篇文章提到有幾個值用於計算Chrome的尺寸:

屬性 值(畫素) 描述
SM_CXFRAME/SM_CYFRAME 4 The thickness of the sizing border around the perimeter of a window that can be resized, in pixels. SM_CXSIZEFRAME is the width of the horizontal border, and SM_CYSIZEFRAME is the height of the vertical border.This value is the same as SM_CXFRAME.
SM_CXPADDEDBORDER 4 The amount of border padding for captioned windows, in pixels.Windows XP/2000: This value is not supported.
SM_CYCAPTION 23 The height of a caption area, in pixels.

在有標題的標準Window,chrome的頂部尺寸為SM_CYFRAME + SM_CXPADDEDBORDER + SM_CYCAPTION = 31,左右兩邊尺寸為SM_CXFRAME + SM_CXPADDEDBORDER = 8,底部尺寸為SM_CYFRAME + SM_CXPADDEDBORDER = 8。

具體的計算方式可以參考Firefox的原始碼:

  // mCaptionHeight is the default size of the NC area at
  // the top of the window. If the window has a caption,
  // the size is calculated as the sum of:
  //      SM_CYFRAME        - The thickness of the sizing border
  //                          around a resizable window
  //      SM_CXPADDEDBORDER - The amount of border padding
  //                          for captioned windows
  //      SM_CYCAPTION      - The height of the caption area
  //
  // If the window does not have a caption, mCaptionHeight will be equal to
  // `GetSystemMetrics(SM_CYFRAME)`
  mCaptionHeight = GetSystemMetrics(SM_CYFRAME) +
                   (hasCaption ? GetSystemMetrics(SM_CYCAPTION) +
                                     GetSystemMetrics(SM_CXPADDEDBORDER)
                               : 0);

  // mHorResizeMargin is the size of the default NC areas on the
  // left and right sides of our window.  It is calculated as
  // the sum of:
  //      SM_CXFRAME        - The thickness of the sizing border
  //      SM_CXPADDEDBORDER - The amount of border padding
  //                          for captioned windows
  //
  // If the window does not have a caption, mHorResizeMargin will be equal to
  // `GetSystemMetrics(SM_CXFRAME)`
  mHorResizeMargin = GetSystemMetrics(SM_CXFRAME) +
                     (hasCaption ? GetSystemMetrics(SM_CXPADDEDBORDER) : 0);

  // mVertResizeMargin is the size of the default NC area at the
  // bottom of the window. It is calculated as the sum of:
  //      SM_CYFRAME        - The thickness of the sizing border
  //      SM_CXPADDEDBORDER - The amount of border padding
  //                          for captioned windows.
  //
  // If the window does not have a caption, mVertResizeMargin will be equal to
  // `GetSystemMetrics(SM_CYFRAME)`
  mVertResizeMargin = GetSystemMetrics(SM_CYFRAME) +
(hasCaption ? GetSystemMetrics(SM_CXPADDEDBORDER) : 0);

在WPF中這幾個值分別對映到SystemParameters的相關屬性:

系統值 SystemParameters屬性
SM_CXFRAME/SM_CYFRAME WindowResizeBorderThickness 4,4,4,4
SM_CXPADDEDBORDER 4
SM_CYCAPTION WindowCaptionHeight 23

另外還有WindowNonClientFrameThickness,相當於WindowResizeBorderThickness的基礎上,Top+=WindowCaptionHeight,值為 4,27,4,4。

SM_CXPADDEDBORDER在WPF裡沒有對應的值,我寫了個WindowParameters的類,添加了這個屬性:

/// <summary>
/// returns the border thickness padding around captioned windows,in pixels. Windows XP/2000:  This value is not supported.
/// </summary>
public static Thickness PaddedBorderThickness
{
    [SecurityCritical]
    get
    {
        if (_paddedBorderThickness == null)
        {
            var paddedBorder = NativeMethods.GetSystemMetrics(SM.CXPADDEDBORDER);
            var dpi = GetDpi();
            Size frameSize = new Size(paddedBorder, paddedBorder);
            Size frameSizeInDips = DpiHelper.DeviceSizeToLogical(frameSize, dpi / 96.0, dpi / 96.0);
            _paddedBorderThickness = new Thickness(frameSizeInDips.Width, frameSizeInDips.Height, frameSizeInDips.Width, frameSizeInDips.Height);
        }

        return _paddedBorderThickness.Value;
    }
}

2.2 WindowChrome的實際大小和普通Window不同

先說說我的環境,WIndows 10,1920 * 1080 解析度,100% DPI。

<WindowChrome.WindowChrome>
    <WindowChrome />
</WindowChrome.WindowChrome>
<Window.Style>
    <Style TargetType="{x:Type Window}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type Window}">
                    <Border>
                        <Grid>
                            <AdornerDecorator>
                                <ContentPresenter />
                            </AdornerDecorator>
                            <ResizeGrip x:Name="WindowResizeGrip"
                                        HorizontalAlignment="Right"
                                        IsTabStop="false"
                                        Visibility="Collapsed"
                                        VerticalAlignment="Bottom" />
                        </Grid>
                    </Border>
                    <ControlTemplate.Triggers>
                        <MultiTrigger>
                            <MultiTrigger.Conditions>
                                <Condition Property="ResizeMode"
                                           Value="CanResizeWithGrip" />
                                <Condition Property="WindowState"
                                           Value="Normal" />
                            </MultiTrigger.Conditions>
                            <Setter Property="Visibility"
                                    TargetName="WindowResizeGrip"
                                    Value="Visible" />
                        </MultiTrigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</Window.Style>

按上一篇文章介紹的方法開啟一個使用WindowChrome的Window(大小為800 * 600),在VisualStudio的實時視覺化樹可以看到AdornerDecorator的實際大小和Window的實際大小都是800 * 600(畢竟邊WindowChrome裡的Border、Grid等都沒設Margin或Padding)。然後用Inspect觀察它的邊框。可以看到Window實際上的範圍沒什麼問題。但和標準Window的對比就可以看出有區別,我在之前的文章中介紹過標準Window的實際範圍和使用者看到的並不一樣。

上面兩張圖分別是通過Inspect觀察的標準Window(上圖)和使用WindowChrome的Window(下圖),可以看到標準Window左右下三個方向有些空白位置,和邊框加起來是8個畫素。WindowChrome則沒有這個問題。

2.3 最大化狀態下Margin和標題高度的問題

WindowChrome最大化時狀態如上圖所示,大小也變為1936 * 1066,這個大小沒問題,有問題的是它不會計算好client-area的尺寸,只是簡單地加大non-client的尺寸,導致client-area的尺寸也成了1936 * 1066。標準Window在最大化時non-client area的尺寸為1936 * 1066,client-area的尺寸為1920 * 1027。

2.4 最大化時chrome尺寸的問題

結合Window(窗體)的UI元素及行為這篇文章,WindowChrome最大化時的client-area的尺寸就是Window尺寸(1936 * 1066)減去WindowNonClientFrameThickness(4,27,4,4)再減去PaddedBorderThickness(4,4,4,4)。這樣就準確地計算出client-area在最大化狀態下的尺寸為1920 * 1027。

在自定義Window的ControlTempalte中我使用Trigger在最大化狀態下將邊框改為0,然後加上WindowResizeBorderThickness的Padding和PaddedBorderThickness的Margin:

<Trigger Property="WindowState"
         Value="Maximized">
    <Setter TargetName="MaximizeButton"
            Property="Visibility"
            Value="Collapsed" />
    <Setter TargetName="RestoreButton"
            Property="Visibility"
            Value="Visible" />
    <Setter TargetName="WindowBorder"
            Property="BorderThickness"
            Value="0" />
    <Setter TargetName="WindowBorder"
            Property="Padding"
            Value="{x:Static SystemParameters.WindowResizeBorderThickness}" />
    <Setter Property="Margin"
            TargetName="LayoutRoot"
            Value="{x:Static local:WindowParameters.PaddedBorderThickness}" />
</Trigger>

以前我還試過讓BorderThickness保持為1,Margin改為7,但後來發現執行在高於100% DPI的環境下出了問題,所以改為繫結到屬性。

在不同DPI下這幾個屬性值如下:

DPI non-client area 尺寸 client area 尺寸 WindowNonClientFrameThickness PaddedBorderThickness
100 1936 * 1066 1920 * 1027 4,4,4,4 4,4,4,4
125 1550.4 1536 3.2,3.2,3.2,3.2 4,4,4,4
150 1294.66666666667 280 3.3333,3.3333,3.3333,3.3333 4,4,4,4
175 1110.85714285714 1097.14285714286 2.8571428,2.8571428,2.8571428,2.8571428 4,4,4,4
200 973 960 2.5,2.5,2.5,2.5 4,4,4,4

可以看到PaddedBorderThickness總是等於4,所以也可以使用不繫結PaddedBorderThickness的方案:

<Border x:Name="WindowBorder"
        BorderThickness="3"
        BorderBrush="{TemplateBinding BorderBrush}"
        Background="{TemplateBinding Background}"
        >
    <Border.Style>
        <Style TargetType="{x:Type Border}">
            <Style.Triggers>
                <DataTrigger Binding="{Binding WindowState, RelativeSource={RelativeSource TemplatedParent}}" Value="Maximized">
                    <Setter Property="Margin" Value="{x:Static SystemParameters.WindowResizeBorderThickness}"/>
                    <Setter Property="Padding" Value="1"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Border.Style>

但我還是更喜歡PaddedBorderThickness,這是心情上的問題(我都寫了這麼多程式碼了,你告訴我直接用4這個神奇的數字就好了,我斷然不能接受)。而且有可能將來Windows的窗體設計會改變,繫結系統的屬性比較保險。

最後,其實應該監視SystemParameters的StaticPropertyChanged事件然後修改PaddedBorderThickness,因為WindowNonClientFrameThickness和WindowResizeBorderThickness會在系統主題改變時改變,但不想為了這小概率事件多寫程式碼就偷懶了。

3. SizeToContent的問題

SizeToContent屬性用於指示Window是否自動調整它的大小,但當設定'SizeToContent="WidthAndHeight"'時就會出問題:

上圖左面時一個沒內容的自定義Window,右邊是一個沒內容的系統Window,兩個都設定了SizeToContent="WidthAndHeight"。可以看到自定義WindowChorme多出了一些黑色的區域,仔細觀察這些黑色區域,發覺它的尺寸大概就是non-client area的尺寸,而且內容就是WindowChrome原本的內容。

SizeToContent="WidthAndHeight"時Window需要計算ClientArea的尺寸然後再確定Window的尺寸,但使用WindowChrome自定義Window時程式以為整個ControlTempalte的內容都是ClientArea,把它當作了ClientArea的尺寸,再加上non-client的尺寸就得出了錯誤的Window尺寸。ControleTemplate的內容沒辦法遮住整個WindowChrome的內容,於是就出現了這些黑色的區域。

解決方案是在OnSourceInitialized時簡單粗暴地要求再計算一次尺寸:

protected override void OnSourceInitialized(EventArgs e)
{
    base.OnSourceInitialized(e);
    if (SizeToContent == SizeToContent.WidthAndHeight && WindowChrome.GetWindowChrome(this) != null)
    {
        InvalidateMeasure();
    }
}

以前我曾建議在OnContentRendered中執行這段程式碼,但後來發現除錯模式,或者效能比較差的場合會有些問題,所以改為在OnSourceInitialized中執行了。

4. FlashWindow的問題

如果一個Window設定了Owner並且以ShowDialog的方式開啟,點選它的Owner將對這個Window呼叫FlashWindowEx功能,即閃爍幾下,並且還有提示音。除了這種方式還可以用程式設計的方式呼叫FlashWindow功能。

WindowChrome提供通知FlashWindow發生的事件,FlashWindow發生時雖然Window看上去在Active/Inactive 狀態間切換,但IsActive屬性並不會改變。

要處理這個問題,可以監聽WM_NCACTIVATE訊息,它通知Window的non-client area是否需要切換Active/Inactive狀態。

IntPtr handle = new WindowInteropHelper(this).Handle;
HwndSource.FromHwnd(handle).AddHook(new HwndSourceHook(WndProc));


protected override void OnActivated(EventArgs e)
{
    base.OnActivated(e);
    SetValue(IsNonClientActivePropertyKey, true);
}

protected override void OnDeactivated(EventArgs e)
{
    base.OnDeactivated(e);
    SetValue(IsNonClientActivePropertyKey, false);
}

private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    if (msg == WindowNotifications.WM_NCACTIVATE)
        SetValue(IsNonClientActivePropertyKey, wParam == _trueValue);

    return IntPtr.Zero;
}

需要新增一個只讀的IsNonClientActive依賴屬性,ControlTemplate通過Trigger使邊框置灰:

<Trigger Property="IsNonClientActive"
         Value="False">
    <Setter Property="BorderBrush"
            Value="#FF6F7785" />
</Trigger>

5. ResizeBorder的問題

5.1 ResizeBorder尺寸的問題

標準Window可以單擊並拖動以調整視窗大小的區域為8畫素(可以理解為SM_CXFRAME的4畫素加上SM_CXPADDEDBORDER的4畫素)。

WindowChrome實際大小就是看起來的大小,預設的ResizeBorderThickness是4畫素,就是從Chrome的邊框向內的4畫素範圍,再多就會影響client-area裡各元素的正常使用。

由於標準Window的課拖動區域幾乎在Window的外側,而且有8個畫素,而WindowChrome只能有4個畫素,所以WindowChrome拖動起來手感沒那麼好。

5.2 拖動邊框產生的效能問題

最後提一下WindowChrome的效能問題,正常操作我覺得應該沒什麼問題,只有拖動左右邊緣尤其是左邊緣改變Window大小的時候右邊的邊緣會很不和諧。其實這個問題不是什麼大問題,看看這個空的什麼都沒有的Skype窗體都會這樣,所以不需要特別在意。

6. 其它自定義Window的方案

在Kino.Toolkit.Wpf裡我只提供了最簡單的使用WindowChrome的方案,這個方案只能建立沒有圓角的Window,而且不能自定義邊框陰影顏色。如果真的需要更高的自由度可以試試參考其它方案。

6.1 VisualStudio

VisualStudio當然沒有開源,但並不妨礙我們去參考它的原始碼。可以在以下DLL找到Microsoft.VisualStudio.PlatformUI.MainWindow:

X:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Common7\IDE\Microsoft.VisualStudio.Shell.UI.Internal.dll

6.2 FirstFloor.ModernUI

Modern UI for WPF (MUI),A set of controls and styles converting your WPF application into a great looking Modern UI app.

6.3 MahApps.Metro

MahApps.Metro,A framework that allows developers to cobble together a Metro or Modern UI for their own WPF applications with minimal effort.

6.4 Fluent.Ribbon

Fluent.Ribbon is a library that implements an Office-like user interface for the Windows Presentation Foundation (WPF).

6.5 HandyControl

HandyControlHandyControl是一套WPF控制元件庫,它幾乎重寫了所有原生樣式,同時包含50多款額外的控制元件,還提供了一些好看的Window。

7. 參考

WindowChrome Class (System.Windows.Shell) Microsoft Docs

SystemParameters Class (System.Windows) Microsoft Docs

WPF Windows 概述 _ Microsoft Docs

GetSystemMetrics function Microsoft Docs

FlashWindowEx function Microsoft Docs

Window Class (System.Windows) Microsoft Docs

Inspect - Windows applications Microsoft Docs

8. 原始碼

Kino.Toolkit.Wpf_Window at mas