[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

GlassFrameThicknessResizeBorderThickness,這兩個屬性用於控制邊框,及用戶可以單擊並拖動以調整窗口大小的區域的寬度。如果兩個都設置為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.SmallIconHeightSystemParameters.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

WPF 使用 WindowChrome,在自定義窗口標題欄的同時最大程度保留原生窗口樣式(類似 UWP_Chrome) - walterlv

11. 源碼

Kino.Toolkit.Wpf_Window at master


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM