Introduction
While developing our software in Whitebox Security, we came upon the requirement to build a control which can be expanded and collapsed, while holding different content.
The requirements from the control were:
- Take minimal screensize when minimized.
- When maximized, the control can be resized by the user to a custom size.
- If the user collapses and expands the control, it would expand to the last expanded length (height or width), as set by the user.
- When the size of the control changes, it would resize other controls on screen.
- Expand and collapse operations would be animated.
- The control can be simply configured to expand to different directions.
Requirements
The project was written in Visual Studio 2008, and built on .NET 3.5 SP1.
Prior knowledge
To use the control, you should have basic WPF knowledge.
If you want to understand how the control is written, you should be familiar with:
The demo application
The demo application is composed of a Window
split into four with a Grid
. On each quarter of the screen, an AnimatingExpanderControl
is placed holding some content.
Each of the four controls demonstrates a slightly different configuration. Some of the things that differ are:
- The expand direction.
- Starting as collapsed or expanded.
- Using animation for expanding and collapsing.
- Limiting the maximum width/height of the content.
Using the control
Here is the XAML of one of the four controls:
<!---->
<Border
BorderBrush="Black"
BorderThickness="2"
Grid.Row="1"
Grid.Column="1"
Padding="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Viewbox
Stretch="Uniform"
Grid.Column="0">
<Image
Source="..\Images\cat_rope.gif">
</Image>
</Viewbox>
<local:AnimatingExpanderControl
ExpandDirection="Left"
Style="{StaticResource AnimatingExpanderControlStyle}"
Grid.Column="1"
Title="Expand Content Left"
>
<Viewbox
Stretch="Uniform">
<StackPanel
Orientation="Vertical">
<Button>
Content
</Button>
<TextBlock>
Some more content
</TextBlock>
<StackPanel
Orientation="Vertical">
<RadioButton Content="First Choice"/>
<RadioButton Content="Second Choice"/>
<RadioButton Content="Third Choice"/>
</StackPanel>
</StackPanel>
</Viewbox>
</local:AnimatingExpanderControl>
</Grid>
</Border>
As you can see, the control is defined with a ViewBox
as its content. This is so the content would be resized according to the space it is given. The control’s “neighboring” content is also placed in a ViewBox
for the same reason.
The custom Dependency Properties that can be set on the control are:
ExpandDirection
- Determines the direction to expand to. Values can be Up
, Down
, Left
, and Right
.
IsCollapsed
- Flag which shows if the control is collapsed or not when the control is initialized.
InitialExpandedLength
- This can be set by the user to control the initial value (width or height) that the expander will expand to.
AnimationEnabled
- Flag which enables or disables the expand and collapse animation on the control.
Title
- This property sets the title of the control which is displayed next to the expand/collapse button.
One of the ways to achieve dynamically resizing the different components on the screen, as demonstrated in the demo, is to place the contents on a Grid
with the AnimatingExpanderControl
in a Column
(or Row
) with Width
set to “Auto
” and the rest of the screen’s content in a Column
with Width
“*”. In addition, ViewBox
es are used to hold the content.
To limit the maximum size the control can be expanded to, set the MaxWidth
(or MaxHeight
) on the Content of the control. This can be seen in the demo on the control that expands right.
The structure of the control
<Border BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
CornerRadius="3"
SnapsToDevicePixels="true">
<DockPanel
Name="TemplateDockPanel"
DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}}">
<Thumb
DockPanel.Dock="Top"
Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}"
BorderBrush="White"
x:Name="PART_Thumb"
Cursor="SizeNS"
DragDelta="GridSplitter_DragDelta"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Margin="5,0,5,5"
IsEnabled="{Binding ElementName=PART_CheckBox, Path=IsChecked, Mode=OneWay}"
Style="{StaticResource GridSplitterStyle}"/>
<ContentPresenter
DockPanel.Dock="Bottom"
Margin="{TemplateBinding Padding}"
x:Name="PART_Content"/>
<CheckBox
x:Name="PART_CheckBox"
Checked="BottomCB_Checked"
Unchecked="BottomCB_UnChecked"
Style="{StaticResource CheckBox_Expander}"
Margin="1"
Content="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Title}"
Foreground="{TemplateBinding Foreground}"
Padding="3"
FontFamily="{TemplateBinding FontFamily}"
FontSize="{TemplateBinding FontSize}"
FontStyle="{TemplateBinding FontStyle}"
FontStretch="{TemplateBinding FontStretch}"
FontWeight="{TemplateBinding FontWeight}">
</CheckBox>
</DockPanel>
</Border>
The control is made of three components, placed in a DockPanel
. These components are set in the ControlTemplate
of the control:
- A
Thumb
which simulates a GridSplitter
. The GridSplitter
allows for resizing the control by dragging it. The GridSplitter
is disabled if the control is collapsed.
- The content of the control, presented in a
ContentPresenter
.
- The
CheckBox
which simulates a button with an arrow. When pressed, the control is expanded/collapsed, and the arrow changes direction. The control’s Title
Dependency Property is presented next to the CheckBox
.
Some basic styles are applied to the Thumb
and the Checkbox
, which I won’t go into.
In addition, some triggers are set in the ControlTemplate
:
<ControlTemplate.Triggers>
-->
<Trigger
Property="ExpandDirection"
Value="Up">
<Setter Property="DockPanel.Dock"
Value="Top"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Bottom"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="Height"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
-->
<Trigger
Property="ExpandDirection"
Value="Down">
<Setter Property="DockPanel.Dock"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Top"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="Height"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
<Setter Property="Style"
TargetName="PART_CheckBox"
Value="{StaticResource CheckBoxUpSideDown_Expander}"
/>
</Trigger>
-->
<Trigger
Property="ExpandDirection"
Value="Left">
<Setter Property="DockPanel.Dock"
Value="Left"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Right"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="Cursor"
Value="SizeWE"
TargetName="PART_Thumb"/>
<Setter Property="Width"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="LayoutTransform"
TargetName="PART_CheckBox"
>
<Setter.Value>
<RotateTransform Angle="-90"/>
</Setter.Value>
</Setter>
<Setter Property="VerticalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
-->
<Trigger
Property="ExpandDirection"
Value="Right">
<Setter Property="DockPanel.Dock"
Value="Right"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Left"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="Cursor"
Value="SizeWE"
TargetName="PART_Thumb"/>
<Setter Property="Width"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="LayoutTransform"
TargetName="PART_CheckBox"
>
<Setter.Value>
<RotateTransform Angle="90"/>
</Setter.Value>
</Setter>
<Setter Property="VerticalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
<Trigger Property="IsEnabled"
Value="false">
<Setter Property="Foreground"
Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
</Trigger>
</ControlTemplate.Triggers>
These triggers basically set different properties according to the ExpandDirection
Dependency Property of the control.
The code-behind
Here is the code for handling the user clicking on the expand/collapse button (actually a CheckBox
) while the control is collapsed:
private void BottomCB_Checked(object sender, RoutedEventArgs e) {
DependencyProperty actualContentLengthDP;
this.DetermineLengthDPToChange(out actualContentLengthDP);
if (AnimationEnabled) {
this.ExpandWithAnimation(actualContentLengthDP);
}
else {
this.ActualContent.SetValue(actualContentLengthDP, this.ExpandedValue);
}
this.IsCollapsed = false;
}
If animation is disabled, the actual content’s height (or width) is set to the ExpandedValue
. If this is the first time the control is expanded, the ExpandedValue
is taken from the InitialExpandedLength
Dependency Property. Otherwise, the value used is the last height (width) that the control was sized to, with the “GridSplitter
”.
The code below handles the expansion, when animation is used:
private void ExpandWithAnimation(
DependencyProperty actualContentLengthDP
) {
Storyboard sb = new Storyboard();
DoubleAnimation expandAnimation = new DoubleAnimation();
expandAnimation.From = 0;
expandAnimation.To = this.ExpandedValue;
expandAnimation.Duration = new TimeSpan(0, 0, 0, 0, _animationDuration);
sb.Children.Add(expandAnimation);
sb.FillBehavior = FillBehavior.Stop;
this.BeginChangeSizeAnimation(
sb,
expandAnimation,
actualContentLengthDP
);
}
private void BeginChangeSizeAnimation(
Storyboard sb,
DoubleAnimation lengthAnimation,
DependencyProperty actualContentLengthDP
) {
Storyboard.SetTargetProperty(lengthAnimation,
new PropertyPath(actualContentLengthDP.Name));
if (null != this.ActualContent) {
sb.Completed += delegate {
this.ActualContent.SetValue(actualContentLengthDP,
lengthAnimation.To.Value);
};
sb.Begin(this.ActualContent);
}
}
This time, the content’s height/width is set to the expanded value by using a simple DoubleAnimation
. After the animation is completed, a delegate sets the value of the actual content’s width/height to the .To
value.
Once the control is expanded, it can be resized by the user. This is done with a Thumb
, which simulates a GridSplitter
. To ensure the Thumb
can’t be dragged when the control is collapsed, recall the following line from the Structure
of the Control
section:
IsEnabled="{Binding ElementName=PART_CheckBox, Path=IsChecked, Mode=OneWay}"
The IsEnabled
property of the Thumb
has a binding to the IsChecked
Dependency Property of the CheckBox
. This means that only if CheckBox
IsChecked
, meaning the control is expanded, the Thumb
IsEnabled
.
This is the event handler for the thumb dragged event:
private void GridSplitter_DragDelta(object sender, DragDeltaEventArgs e) {
if (null != this.ActualContent) {
switch (this.ExpandDirection) {
case ExpandDirection.Down:
this.ExpandedValue = this.ActualContent.Height + e.VerticalChange;
this.CheckExpandedValue(this.ActualContent.MaxHeight);
this.ActualContent.Height = this.ExpandedValue;
break;
case ExpandDirection.Up:
this.ExpandedValue = this.ActualContent.Height - e.VerticalChange;
this.CheckExpandedValue(this.ActualContent.MaxHeight);
this.ActualContent.Height = this.ExpandedValue;
break;
case ExpandDirection.Left:
this.ExpandedValue = this.ActualContent.Width - e.HorizontalChange;
this.CheckExpandedValue(this.ActualContent.MaxWidth);
this.ActualContent.Width = this.ExpandedValue;
break;
case ExpandDirection.Right:
this.ExpandedValue = this.ActualContent.Width + e.HorizontalChange;
this.CheckExpandedValue(this.ActualContent.MaxWidth);
this.ActualContent.Width = this.ExpandedValue;
break;
}
}
}
The method first determines the vertical or horizontal change, and sets the ExpandedValue
accordingly. It then verifies the new ExpandedValue
is legal. Next, it sets the Height
or Width
property of the control’s content to the new ExpandedValue
. The next time the control is collapsed and then expanded, it will expand to the saved ExpandedValue
.
Here is the code for checking that the ExpandedValue
is legal, and resetting it if it isn’t:
private void CheckExpandedValue(double MaxLength) {
if (this.ExpandedValue < 0) {
this.ExpandedValue = 0;
}
if (this.ExpandedValue > MaxLength) {
this.ExpandedValue = MaxLength;
}
}
What do you think?
This is my first article for CodeProject. Please let me know if the article helped you, and what you think about it.
Credits
When writing the triggers for the ExpandDirection
, I was inspired by some of Microsoft’s original WPF control templates.