1. 程式人生 > >[WPF自定義控制元件]使用WindowChrome自定義Window Style

[WPF自定義控制元件]使用WindowChrome自定義Window Style

1. 為什麼要自定義Window

對稍微有點規模的桌面軟體來說自定義的Window幾乎是標配了,一來設計師總是剋制不住自己想想軟體更個性化,為了UI的和諧修改Window也是必要的;二來多一行的空間可以新增很多功能,尤其是上邊緣,因為被螢幕限制住滑鼠的移動所以上邊緣的按鈕很容易選中。做桌面開發總有一天會遇到自定義Window的需求,所以我在控制元件庫中也提供了一個簡單的自定義Window。

2. 我想要的功能

我在上一篇文章介紹了標準Window的功能,我想實現一個包含這些基本功能的,窄邊框、扁平化的Window,基本上模仿Windows 10 的Window,但要可以方便地自定義樣式;陰影、動畫效果保留系統預設的就可以了,基本上會很耐看。最後再放置一個FunctionBar方便新增更多功能。

最後成果如下:

這是一個名為ExtendedWindow的自定義Window,原始碼地址可見文章最後。

3. WindowChrome

3.1 為什麼要使用WindowChrome自定義Window

WPF有兩種主流的自定義Window的方案,《WPF程式設計寶典》介紹了使用WindowStyle="None"AllowsTransparency="True"建立無邊框的Window然後在裡面仿造一個Window,以前也有很多部落格詳細介紹了這種方式,這裡就不再贅述。這種方法的原理是從Window中刪除non-client area(即chrome),再由使用者自定義Window的所有外觀和部分行為。這種方式的自由度很高,但也有不少問題:

  • Window沒有陰影導致很難看,但新增自定義的DropShadowEffect又十分影響效能;
  • 沒有彈出、關閉、最大化、最小化動畫,尤其當啟動了大量任務將工作列堆滿的情況下沒有最小化動畫很容易找不到自己的程式;
  • 沒有動畫很麻煩,自定義的動畫做得不好也十分影響使用;
  • 需要寫大量程式碼實現Window本來的拖動、改變大小、最大化等行為;
  • 各種其它細節的缺失;

大部分自定義Window或多或少都有上面所說的問題,幸好WPF提供了WindowChrome這個類用於建立自定義的Window,這個類本身處理了上面部分問題。

3.2 WindowChrome的基本概念

WindowChrome定義了Window non-client area(即chrome)的外觀和行為, 在Window上應用WindowChrome的WindowChrome附加屬性即可將Window的non-client area替換為WindowChrome(繞口):

<WindowChrome.WindowChrome>
    <WindowChrome />
</WindowChrome.WindowChrome>

然後用Blend生成這個Window的Style,將最外層Border的背景移除並做了些簡化後大概是這樣:

<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>

這樣一個沒有Content的Window執行效果如下:

可以看到WindowChrome已經定義好noe-client area的邊框、陰影、標題欄、右上角的三個按鈕,ControleTemplate裡也在右下角放置了一個ResizeGrip,而且拖動、改變大小、最大化最小化、動畫等功能都已經做好了。除了Icon和標題外WindowChrome已經把一個標準的Window實現得差不多了。要實現自定義Window,只需要將我們想要的邊框、Icon、標題、自定義樣式的按鈕等放在上面遮擋WindowChrome的各種元素就可以了。原理十分簡單,接下來再看看WindowChrome的各個屬性。

3.3 UseAeroCaptionButtons

UseAeroCaptionButtons表示標題欄上的那三個預設按鈕是否可以命中,因為我們想要自己管理這三個按鈕的樣式、顯示或隱藏,所以設定為False。

3.4 GlassFrameThickness和ResizeBorderThickness

GlassFrameThickness和ResizeBorderThickness,這兩個屬性用於控制邊框,及使用者可以單擊並拖動以調整視窗大小的區域的寬度。如果兩個都設定為50效果如下:

可以看到因為邊框和ResizeBorder變大了,標題欄也下移了相應的距離(通過可拖動區域和SystemMenu的位置判斷)。當然因為外觀是我們自己定義的,ResizeBorderThickness也不需要這麼寬,所以兩個值都保留預設值就可以了。

3.5 CaptionHeight

CaptionHeight指定WindowChrome的標題欄高度。它不影響外觀,因為WindowChrome的標題欄範圍實際是不可見的,它包括可以拖動窗體、雙擊最大化窗體、右鍵開啟SystemMenu等行為。

CaptionHeight、GlassFrameThickness和ResizeBorderThickness的預設值都和SystemParameters的對應的值一致。

3.6 IsHitTestVisibleInChrome附加屬性

GlassFrameThickness和CaptionHeight定義了Chrome的範圍,預設情況下任何在Chrome的範圍內的元素都不可以互動,如果需要在標題欄放自己的按鈕(或其它互動元素)需要將這個按鈕的WindowsChrome.IsHitTestVisibleInChrome附加屬性設定為True。

3.7 使用WindowChrome

綜上所述,使用WindowChrome只需要設定UseAeroCaptionButtons為False,並且設定CaptionHeight,比較標準的做法是使用SystemParameter的WindowNonClientFrameThickness的Top,在100% DPI下是 27 畫素(其它三個邊都為4畫素,因為我的目標是窄邊框的Window,所以不會用這個值)。

<Setter Property="WindowChrome.WindowChrome">
    <Setter.Value>
        <WindowChrome UseAeroCaptionButtons="False"
                      CaptionHeight="{Binding Path=(SystemParameters.WindowNonClientFrameThickness).Top}" />
    </Setter.Value>
</Setter>

WindowChrome的文件有些舊了,文件中介紹的SystemParameters2在.NET 4.5已經找不到,在Github上還能找到不少它的實現,但沒必要勉強用一箇舊的API。

4. 自定義Window基本佈局

<ControlTemplate TargetType="{x:Type Window}">
    <Border BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}"
            x:Name="WindowBorder">
        <Grid x:Name="LayoutRoot"
              Background="{TemplateBinding Background}">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Grid x:Name="WindowTitlePanel"
                  Height="{Binding Path=(SystemParameters.WindowNonClientFrameThickness).Top}"
                  Background="{TemplateBinding BorderBrush}"
                  Margin="0,-1,0,0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="Auto" />
                </Grid.ColumnDefinitions>

                <StackPanel Orientation="Horizontal">
                    <Image Source="{TemplateBinding Icon}"
                           Height="{x:Static SystemParameters.SmallIconHeight}"
                           Width="{x:Static SystemParameters.SmallIconWidth}"
                           WindowChrome.IsHitTestVisibleInChrome="True" />
                    <ContentControl FontSize="{DynamicResource {x:Static SystemFonts.CaptionFontSize}}"
                                    Content="{TemplateBinding Title}" />
                </StackPanel>

                <StackPanel x:Name="WindowCommandButtonsPanel"
                            Grid.Column="1"
                            HorizontalAlignment="Right"
                            Orientation="Horizontal"
                            WindowChrome.IsHitTestVisibleInChrome="True"
                            Margin="0,0,-1,0">
                    <ContentPresenter Content="{Binding FunctionBar, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}"
                                      Focusable="False" />
                    <Button x:Name="MinimizeButton" />
                    <Grid Margin="1,0,1,0">
                        <Button x:Name="RestoreButton"
                                Visibility="Collapsed" />
                        <Button x:Name="MaximizeButton" />
                    </Grid>
                    <Button x:Name="CloseButton"
                            Background="Red" />
                </StackPanel>
            </Grid>
            <AdornerDecorator Grid.Row="1"
                              KeyboardNavigation.IsTabStop="False">
                <ContentPresenter Content="{TemplateBinding Content}"
                                  x:Name="MainContentPresenter"
                                  KeyboardNavigation.TabNavigation="Cycle" />
            </AdornerDecorator>
            <ResizeGrip x:Name="ResizeGrip"
                        HorizontalAlignment="Right"
                        VerticalAlignment="Bottom"
                        Grid.Row="1" />
        </Grid>
    </Border>
</ControlTemplate>

上面是簡化後的ControlTemplate及執行時的VisualTree結構,它包含以下部分:

  • WindowBorder,外層的邊框,它的Border顏色即Window的邊框顏色。
  • LayoutRoot,分為兩行,第一行為標題欄,第二行為Content。
  • 標題欄,裡面包含Icon、Title、FunctionBar及WindowCommandButtonsPanel(包含最小化、最大化、還原和關閉等按鈕)。
  • MainContentPresenter,即cient area。
  • ResizeGrip。

5. 繫結到SystemCommand

SystemCommands有5個命令CloseWindowCommand、MaximizeWindowCommand、MinimizeWindowCommand、RestoreWindowCommand、ShowSystemMenuCommand,並且還提供了CloseWindow、MaximizeWindow、MinimizeWindow、RestoreWindow、ShowSystemMenu5個靜態方法。Window標題欄上的各個按鈕需要繫結到這些命名並執行對應的靜態方法。寫在自定義的Window類裡太複雜了而且不能重用,所以我把這個功能做成附加屬性,用法如下:

<Setter Property="local:WindowService.IsBindingToSystemCommands"
        Value="True" />

具體實現程式碼很普通,就是IsBindingToSystemCommands屬性改變時呼叫WindowCommandHelper繫結到各個命令:

private class WindowCommandHelper
{
    private Window _window;

    public WindowCommandHelper(Window window)
    {
        _window = window;
    }

    public void ActiveCommands()
    {
        _window.CommandBindings.Add(new CommandBinding(SystemCommands.CloseWindowCommand, CloseWindow));
        _window.CommandBindings.Add(new CommandBinding(SystemCommands.MaximizeWindowCommand, MaximizeWindow, CanResizeWindow));
        _window.CommandBindings.Add(new CommandBinding(SystemCommands.MinimizeWindowCommand, MinimizeWindow, CanMinimizeWindow));
        _window.CommandBindings.Add(new CommandBinding(SystemCommands.RestoreWindowCommand, RestoreWindow, CanResizeWindow));
        _window.CommandBindings.Add(new CommandBinding(SystemCommands.ShowSystemMenuCommand, ShowSystemMenu));
    }

    /*SOME CODE*/
}

6. UI元素的實現細節

接下來介紹ControlTemplate中各個UI元素的實現細節。

6.1 標題欄

<Grid x:Name="WindowTitlePanel"
      VerticalAlignment="Top"
      Height="{Binding Path=(SystemParameters.WindowNonClientFrameThickness).Top}"
      Background="{TemplateBinding BorderBrush}">

標題欄的高度和WindowChrome的CaptionHeight一致,而Background則和Window的BorderBrush一致。

Icon

<Image Source="{TemplateBinding Icon}"
       VerticalAlignment="Center"
       Margin="5,0,5,0"
       Height="{x:Static SystemParameters.SmallIconHeight}"
       Width="{x:Static SystemParameters.SmallIconWidth}"
       WindowChrome.IsHitTestVisibleInChrome="True">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="MouseLeftButtonDown">
            <i:InvokeCommandAction Command="{x:Static SystemCommands.ShowSystemMenuCommand}" />
        </i:EventTrigger>
        <i:EventTrigger EventName="MouseRightButtonDown">
            <i:InvokeCommandAction Command="{x:Static SystemCommands.ShowSystemMenuCommand}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Image>

Icon是一張圖片,它的大小由SystemParameters.SmallIconHeight和SystemParameters.SmallIconWidth決定,通常來說是16 * 16畫素。

Icon還繫結到SystemCommands.ShowSystemMenuCommand,點選滑鼠左右鍵都可以開啟SystemMenu。

最後記得設定WindowChrome.IsHitTestVisibleInChrome="True"

Title

<ContentControl IsTabStop="False"
                Foreground="White"
                HorizontalAlignment="Center"
                VerticalAlignment="Center"
                FontSize="{DynamicResource {x:Static SystemFonts.CaptionFontSize}}"
                Content="{TemplateBinding Title}" />

標題的字號由SystemFonts.CaptionFontSize決定,但顏色、字型都自己定義。

6.2 按鈕

<Style x:Key="MinimizeButtonStyle"
       TargetType="Button"
       BasedOn="{StaticResource WindowTitleBarButtonStyle}">
    <Setter  Property="ToolTip"
             Value="Minimize" />
    <Setter Property="ContentTemplate"
            Value="{StaticResource MinimizeWhite}" />
    <Setter Property="Command"
            Value="{Binding Source={x:Static SystemCommands.MinimizeWindowCommand}}" />
</Style>

<!--OTHER BUTTON STYLES-->

<Button x:Name="MinimizeButton"
        Style="{StaticResource MinimizeButtonStyle}" />
<Grid Margin="1,0,1,0">
    <Button x:Name="RestoreButton"
            Style="{StaticResource RestoreButtonStyle}"
            Visibility="Collapsed" />
    <Button x:Name="MaximizeButton"
            Style="{StaticResource MaximizeButtonStyle}" />
</Grid>
<Button x:Name="CloseButton"
        Background="Red"
        Style="{StaticResource CloseButtonStyle}" />

按鈕基本上使用相同的樣式,不過CloseButton的背景是紅色。按鈕的圖示參考Windows 10(具體來說是Segoe MDL2裡的ChromeMinimize、ChromeMaximize、ChromeRestore、ChromeClose,不過沒有在專案中引入Segoe MDL2字型,而是把它們轉換成Path來使用)。各個按鈕綁定了對應的SystemCommand。

6.3 FunctionBar

<ContentPresenter Content="{Binding FunctionBar, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}"
                  Focusable="False" />

在這篇文章中介紹了FunctionBar的實現及應用,這段XAML即在標題欄為FunctionBar留一個佔位符。

6.4 ClientArea

<AdornerDecorator Grid.Row="1"
                  KeyboardNavigation.IsTabStop="False">
    <ContentPresenter Content="{TemplateBinding Content}"
                      x:Name="MainContentPresenter"
                      KeyboardNavigation.TabNavigation="Cycle" />
</AdornerDecorator>

這是Client Area部分的內容。一個Window中只有client area中的內容可以獲得鍵盤焦點,而且tab鍵只會讓鍵盤焦點在Window的內容中迴圈。當一個Window從非啟用狀態會到啟用狀態,之前獲得鍵盤焦點的元素將重新獲得鍵盤焦點。所以AdornerDecorator不要讓它獲得焦點,而MainContentPresenter則要設定為KeyboardNavigation.TabNavigation="Cycle"

AdornerDecorator 為視覺化樹中的子元素提供 AdornerLayer,如果沒有它的話一些裝飾效果不能顯示(例如下圖Button控制元件的Focus效果),Window的 ContentPresenter 外面套個 AdornerDecorator 是 必不能忘的。

6.5 ResizeGrip

<ResizeGrip x:Name="ResizeGrip"
            HorizontalAlignment="Right"
            VerticalAlignment="Bottom"
            Grid.Row="1"
            IsTabStop="False"
            Visibility="Hidden"
            WindowChrome.ResizeGripDirection="BottomRight" />

ResizeGrip是當ResizeMode = ResizeMode.CanResizeWithGrip;並且WindowState = Normal時時出現的Window右下角的大小調整手柄,外觀為組成三角形的一些點。除了讓可以操作的區域變大一些,還可以用來提示Window是可以調整大小的。

7. 處理Triggers

雖然我平時喜歡用VisualState的方式實現模板化控制元件UI再狀態之間的轉變,但有時還是Trigger方便快捷,尤其是不需要做動畫的時候。自定義Window有以下幾組需要處理的Trigger:

7.1 IsNonClientActive

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

這個屬性是我自定義的,用於代替IsActive,在它為False的時候邊框和標題欄變成灰色。

7.2 ResizeGrip

<MultiTrigger>
    <MultiTrigger.Conditions>
        <Condition Property="ResizeMode"
                   Value="CanResizeWithGrip" />
        <Condition Property="WindowState"
                   Value="Normal" />
    </MultiTrigger.Conditions>
    <Setter TargetName="ResizeGrip"
            Property="Visibility"
            Value="Visible" />
</MultiTrigger>

上面這段XAML控制ResizeGrip是否顯示。

7.3 Buttons

<Trigger Property="WindowState"
         Value="Normal">
    <Setter TargetName="MaximizeButton"
            Property="Visibility"
            Value="Visible" />
    <Setter TargetName="RestoreButton"
            Property="Visibility"
            Value="Collapsed" />
</Trigger>
<Trigger Property="ResizeMode"
         Value="NoResize">
    <Setter TargetName="MinimizeButton"
            Property="Visibility"
            Value="Collapsed" />
    <Setter TargetName="MaximizeButton"
            Property="Visibility"
            Value="Collapsed" />
    <Setter TargetName="RestoreButton"
            Property="Visibility"
            Value="Collapsed" />
</Trigger>

這兩個Trigger控制最小化、最大化和還原按鈕的狀態。最大化、還原兩個按鈕的IsEnabled狀態由繫結的SystemCommand控制。

7.4 Maximized

<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>

Maximized狀態下最大化按鈕隱藏,還原按鈕出現。並且Window的Margin需要調整,具體留到下一篇文章再說吧。

8. DragMove

有些人喜歡不止標題欄,按住Window的任何空白部分都可以拖動Window,只需要在程式碼中新增DragMove即可:

protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
    base.OnMouseLeftButtonDown(e);
    if (e.ButtonState == MouseButtonState.Pressed)
        DragMove();
}

但這樣做不喜歡DragMove功能的人又會有意見,再新增一個屬性來開關這個功能又很麻煩,索性就把它做成WindowService.IsDragMoveEnabled附加屬性,在DefaultStyle中設定了。

9. 結語

使用WindowChrome自定義Window的基本功能就介紹到這裡了,但其實WindowChrome有很多缺陷,下一篇文章將介紹這些陷阱及講解如何迴避(或者為什麼不/不能迴避)。

ExtendedWindow的做法是儘量成為一個更通用的基類,樣式和其它附加屬性中的行為和ExtendedWindow的類本身沒有必然關聯(目前位置只添加了FunctionBar依賴屬性)。這樣做的好處是為程式碼和樣式解耦,而且一旦為控制元件添加了屬性,以後再想不支援就很難了,反正XAML的自由度很高,都交給XAML去擴充套件就好了。

我以前也寫過一篇文章使用WindowChrome自定義Window Style簡單介紹過自定義Window樣式的方案,當時的方案有不少問題,這次算是填上以前的坑。

10. 參考

WindowChrome Class (System.Windows.Shell) Microsoft Docs

WPF Windows 概述 _ Microsoft Docs

對話方塊概述 _ Microsoft Docs

SystemParameters Class (System.Windows) Microsoft Docs

11. 原始碼

Kino.Toolkit.Wpf_Window at mas