Introduction
This article will demonstrate how to create a ControlTemplate for the WPF Expander
control to customize its appearance and behavior. First, a simplified version
of the default template is explained in detail. Then, a couple more complex versions will be built on top of the default template with customizations such as adding animation
and changing the look and feel. The code shown in this article was designed to be both .NET 3.5 and .NET 4 compatible.
Background
It is expected that the reader has basic understanding of basic WPF concepts such as Binding and Triggers. A basic knowledge of Animations will be very helpful.
The reader should also be familiar (or willing to lookup) commonly used controls such as Grid
, DockPanel
, ToggleButton
, etc..
Visual "Parts" of Expander
Typically, an Expander
control is visually composed of three parts. I'm going to call those parts as "icon",
"header", and "content" for easier reference. For example, "header" means the part in the diagram, while "Header"
refers to some property in the XAML. Please excuse my MS Paint skills:
Building the Template
The ControlTemplate that will be described here uses a templated ToggleButton
for the header/icon parts, and a ContentPresenter
for the actual
Expander
content. All of these parts are then laid out using a DockPanel
,
but it really could be any layout control of your choice.
Expander ToggleButton Template
The first step is to create a ControlTemplate
for the ToggleButton
(Expander
's button); this will later be used within
the Expander
's template. Inside the ToggleButton
's template, we will use WPF Shapes to draw our icon and another ContentPresenter
to be the header, which are then laid out using a Grid
. Some Triggers are set for IsMouseOver
/IsPressed
to change the icon's look,
and also for IsChecked
to change the icon when the button is toggled. The code is shown below. I will also talk about a few key points after that.
<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>
-->
<Trigger Property="IsChecked"
Value="True">
<Setter Property="Data"
TargetName="Sign" Value="M 0,5 H 10 Z"/>
</Trigger>
-->
<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>
Binding to Templated Parent
We can "expose" properties of the component controls inside a template by binding them to the templated parent (ToggleButton
).
For example, we can bind the Background
property of the Border
inside the template to the background of the templated parent (the TargetType ToggleButton
)
like in the above code. In this way, when we are using this template, if we set the ToggleButton
's Background
property to some colour,
the Border
's background will be set to that colour as well.
There are actually two standard ways to bind to the templated parent:
- Using
TemplateBinding
Example, inside ToggleButton
's template:
<Border ... Background="{TemplateBinding Background}"/>
TemplateBinding
is the more optimized version of standard binding as it is evaluated at compile time, but it does have some limitations.
For example, it only supports the OneWay binding mode and does not allow you to set attributes such as Converters or StringFormat that standard Binding
has.
Using Binding
with RelativeSource
set to TemplatedParent
Example, inside Expander
's template:
<ToggleButton ... IsChecked="{Binding Path=IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" />
This method is more flexible but slower than TemplateBinding
as it provides all the features that standard Binding
has.
Clickable Area
Whatever we place inside the ToggleButton
will be clickable for toggling the visibility of the Expander
's content. If you do not want the user to toggle
the Expander
by clicking header components, then you can move the header parts (e.g., the ContentPresenter
) out of the ToggleButton
's template and put it in the Expander
's
template instead.
Furthermore, for any Shape
whose Fill
property is null or unset will only be clickable on the "outline" of the
Shape
. This is by design. To work around this, one way is to wrap a Border
with a set Background
around all of the components of the template,
but you must make sure that the Background
property does not remain unset or null at runtime (even if it is set using Binding
). Another way is to put a
Rectangle
with Fill
set to Transparent
on top of any Shape
you are using
which was done above. The key idea is to avoid unset or null values for Background
/Fill
properties.
Expander Template
Using the ToggleButton
template created above, we can put the ToggleButton
on top of a ContentPresenter
. We can then bind the button's
IsChecked
to the Expander
's IsExpanded
and set a Trigger that will make the ContentPresenter
collaspe when IsExpanded
is false
. Note that this does not handle ExpandDirection
and only includes a few basic TemplateBindings. For ExpandDirection
,
you will need need to set Triggers to change the layout of the entire template according to ExpandDirection
's value. It is best that you download "Default WPF Themes"
on Control Styles and Templates and look at the default Expander
template in one of the theme XAMLs.
<!---->
<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>
Customizations
Customizing the Expander
is just like creating a regular WPF GUI except you use more basic controls to build a more complex one. There are many things you can do to customize
the template, I'm only going to list a couple of common examples here.
Using the Tag Property
The Tag
property is desgined to store custom information;
its type is Object
so you can mostly use it for anything you want. All FrameworkElement
s and descendants inherit this property. For example, suppose you wanted separate
background colours for the header and content parts of the Expander
. In this case, we can use Tag
to store the Background
value for the header and
do some binding to expose it.
In the Expander's ToggleButton
template, you must have Background
exposed through binding like this:
<ControlTemplate x:Key="SimpleExpanderButtonTemp" TargetType="{x:Type ToggleButton}">
<Border x:Name="ExpanderButtonBorder"
Background="{TemplateBinding Background}"
...
Add Background TemplateBinding
to Expander
template's ToggleButton
like this:
<ControlTemplate x:Key="SimpleExpanderTemp" TargetType="{x:Type Expander}">
...
<ToggleButton x:Name="ExpanderButton"
Template="{StaticResource SimpleExpanderButtonTemp}"
...
Background="{TemplateBinding Tag}"
</ToggleButton>
...
Then we can set Tag
to a colour that we want for the ToggleButton
on the Expander
when using it. Please note that since we are using
Tag
to set the Background
property indirectly, we cannot use strings like "Red" or "Green"
to set Tag=>Background as our string value is not automatically converted. Thus, we need to pass a Brush
object instead, which is the type for
the Background
property.
<Expander ...>
<Expander.Tag>
<SolidColorBrush Color="Red" />
</Expander.Tag>
What if you want more than one custom property or do something more extensive? Then the best way is to create a new class that inherits from Expander
and declare the extra properties in that class. However, creating new custom/composite controls is not within the scope of this article.
Make Header the Same Width as the Content
If you want the content to be the same width as the header, then you can swap the DockPanel
for a Grid
and lay out the ToggleButton
and
ContentPresenter
in two rows. Very simple:
<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>
...
Expand/Collapse Animation
Rotating Arrow
To animate rotation of the arrow, we can use RenderTransform
.RotateTransform
to rotate the element over time. Since this is using RenderTransform
, it will only affect how the element is drawn and not disturb the layout.
<ControlTemplate.Triggers>
-->
<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>
Stretch Out Content
To get the content to "stretch" out when expanded, we can use
LayoutTransform
.ScaleTransform
and animate its ScaleY
property. In this case, we use LayoutTransform
instead of RenderTransform
since we need the height (layout) of the control
to change not just how the control is drawn (rendered). You can see the difference by replacing LayoutTransform
with RenderTransform
and experimenting
with that. Animations are done through Triggers. In the code below, note the syntax for referencing the ScaleY
property and declaring LayoutTransform
under
ContentPresenter
:
<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>
...
"Reveal" Content
To get the content to "reveal" (not sure what to call it) is slightly more complicated than the above animations. At first, I thought to wrap
the ContentPresenter
inside a ScrollViewer
and animate the ScrollViewer
's height from 0 to the content's ActualHeight
. However, according
to MSDN, data binding in ControlTemplate
Storyboard properties is not supported, so you can't do something
like To="{Binding ...,Path=ActualHeight}"
. Thus, a workaround was used instead (below), and it is credited
to Justin in this thread.
Referring to the code below, the basic idea is to bind the ScrollViewer
's Height
to (Tag
* ActualHeight
of the content) which was done using
MultiBinding and a Converter. In this way, when expanding/collapsing,
we can animate the Tag
property between 0 and 1 which will in effect scale the Height
of the ScrollViewer
. In the initial collapsed state,
Tag
is set to 0 so that the ScrollViewer
's Height
is set to 0. In expanded state,
Tag
will be set to 1 and ScrollViewer
's Height
will be set to the ActualHeight
of the content.
Namespace declarations required for the XAML:
xmlns:local ="clr-namespace:SampleExpander"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Below is the XAML for the "reveal" animation. Note the first line to declare the converter that was used.
<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>
Below is the "MultiplyConverter
" that was used in the XAML from the code-behind. It must be in the same namespace as the code-behind.
It simply multiplies all the values that was fed to it in MultiBinding
. In this case its Tag
*(ExpanderContent
's ActualHeight
).
The result is then returned and fed into ExpanderContentScrollView
's 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");
}
}
Performance Limitation
Don't put too many controls inside the animated expanders. Layout/Render transform animations may not look smooth when there are too many elements to process at once.
Using the Code
Sample code is provided at the top of this article. I've declared all the templates as StaticResources. When copy-pasting the templates, be sure to include
all the required components. For example, for the animated Expander
templates, you will need to copy both the ToggleButton
template
and the Expander
template itself.
Thanks for Reading!
Please rate it! This is my first article on CodeProject, so let me know if there are any questions or concerns in the comments below. I hope that this article was useful for you.