65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 ControlTemplate 自定义 WPF Expander

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (94投票s)

2011年9月1日

CPOL

8分钟阅读

viewsIcon

380880

downloadIcon

16155

介绍如何通过模板化来自定义WPF Expander的外观和行为。

ExpanderTemplateArticle/simpleexpander.gifExpanderTemplateArticle/stretchy.gifExpanderTemplateArticle/reveal.gif

引言

本文将演示如何为WPF Expander控件创建ControlTemplate,以自定义其外观和行为。首先,将详细解释默认模板的一个简化版本。然后,将在此默认模板的基础上构建几个更复杂的版本,并进行自定义,例如添加动画和更改外观。本文所示代码旨在同时兼容.NET 3.5和.NET 4。

背景

预计读者应具备WPF基本概念(如绑定和触发器)的初步理解。对动画的基本了解将非常有帮助。读者还应熟悉(或愿意查阅)常用控件,如GridDockPanelToggleButton等。

Expander的视觉“部分”

通常,一个Expander控件在**视觉上**由三个部分组成。为方便参考,我将这些部分称为“图标”、“标题”和“内容”。例如,“header”指的是图中的部分,而“Header”则指XAML中的某个属性。请原谅我的画图技巧。

ExpanderTemplateArticle/Default_Expander_pic.png

构建模板

ExpanderTemplateArticle/simpleexpander.gif

本文将介绍的ControlTemplate使用了一个模板化的ToggleButton作为标题/图标部分,并使用一个ContentPresenter作为实际的Expander内容。所有这些部分都使用DockPanel进行布局,但您也可以选择任何布局控件。

Expander的ToggleButton模板

第一步是为ToggleButtonExpander的按钮)创建一个ControlTemplate,该模板稍后将在Expander的模板中使用。在ToggleButton的模板内部,我们将使用WPF形状来绘制图标,并使用另一个ContentPresenter作为标题,然后使用Grid进行布局。设置IsMouseOver/IsPressed的触发器来改变图标的外观,以及设置IsChecked的触发器来改变按钮切换时的图标。代码如下所示。之后我还会讨论一些关键点。

<ControlTemplate x:Key="SimpleExpanderButtonTemp" 
             TargetType="{x:Type ToggleButton}">
    <Border x:Name="ExpanderButtonBorder"
            Background="{TemplateBinding Background}"
            BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}"
            Padding="{TemplateBinding Padding}"
            >
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Rectangle Fill="Transparent"
                       Grid.ColumnSpan="2"/>
            <Ellipse Name="Circle"
                 Grid.Column="0"
                 Stroke="DarkGray"
                 Width="20"
                 Height="20"
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 />
            <Path x:Name="Sign"
              Grid.Column="0"
              Data="M 0,5 H 10 M 5,0 V 10 Z"
              Stroke="#FF666666"
              Width="10"
              Height="10"
              StrokeThickness="2"
              HorizontalAlignment="Center"
              VerticalAlignment="Center"
              RenderTransformOrigin="0.5,0.5"
              >
                <Path.RenderTransform>
                    <RotateTransform Angle="0"/>
                </Path.RenderTransform>
            </Path>
            <ContentPresenter x:Name="HeaderContent"
                          Grid.Column="1"
                          Margin="4,0,0,0"
                          ContentSource="Content"/>
        </Grid>
    </Border>
    <ControlTemplate.Triggers>
        <!--Change the sign to minus when toggled-->
        <Trigger Property="IsChecked"
                 Value="True">
            <Setter Property="Data" 
               TargetName="Sign" Value="M 0,5 H 10 Z"/>
        </Trigger>

        <!-- MouseOver, Pressed behaviours-->
        <Trigger Property="IsMouseOver"
                         Value="true">
            <Setter Property="Stroke"
                            Value="#FF3C7FB1"
                            TargetName="Circle"/>
            <Setter Property="Stroke"
                            Value="#222"
                            TargetName="Sign"/>
        </Trigger>
        <Trigger Property="IsPressed"
                         Value="true">
            <Setter Property="Stroke"
                            Value="#FF526C7B"
                            TargetName="Circle"/>
            <Setter Property="StrokeThickness"
                            Value="1.5"
                            TargetName="Circle"/>
            <Setter Property="Stroke"
                            Value="#FF003366"
                            TargetName="Sign"/>
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

绑定到模板化父级

我们可以通过将模板内组件控件的属性绑定到模板化父级(ToggleButton)来“暴露”这些属性。例如,我们可以将模板内BorderBackground属性绑定到模板化父级(TargetType ToggleButton)的背景,就像上面的代码一样。这样,当使用此模板时,如果我们设置ToggleButtonBackground属性为某种颜色,Border的背景也将设置为该颜色。

实际上,有两种标准的方法可以绑定到模板化父级。

  • 使用TemplateBinding
  • 例如,在ToggleButton的模板内部

    <Border ... Background="{TemplateBinding Background}"/>

    TemplateBinding是标准绑定的优化版本,因为它在编译时进行评估,但它确实有一些限制。例如,它只支持OneWay绑定模式,并且不允许设置标准Binding所具有的诸如ConvertersStringFormat之类的属性。

  • 使用设置为TemplatedParentRelativeSourceBinding
  • 例如,在Expander的模板内部

    <ToggleButton ... IsChecked="{Binding Path=IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"  />

    这种方法比TemplateBinding更灵活但速度更慢,因为它提供了标准Binding的所有功能。

可点击区域

放在ToggleButton内的任何内容都将可点击,用于切换Expander内容的可视性。如果您不希望用户通过点击标题组件来切换Expander,则可以将标题部分(例如ContentPresenter)移出ToggleButton的模板,而是将其放入Expander的模板中。

此外,对于任何Fill属性为空或未设置的Shape,只有其“**轮廓**”才可点击。这是设计使然。要解决此问题,一种方法是将一个设置了BackgroundBorder包裹模板的所有组件,但您必须确保Background属性在运行时不会保持未设置或为空(即使它是通过Binding设置的)。另一种方法是在使用的任何Shape的顶部放置一个Fill设置为TransparentRectangle(如上所示)。关键在于避免Background/Fill属性值未设置或为空。

Expander模板

使用上面创建的ToggleButton模板,我们可以将ToggleButton放在ContentPresenter的顶部。然后,我们可以将按钮的IsChecked绑定到ExpanderIsExpanded,并设置一个触发器,在IsExpandedfalse时使ContentPresenter折叠。请注意,这不处理ExpandDirection,并且只包含一些基本的TemplateBindings。对于ExpandDirection,您需要设置触发器来根据ExpandDirection的值更改整个模板的布局。最好下载Control Styles and Templates中的“Default WPF Themes”,并在其中一个主题XAML文件中查看默认的Expander模板。

<!-- Simple Expander Template-->
<ControlTemplate x:Key="SimpleExpanderTemp" TargetType="{x:Type Expander}">
    <DockPanel>
        <ToggleButton x:Name="ExpanderButton"
                      DockPanel.Dock="Top"
                      Template="{StaticResource SimpleExpanderButtonTemp}"
                      Content="{TemplateBinding Header}"
                      IsChecked="{Binding Path=IsExpanded, 
                      RelativeSource={RelativeSource TemplatedParent}}"
                      OverridesDefaultStyle="True"
                      Padding="1.5,0">
        </ToggleButton>
        <ContentPresenter x:Name="ExpanderContent"
                          Visibility="Collapsed"
                          DockPanel.Dock="Bottom"/>
    </DockPanel>
    <ControlTemplate.Triggers>
        <Trigger Property="IsExpanded" Value="True">
            <Setter TargetName="ExpanderContent" 
              Property="Visibility" Value="Visible"/>
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

定制化

自定义Expander就像创建常规WPF GUI一样,只不过您需要使用更多基本控件来构建更复杂的控件。您可以对模板进行许多自定义,这里我只列举几个常见的例子。

使用Tag属性

Tag属性旨在存储自定义信息;其类型为Object,因此您可以将其用于几乎任何目的。所有FrameworkElement及其派生类都继承此属性。例如,假设您希望Expander的标题和内容部分具有不同的背景颜色。在这种情况下,我们可以使用Tag来存储标题的Background值,并进行一些绑定来暴露它。

在Expander的ToggleButton模板中,您必须通过绑定暴露Background,如下所示:

<ControlTemplate x:Key="SimpleExpanderButtonTemp" TargetType="{x:Type ToggleButton}">
    <Border x:Name="ExpanderButtonBorder"
            Background="{TemplateBinding Background}"
        ...

在Expander模板的ToggleButton中添加Background TemplateBinding,如下所示:

<ControlTemplate x:Key="SimpleExpanderTemp" TargetType="{x:Type Expander}">
    ...
    <ToggleButton x:Name="ExpanderButton"
                  Template="{StaticResource SimpleExpanderButtonTemp}"
                  ...
                  Background="{TemplateBinding Tag}"
    </ToggleButton>
    ...

然后,在使用它时,我们可以将Tag设置为我们想要的ToggleButton颜色。请注意,由于我们使用Tag来间接设置Background属性,因此我们**不能**使用“Red”或“Green”之类的字符串来设置Tag=>Background,因为我们的字符串值不会自动转换。因此,我们需要传递一个Brush对象,这是Background属性的类型。

<Expander ...>
    <Expander.Tag>
        <SolidColorBrush Color="Red" />
    </Expander.Tag>

如果您想要一个以上的自定义属性或做更广泛的事情怎么办?那么最好的方法是创建一个继承自Expander的新类,并在该类中声明额外的属性。但是,创建新的自定义/复合控件不属于本文的范围。

使标题与内容宽度相同

如果您希望内容与标题宽度相同,那么您可以将DockPanel替换为Grid,并将ToggleButtonContentPresenter放在两行中。非常简单。

<ControlTemplate x:Key="SimpleExpanderTemp" TargetType="{x:Type Expander}">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <ToggleButton x:Name="ExpanderButton"
                      ...
        </ToggleButton>
        <ContentPresenter x:Name="ExpanderContent"
                          Grid.Row="1"
                          .../>
    </Grid>
    ...

展开/折叠动画

旋转箭头

要为箭头添加旋转动画,我们可以使用RenderTransform.RotateTransform来随时间旋转元素。由于这使用了RenderTransform,它只会影响元素的绘制方式,而不会干扰布局。

<ControlTemplate.Triggers>
    <!-- Animate arrow when toggled-->
    <Trigger Property="IsChecked"
             Value="True">
        <Trigger.EnterActions>
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="Arrow"
                         Storyboard.TargetProperty=
                           "(Path.RenderTransform).(RotateTransform.Angle)"
                         To="180"
                         Duration="0:0:0.4"/>
                </Storyboard>
            </BeginStoryboard>
        </Trigger.EnterActions>
        <Trigger.ExitActions>
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="Arrow"
                         Storyboard.TargetProperty=
                           "(Path.RenderTransform).(RotateTransform.Angle)"
                         To="0"
                         Duration="0:0:0.4"/>
                </Storyboard>
            </BeginStoryboard>
        </Trigger.ExitActions>
    </Trigger>
</ControlTemplate.Triggers>

内容拉伸

ExpanderTemplateArticle/stretchy.gif

要使内容在展开时“拉伸”出来,我们可以使用LayoutTransform.ScaleTransform并为其ScaleY属性添加动画。在这种情况下,我们使用LayoutTransform而不是RenderTransform,因为我们需要控件的高度(布局)发生变化,而不仅仅是控件如何绘制(渲染)。您可以通过将LayoutTransform替换为RenderTransform并进行实验来看到区别。动画通过触发器完成。在下面的代码中,请注意引用ScaleY属性以及在ContentPresenter下声明LayoutTransform的语法。

<ControlTemplate x:Key="StretchyExpanderTemp" TargetType="{x:Type Expander}">
    <DockPanel>
        <ToggleButton .../>
        <ContentPresenter ...>
            <ContentPresenter.LayoutTransform>
                <ScaleTransform ScaleY="0"/>
            </ContentPresenter.LayoutTransform>
        </ContentPresenter>
    </DockPanel>
    <ControlTemplate.Triggers>
        <Trigger Property="IsExpanded" Value="True">
            <Trigger.EnterActions>
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation Storyboard.TargetName="ExpanderContent"
                             Storyboard.TargetProperty=
                               "(ContentPresenter.LayoutTransform).(ScaleTransform.ScaleY)"
                             To="1"
                             Duration="0:0:0.4"/>
                    </Storyboard>
                </BeginStoryboard>
            </Trigger.EnterActions>
            <Trigger.ExitActions>
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation Storyboard.TargetName="ExpanderContent"
                             Storyboard.TargetProperty=
                               "(ContentPresenter.LayoutTransform).(ScaleTransform.ScaleY)"
                             To="0"
                             Duration="0:0:0.4"/>
                    </Storyboard>
                </BeginStoryboard>
            </Trigger.ExitActions>
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>
...

内容“揭示”

ExpanderTemplateArticle/reveal.gif

要实现内容“揭示”(不确定如何称呼它)比上述动画稍微复杂一些。起初,我考虑将ContentPresenter包装在ScrollViewer中,并将ScrollViewer的高度从0动画到内容的高度ActualHeight。然而,根据MSDN,ControlTemplate Storyboard属性中的数据绑定**不被支持**,所以您**不能**执行诸如To="{Binding ...,Path=ActualHeight}"之类的操作。因此,采用了变通方法(如下),其功劳归于Justin在此线程中

参考下面的代码,基本思想是将ScrollViewerHeight绑定到(Tag * 内容的ActualHeight),这可以通过MultiBinding和Converter完成。这样,在展开/折叠时,我们可以将Tag属性在0和1之间动画,从而有效地缩放ScrollViewerHeight。在初始折叠状态下,Tag设置为0,因此ScrollViewerHeight设置为0。在展开状态下,Tag将设置为1,ScrollViewerHeight将设置为内容的ActualHeight

XAML所需的命名空间声明

xmlns:local ="clr-namespace:SampleExpander"
xmlns:sys="clr-namespace:System;assembly=mscorlib"

下面是“揭示”动画的XAML。请注意第一行声明了所使用的转换器。

<local:MultiplyConverter x:Key="multiplyConverter" />
<ControlTemplate x:Key="RevealExpanderTemp" TargetType="{x:Type Expander}">
    <DockPanel>
        <ToggleButton x:Name="ExpanderButton" ... />
        <ScrollViewer x:Name="ExpanderContentScrollView" DockPanel.Dock="Bottom"
                      HorizontalScrollBarVisibility="Hidden"
                      VerticalScrollBarVisibility="Hidden"
                      HorizontalContentAlignment="Stretch"
                      VerticalContentAlignment="Bottom"
                      >
            <ScrollViewer.Tag>
                <sys:Double>0.0</sys:Double>
            </ScrollViewer.Tag>
            <ScrollViewer.Height>
                <MultiBinding Converter="{StaticResource multiplyConverter}">
                    <Binding Path="ActualHeight" ElementName="ExpanderContent"/>
                    <Binding Path="Tag" RelativeSource="{RelativeSource Self}" />
                </MultiBinding>
            </ScrollViewer.Height>
            <ContentPresenter x:Name="ExpanderContent" ContentSource="Content"/>
        </ScrollViewer>
    </DockPanel>
    <ControlTemplate.Triggers>
        <Trigger Property="IsExpanded" Value="True">
            <Trigger.EnterActions>
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation 
                           Storyboard.TargetName="ExpanderContentScrollView"
                           Storyboard.TargetProperty="Tag"
                           To="1"
                           Duration="0:0:0.4"/>
                    </Storyboard>
                </BeginStoryboard>
            </Trigger.EnterActions>
            <Trigger.ExitActions>
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation 
                             Storyboard.TargetName="ExpanderContentScrollView"
                             Storyboard.TargetProperty="Tag"
                             To="0"
                             Duration="0:0:0.4"/>
                    </Storyboard>
                </BeginStoryboard>
            </Trigger.ExitActions>
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

下面是代码隐藏中XAML使用的“MultiplyConverter”。它必须与代码隐藏位于同一命名空间。它只是将MultiBinding中输入的所有值相乘。在本例中是Tag*(ExpanderContentActualHeight)。结果被返回并馈入ExpanderContentScrollViewHeight

public class MultiplyConverter : IMultiValueConverter
{
   public object Convert(object[] values, Type targetType, 
          object parameter, CultureInfo culture)
   {
       double result = 1.0;
       for (int i = 0; i < values.Length; i++)
       {
           if (values[i] is double)
               result *= (double)values[i];
       }

       return result;
   }

   public object[] ConvertBack(object value, Type[] targetTypes, 
          object parameter, CultureInfo culture)
   {
       throw new Exception("Not implemented");
   }
}

性能限制

不要在动画展开器中放置太多控件。当需要处理的元素过多时,布局/渲染转换动画可能不会流畅。

Using the Code

本文顶部提供了示例代码。我已将所有模板声明为StaticResources。复制粘贴模板时,请务必包含所有必需的组件。例如,对于动画Expander模板,您需要同时复制ToggleButton模板和Expander模板本身。

感谢阅读!

请评分!这是我在CodeProject上的第一篇文章,所以如果您有任何问题或疑虑,请在下面的评论中告诉我。希望本文对您有所帮助。

© . All rights reserved.