A Home for the Collapsible Panel

Phew! It's been a long time since my last post. I have no excuse. I've been a lazy bum and I know it. Well, getting married will do that to ya :)

I have been using the Collapsible Panel control as a tool to demonstrate various Silverlight concepts over the past few months. I started out by building my first content control, then moved on to using VisualTransitions to improve it and finally migrated it to Silverlight 2 RTM when it came out. For me, its been a great way to learn about Silverlight's UI libraries and the intricacies of Silverlight's layout subsystem. This article is another one in this series where I explore writing a container control for the collapsible panel. The code here is by no means final and I am sure there are things that can be improved and features that can be added. Please use the comments link below or the email link at the top to reach out to me with your feedback and suggestions.

Ever since I first put up the collapsible panel control a lot of people have asked me for an elegant way of putting multiple panels on screen. Container controls like StackPanel don't know anything about the CollapsiblePanel expanding or collapsing so they can't animate their content when a panel changes size. What we need is a new container that can respond to the expanding and collapsing of panels that it contains. To get a quick peek at what we're going to build, click here. The demo shows two collapsible panels sitting in a CollapsiblePanelContainer (our new container control) and StackPanel side by side. Notice how the CollapsiblePanelContainer automatically compensates for the panels expanding and collapsing. The source code for the CollapsiblePanel and CollapsiblePanelContainer (along with samples) is here:

Right, lets dive into the code. The first thing that needed to be done was to add an event to the CollapsiblePanel control that the container can hook into. This event simply tells the container (or whoever else is listening) that the CollapsiblePanel is about to change its size. Here's what the event definition looks like:

public event EventHandler<SizeChangingEventArgs> SizeChanging;

 

We trigger the event whenever the IsExpanded dependancy property changes. To do this, we must change the IsExpandedChanged callback method to look like this:

private static void IsExpandedChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
bool expanded = (bool)e.NewValue;
CollapsiblePanel panel = o as CollapsiblePanel;
if (panel != null)
{
if (expanded)
{
if (panel.SizeChanging != null)
{
SizeChangingEventArgs se = new SizeChangingEventArgs(new Size(0, panel._expandCollapseButton.ActualHeight), new Size(0, panel._expandCollapseButton.ActualHeight + panel._content.ActualHeight));
panel.SizeChanging(panel, se);
}

_ChangeState(panel, Expand, panel._RollDownStoryboardName);
}
else
{
if (panel.SizeChanging != null)
{
SizeChangingEventArgs se = new SizeChangingEventArgs(new Size(0, panel._expandCollapseButton.ActualHeight), new Size(0, panel._expandCollapseButton.ActualHeight));
panel.SizeChanging(panel, se);
}

_ChangeState(panel, Collapse, panel._RollUpStoryboardName);
}
}
}

Note the bold bits. We simply check whether there are any event handlers attached, and depending upon the new value of IsExpanded we either pass the height as just the height of the title (if the panel is collapsing) or the height of the title plus the height of the content (if the panel is expanding). Also notice that I always pass the width as 0. While this isn't technically correct we are only concerned with changing height at the moment, so we'll let that be until the next version :) The SizeChangedEventArgs class, which is derived from RoutedEventArgs carries the old/new size information to the event subscribers.

Finally, we add a new property called VisibleHeight to the CollapsiblePanel:

public double VisibleHeight
{
get
{
if (IsExpanded)
{
return (_expandCollapseButton.ActualHeight + _content.ActualHeight);
}
else
{
return _expandCollapseButton.ActualHeight;
}
}
}

The VisibleHeight property is a nice convenient way for the container (or anybody else) to find out what size the CollapsiblePanel is at any given moment.

The real magic happens in a new class called CollapsiblePanelContainer. The CollapsiblePanelContainer class behaves a bit like a StackPanel. CollapsiblePanel instances added to the class are laid out vertically, one after the other. To put some distance between the panels, you can use the Margin property which is respected when CollapsiblePanelContainer does it's layout. The CollapsiblePanelContainer class implements a bunch of attached dependancy properties to keep track of the positions and order of child panels. These properties are:

  • TopProperty (double): The Y co-ordinate position of a panel within the CollapsiblePanelContainer
  • PreviousPanelProperty (CollapsiblePanel): The panel that precedes (is above) a given panel visually
  • NextPanelProperty (CollapsiblePanel): The panel that succeeds (is below) a given panel visually

The implementations of all the attached properties are pretty standard stuff so I won't go into the details of it.

The first thing the CollapsiblePanelContainer has to do is to layout the CollapsiblePanels correctly. This is done in the ArrangeOverride and MeasureOverride methods. These methods correspond to the Silverlight layout system's Arrange and Measure passes (see here for more info on the Silverlight layout system). Here's what our ArrangeOverride method looks like:

protected override Size ArrangeOverride(Size finalSize)
{
CollapsiblePanel previousPanel = null;
double panelTop = 0;

for (int i = 0; i < Children.Count; i++)
{
CollapsiblePanel panel = Children[i] as CollapsiblePanel;
if (panel != null && (panel as FrameworkElement).Visibility == Visibility.Visible)
{
panel.SizeChanging += new EventHandler<SizeChangingEventArgs>(panel_SizeChanging);

if (previousPanel != null)
{
double previousPanelHeight = 0;

if ((previousPanel as FrameworkElement).Visibility == Visibility.Visible)
{
previousPanelHeight = previousPanel.VisibleHeight;
previousPanelHeight += (previousPanel as FrameworkElement).Margin.Top + (previousPanel as FrameworkElement).Margin.Bottom;
}

panelTop = panelTop + previousPanelHeight;
SetTop(panel as UIElement, panelTop);

SetNextPanel(previousPanel, panel);
SetPreviousPanel(panel, previousPanel);

_AddStoryboard(panel as UIElement, panelTop);
}

previousPanel = panel;
}

double panelHeight = 0;
panelHeight = panel.DesiredSize.Height;//.ActualHeight;
panelHeight += (panel as FrameworkElement).Margin.Top + (panel as FrameworkElement).Margin.Bottom;

Children[i].Arrange(new Rect(0, panelTop, finalSize.Width, panelHeight));
}

return finalSize;
}

The method iterates through each of the panel's children and sets up the Top, PreviousPanel and NextPanel attached properties. Notice that the method only works with CollapsiblePanel objects so at the moment, it isn't possible to put anything other than CollapsiblePanels inside a CollapsiblePanelContainer. The point of the ArrangeOverride method is to workout a rectangular box that can fit the child that needs laying out. Once this rectangle's size and location within the panel has been worked out, the ArrangeOverride method calls the child's Arrange method with the rectangle. This tells the layout system exactly how/where to arrange that child.

In addition to doing the arranging, our implementation of ArrangeOverride also attaches a storyboard to each panel (except the first one which won't need to move in response to other panels moving). This story board allows us to animate a panel if the panels before it expands, contract or move. Notice that we also attach an event handler to each panel's SizeChanging event. As we will see in a little bit, this event handler is where the container triggers the actual animation. Here's what the _AddStoryboard method looks like:

private void _AddStoryboard(UIElement panel, double value)
{
TransformGroup tGroup = new TransformGroup();
TranslateTransform translate = new TranslateTransform();
translate.Y = 0;
tGroup.Children.Add(translate);
panel.RenderTransform = tGroup;

string sbName = (panel as FrameworkElement).Name + "Animation";

if (Resources.Contains(sbName))
{
Storyboard sb = Resources[sbName] as Storyboard;
DoubleAnimationUsingKeyFrames anim = sb.Children[0] as DoubleAnimationUsingKeyFrames;
SplineDoubleKeyFrame keyFrame = anim.KeyFrames[0] as SplineDoubleKeyFrame;
keyFrame.Value = -(value);
}
else
{
Storyboard sb = new Storyboard();
sb.SetValue(NameProperty, sbName);
DoubleAnimationUsingKeyFrames anim = new DoubleAnimationUsingKeyFrames();
sb.Children.Add(anim);
Storyboard.SetTargetName(anim, (panel as FrameworkElement).Name);
Storyboard.SetTargetProperty(anim, new PropertyPath("(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.Y)"));
anim.BeginTime = new TimeSpan(0, 0, 0);
SplineDoubleKeyFrame keyFrame = new SplineDoubleKeyFrame();
KeySpline spline = new KeySpline();
spline.ControlPoint1 = new Point(0, 1);
spline.ControlPoint2 = new Point(1, 1);
keyFrame.KeySpline = spline;
keyFrame.KeyTime = new TimeSpan(0, 0, 1);
keyFrame.Value = -(value);
anim.KeyFrames.Add(keyFrame);
Resources.Add(sbName, sb);
}
}

The method adds a storyboard with the name (panel name) + "Animation". For this reason it is important that CollapsiblePanels put in a CollapsiblePanelContainer have names defined. The Storyboard targets a translate transform which is also added to the panel at this point.

Our implementation of MeasureOverride is pretty straightforward. We simply total up the height of the CollapsiblePanels contained within the CollapsiblePanelContainer and return the total height. Here's what it looks like:

protected sealed override Size MeasureOverride(Size constraint)
{
double maxHeight = 0;
foreach (FrameworkElement child in Children)
{
child.Measure(constraint);
maxHeight += child.DesiredSize.Height;
}

return new Size(constraint.Width, maxHeight);
}

Now lets take a look at the panel_SizeChanging event handler:

void panel_SizeChanging(object sender, SizeChangingEventArgs e)
{
CollapsiblePanel panel = sender as CollapsiblePanel;
CollapsiblePanel nextPanel = GetNextPanel(panel);
if (panel != null && nextPanel != null)
{
double goToY = _GetElementTop(panel as UIElement) + e.NewSize.Height + (panel as FrameworkElement).Margin.Bottom + (nextPanel as FrameworkElement).Margin.Top;
_MovePanel(nextPanel, goToY);
}
}

Note that because of the SizeChangingEventArgs passed in, we know exactly what the new size of the panel is going to be. All we have to do here is to find the next panel (using the handy NextPanel attached property) and move that panel up (or down) to compensate for the change. To do the moving, we will use the storyboard we added in the ArrangeOverride method. Here's what _MovePanel looks like:

void _MovePanel(CollapsiblePanel panel, double newY)
{
CollapsiblePanel nextPanel = GetNextPanel(panel);
if (nextPanel != null)
{
double nextPanelY = newY + panel.VisibleHeight + (panel as FrameworkElement).Margin.Bottom + (nextPanel as FrameworkElement).Margin.Top;
_MovePanel(nextPanel, nextPanelY);
}

if (panel != null)
{
string animationResourceName = (panel as FrameworkElement).Name + "Animation";
Storyboard sb = Resources[animationResourceName] as Storyboard;

if (sb != null)
{
double goFromY = _GetElementTop(panel as UIElement);
double translation = (newY - goFromY) + _GetExistingTranslation(panel as UIElement);
((sb.Children[0] as DoubleAnimationUsingKeyFrames).KeyFrames[0] as SplineDoubleKeyFrame).Value = translation;
sb.Begin();
}
}
}

In _MovePanel, we first dig out the CollapsiblePanel that is after the current CollapsiblePanel and recursively call _MovePanel for it. This means that all the CollapsiblePanels below the one that originally changed size will automatically move to compensate for the change. Once we've made the recursive call, we dig out the storyboard for the current CollapsiblePanel and set it's value to the desired translation. Notice that we work out the existing translation first since it is quite possible that the panel we are trying to move has already moved sometime in the past. This would mean that the existing translation would be non-zero (negative if the panel moved up, positive if it moved down) and that we need to take the existing value into account when calculating the new translation.

Finally, to show off what the new container control can do, I added a bunch of CollapsiblePanels to a CollapsiblePanelContainer and sat it side-by-side with a StackPanel also containing CollapsiblePanels. This demonstration can be found in the project called Knowledgecast.DemoApplication project (Page.xaml). To see the code in action, go here and download the code go here:

Well there you have it. Now we've gone and given our CollapsiblePanel a fitting home to live in along with its other brethren. Have fun playing around with the samples and do let me know if you use this code someplace. I love to see my stuff in action :)