[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

posted @ 2019-06-11 09:01 dino.c 阅读(...) 评论(...) 编辑 收藏