Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Animating Expander Control

0.00/5 (No votes)
1 Apr 2009 1  
A custom content control that can be expanded/collapsed with animation.

Sample Image

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:

<!--Expand Left-->
<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, ViewBoxes 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:

  1. 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.
  2. The content of the control, presented in a ContentPresenter.
  3. 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 for up direction-->
    <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 for down direction-->
    <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 for left direction-->
    <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 for right direction-->
    <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) {
    //determine the DP that needs to be changed
    DependencyProperty actualContentLengthDP;
    this.DetermineLengthDPToChange(out actualContentLengthDP);
    
    //change the determined DP with animation
    if (AnimationEnabled) {
        this.ExpandWithAnimation(actualContentLengthDP);
    }
    //change the determined DP without animation
    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;
    //Content is collapsed. Expand to last saved this.ExpandedValue.
    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
) {
    //execute Animation
    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) {
        //set this.ExpandedValue to new dragged value, then resize the content.
        //if new value is smaller then zero, or bigger the Content's Max Length,
        //reset length accordingly
        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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here