使用 ControlTemplate 自定义 WPF Expander






4.91/5 (94投票s)
介绍如何通过模板化来自定义WPF Expander的外观和行为。
引言
本文将演示如何为WPF Expander
控件创建ControlTemplate
,以自定义其外观和行为。首先,将详细解释默认模板的一个简化版本。然后,将在此默认模板的基础上构建几个更复杂的版本,并进行自定义,例如添加动画和更改外观。本文所示代码旨在同时兼容.NET 3.5和.NET 4。
背景
预计读者应具备WPF基本概念(如绑定和触发器)的初步理解。对动画的基本了解将非常有帮助。读者还应熟悉(或愿意查阅)常用控件,如Grid
、DockPanel
、ToggleButton
等。
Expander的视觉“部分”
通常,一个Expander
控件在**视觉上**由三个部分组成。为方便参考,我将这些部分称为“图标”、“标题”和“内容”。例如,“header”指的是图中的部分,而“Header”则指XAML中的某个属性。请原谅我的画图技巧。
构建模板
本文将介绍的ControlTemplate
使用了一个模板化的ToggleButton
作为标题/图标部分,并使用一个ContentPresenter
作为实际的Expander
内容。所有这些部分都使用DockPanel
进行布局,但您也可以选择任何布局控件。
Expander的ToggleButton模板
第一步是为ToggleButton
(Expander
的按钮)创建一个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
)来“暴露”这些属性。例如,我们可以将模板内Border
的Background
属性绑定到模板化父级(TargetType ToggleButton
)的背景,就像上面的代码一样。这样,当使用此模板时,如果我们设置ToggleButton
的Background
属性为某种颜色,Border
的背景也将设置为该颜色。
实际上,有两种标准的方法可以绑定到模板化父级。
- 使用
TemplateBinding
例如,在ToggleButton
的模板内部
<Border ... Background="{TemplateBinding Background}"/>
TemplateBinding
是标准绑定的优化版本,因为它在编译时进行评估,但它确实有一些限制。例如,它只支持OneWay绑定模式,并且不允许设置标准Binding
所具有的诸如Converters
或StringFormat
之类的属性。
TemplatedParent
的RelativeSource
的Binding
例如,在Expander
的模板内部
<ToggleButton ... IsChecked="{Binding Path=IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" />
这种方法比TemplateBinding
更灵活但速度更慢,因为它提供了标准Binding
的所有功能。
可点击区域
放在ToggleButton
内的任何内容都将可点击,用于切换Expander
内容的可视性。如果您不希望用户通过点击标题组件来切换Expander
,则可以将标题部分(例如ContentPresenter
)移出ToggleButton
的模板,而是将其放入Expander
的模板中。
此外,对于任何Fill
属性为空或未设置的Shape
,只有其“**轮廓**”才可点击。这是设计使然。要解决此问题,一种方法是将一个设置了Background
的Border
包裹模板的所有组件,但您必须确保Background
属性在运行时不会保持未设置或为空(即使它是通过Binding
设置的)。另一种方法是在使用的任何Shape
的顶部放置一个Fill
设置为Transparent
的Rectangle
(如上所示)。关键在于避免Background
/Fill
属性值未设置或为空。
Expander模板
使用上面创建的ToggleButton
模板,我们可以将ToggleButton
放在ContentPresenter
的顶部。然后,我们可以将按钮的IsChecked
绑定到Expander
的IsExpanded
,并设置一个触发器,在IsExpanded
为false
时使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
,并将ToggleButton
和ContentPresenter
放在两行中。非常简单。
<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>
内容拉伸
要使内容在展开时“拉伸”出来,我们可以使用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>
...
内容“揭示”
要实现内容“揭示”(不确定如何称呼它)比上述动画稍微复杂一些。起初,我考虑将ContentPresenter
包装在ScrollViewer
中,并将ScrollViewer
的高度从0动画到内容的高度ActualHeight
。然而,根据MSDN,ControlTemplate
Storyboard属性中的数据绑定**不被支持**,所以您**不能**执行诸如To="{Binding ...,Path=ActualHeight}"
之类的操作。因此,采用了变通方法(如下),其功劳归于Justin在此线程中。
参考下面的代码,基本思想是将ScrollViewer
的Height
绑定到(Tag
* 内容的ActualHeight
),这可以通过MultiBinding和Converter完成。这样,在展开/折叠时,我们可以将Tag
属性在0和1之间动画,从而有效地缩放ScrollViewer
的Height
。在初始折叠状态下,Tag
设置为0,因此ScrollViewer
的Height
设置为0。在展开状态下,Tag
将设置为1,ScrollViewer
的Height
将设置为内容的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
*(ExpanderContent
的ActualHeight
)。结果被返回并馈入ExpanderContentScrollView
的Height
。
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上的第一篇文章,所以如果您有任何问题或疑虑,请在下面的评论中告诉我。希望本文对您有所帮助。