Writing a Silverlight Content Control

Note: The zip archive below has been updated after Silverlight 2 released to the web. For more details on the changes I made, see this post.

Note: I updated the article and attached code after samcov pointed out some issues with the earlier code.  

This article illustrates the writing of a Silverlight content control. Click here to see it in action. It is part of multi-post series. You can find the next post, which is about Visual Transitions here. If you’d rather just dive into the code and explore it yourself then it is available for download here:

Here's what we are going to build:

Preview

All the content in the panel (including the text, the textbox and the radio button) is defined by the consumer of the control (the page where the panel is embedded). This is the sort of thing that content controls enable in Silverlight. Now lets get on with figuring out how it's done.

For my first Silverlight project recently, I needed to do a panel based interface. I decided to do collapsible panels so that I could present as much information as possible while still giving users a choice to only see what they wanted to. Originally, I started out with a whole bunch of user controls but as you can imagine, updating and maintaining each of those user controls got very tedious very quickly.

The only thing different between these user controls was the actual content in them. This got me thinking. Is there a better way to abstract out the container while still keeping the content different? I found my answer in content controls. The Silverlight button, for instance, is a content control. The wonderful thing about a content control is that you can leave the decision of what is going to go into the control up to the consumer of the control and just concentrate on how the overall control behaves. The content can be anything ranging from simple text to elaborate graphics.

I found some great blog posts and walkthroughs on writing basic content controls for Silverlight 2 Beta 1, but nothing that used the new Visual State Manager that shipped with Silverlight 2 Beta 2. I decided there might be other people interested in doing what I have done so I wrote this post.

The first thing to do when writing a content control is to inherit from the System.Windows.Controls.ContentControl. This class is in the System.Windows.dll assembly. Start with a Silverlight class library project and add your content control class:

public class CollapsiblePanel : ContentControl
{
public CollapsiblePanel()
{
DefaultStyleKey = typeof(CollapsiblePanel);
}
}

We will call our content control CollapsiblePanel because it is a panel that the user can expand or contract at runtime. Notice the DefaultStyleKey property that I set in the constructor. This property identifies the default style for the control and is usually set to the type of the control. This is useful if for instance you want to inherit a control (such as Button) but do not want to create a whole new default style template for it. In such a case, you would just set the DefaultStyleKey to point to the type of the base class.

The next thing to do is set up some template parts and template visual states. Template parts are used identify the types of the named parts that are used to apply control templates. If that line makes little sense, you can blame it on inability to express the concept  It will become clearer exactly what purpose template parts serve as we move forward. Template visual states on the other hand are a way for the template to identify transitions from one state of the control to the other. A button for example will have visual states for events like “mouse over”, “mouse down”, “disabled” etc. Template visual states are a way for the template writer to specify how each of these states should look visually.

For our control we will need a template part that identifies the control that can be clicked to expand or collapse the panel. We will also need the template to identify a container control (such as a Grid or another Panel) that contains the content. Finally, we will need a control that contains the content container. We will see why this is needed a little later.

We only need two visual states, one to represent the expanded state and the other to represent the collapsed state. Visual states can also be grouped. For example, you might want to have one set of visual states that specify what a control’s “mouse over” and “mouse down” events look like when it is enabled. On the other hand, you might want another set of visual states that specify what “mouse over” and “mouse down” look like when the control is disabled. This is where visual state groups come in handy. For our purposes, we’ll just stick with one group we’ll call CommonStates. Enough talk, now let’s look at some code:

[TemplatePart(Name=CollapsiblePanel.ExpandCollapseButton, Type=typeof(FrameworkElement))]
[TemplatePart(Name = CollapsiblePanel.ContentContainer, Type = typeof(Panel))]
[TemplatePart(Name = CollapsiblePanel.PanelContent, Type = typeof(FrameworkElement))]
[TemplateVisualState(GroupName=CollapsiblePanel.CommonStates, Name=CollapsiblePanel.Expand)]
[TemplateVisualState(GroupName = CollapsiblePanel.CommonStates, Name = CollapsiblePanel.Collapse)]
public class CollapsiblePanel : ContentControl
{
private const string ExpandCollapseButton = "ExpandCollapseButton";
private const string ContentContainer = "ContentContainer";
private const string PanelContent = "PanelContent";
private const string CommonStates = "CommonStates";
private const string Collapse = "Collapse";
private const string Expand = "Expand";

private string _RollUpStoryboardName = null;
private string _RollDownStoryboardName = null;
private FrameworkElement _expandCollapseButton;
private Panel _contentContainer;
private FrameworkElement _content;

public CollapsiblePanel()
{
DefaultStyleKey = typeof(CollapsiblePanel);
}
}

Notice how the types of each of the template parts is specified alongside the part’s name. This makes it easy for someone who writes controls to guide the template writer. Be careful not to make the type too restrictive lest you limit the creativity of template writers. Notice for instance how I chose to make the ExpandCollapseButton a FrameworkElement instead of a button. This is because in Silverlight practically all controls and shapes have events for detecting mouse clicks and I did not want to be limited to using buttons for this template part.
Before we go any further I thought I’d discuss how the collapse/expand mechanism would work. When collapsing the panel we want the contents of the panel to disappear. However, rather than scaling the content to a zero size, I wanted to give the impression that it was rolling up behind a title bar. My solution for this was to put all the content inside a container control and set the clipping rectangle of the container control to be the same width/height as the content. This part of the template will be represented by the ContentContainer TemplatePart defined above.

Another container control inside ContentContainer would contain the actual content. This is the TemplatePart called PanelContent. When I want the content to roll up, I simply translate PanelContent up on the Y axis so that all the content ends up outside of the clipping region of ContentContainer. The following illustration represents how this works.

How it works

The gray dashed lines above represent the content when it moves out of the clipping region of the ContentContainer.
Now lets take a look at the OnApplyTemplate method implementation for CollapsiblePanel. The OnApplyTemplate method is a virtual method of ContentControl that is called as soon as the template is applied. This is a good place to get references to the template parts and set up any events that we might want to listen for. Here’s what the code looks like:

public override void OnApplyTemplate()
{
base.OnApplyTemplate();

_expandCollapseButton = GetTemplateChild(ExpandCollapseButton) as FrameworkElement;
_contentContainer = GetTemplateChild(ContentContainer) as Panel;
_content = GetTemplateChild(PanelContent) as FrameworkElement;

if (_contentContainer != null)
{
_contentContainer.SizeChanged += new SizeChangedEventHandler(_contentContainer_SizeChanged);
}

if (_content != null)
{
_content.SizeChanged += new SizeChangedEventHandler(_content_SizeChanged);
}

if (_expandCollapseButton != null)
{
if (_expandCollapseButton is ButtonBase)
{
(_expandCollapseButton as ButtonBase).Click += new RoutedEventHandler(_expandCollapseButton_Click);
}
else
{
_expandCollapseButton.MouseLeftButtonUp += new MouseButtonEventHandler(_expandCollapseButton_MouseLeftButtonUp);
}
}
}

First, we call the base implementation of OnApplyTemplate. Then, we get references to all the template parts using the GetTemplateChild method. We set size changed event handlers for both the content container and the content itself. This is needed so that we can set up the clipping rectangle (on the content container) and the translation animation for the content that I talked about earlier. Finally, we set up mouse events for the expand/collapse button. Notice that we use the Click event if the expand/collapse control inherits from ButtonBase, otherwise we just use the MouseLeftButtonUp event. This is important because controls that inherit from ButtonBase do not let the MouseLeftButtonUp event bubble up. The Click event is where you listen for mouse clicks in this case.

Lets take a look at the implementation of the events defined in OnApplyTemplate to get a better idea of what goes on when each of these events is triggered. The _contentcontainer_SizeChanged event handler simply sets up a clipping rectangle that is the same width/height as the content container:

void _contentContainer_SizeChanged(object sender, SizeChangedEventArgs e)
{
Panel container = sender as Panel;
if (container != null)
{
RectangleGeometry rg = new RectangleGeometry();
Rect r = new Rect(0, 0, container.ActualWidth, container.ActualHeight);
RectangleGeometry clip = new RectangleGeometry();
rg.Rect = r;
container.Clip = rg;
}
}

The _content_SizeChanged event handler is used to set up storyboards that actually show the content rolling up and down (depending upon whether it is expanding or collapsing). Here’s the code:

void _content_SizeChanged(object sender, SizeChangedEventArgs e)
{
FrameworkElement content = sender as FrameworkElement;
if (content != null)
{
TransformGroup tGroup = new TransformGroup();
TranslateTransform translate = new TranslateTransform();
translate.SetValue(FrameworkElement.NameProperty, "RollTransform" + Guid.NewGuid().ToString());
translate.Y = 0;
tGroup.Children.Add(translate);
content.RenderTransform = tGroup;

_RollUpStoryboardName = "RollUp" + Guid.NewGuid().ToString();
_RollDownStoryboardName = "RollDown" + Guid.NewGuid().ToString();

_SetupYTranslationStoryboard(translate, _RollUpStoryboardName, -content.ActualHeight);
_SetupYTranslationStoryboard(translate, _RollDownStoryboardName, 0); }
}

The method first attaches a translate transform to the content’s RenderTransform. The _SetupYTranslationStoryboard method is used to set up the actual storyboard that will translate the content up or down. The method takes a reference to the translate transform, a string containing the name of the storyboard and the value of Y to which the storyboard must animate the transform. To roll up, we simply translate up by the content’s height (negative of the content.ActualHeight) and to roll down we translate Y back to 0. Lets take a look at the _SetupYTranslationStoryboard method:

void _SetupYTranslationStoryboard(TranslateTransform transform, string sbName, double translation)
{
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 = translation;
}
else
{
Storyboard sb = new Storyboard();
sb.SetValue(NameProperty, sbName);
DoubleAnimationUsingKeyFrames anim = new DoubleAnimationUsingKeyFrames();
sb.Children.Add(anim);
Storyboard.SetTarget(anim, transform);
Storyboard.SetTargetProperty(anim, new PropertyPath("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 = translation;
anim.KeyFrames.Add(keyFrame);
Resources.Add(sbName, sb);
}
}

I like smooth animations, so I use a key spline to slow down the animation towards the end. Now let’s take a look at what the expand/collapse button’s event handlers look like:

void _expandCollapseButton_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
IsExpanded = !IsExpanded;
}

void _expandCollapseButton_Click(object sender, RoutedEventArgs e)
{
IsExpanded = !IsExpanded;
}

These event handlers simply flip the value of the IsExpanded dependancy property (which we will look at in a moment). All the real work of animating between the expanded and contracted states goes on in the property changed callback of the IsExpanded property. Let’s take a look at how IsExpanded is defined:

public bool IsExpanded
{
get { return (bool)GetValue(IsExpandedProperty); }
set { SetValue(IsExpandedProperty, value); }
}

// Using a DependencyProperty as the backing store for IsExpanded. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsExpandedProperty =
DependencyProperty.Register("IsExpanded", typeof(bool), typeof(CollapsiblePanel), new PropertyMetadata(new PropertyChangedCallback(IsExpandedChanged)));

private static void IsExpandedChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
bool expanded = (bool)e.NewValue;
CollapsiblePanel panel = o as CollapsiblePanel;

if (expanded)
{
VisualStateManager.GoToState((o as Control), Expand, true);
if (panel != null && panel._RollDownStoryboardName != null)
{
Storyboard sb = (o as FrameworkElement).Resources[panel._RollDownStoryboardName] as Storyboard;
if (sb != null)
{
sb.Begin();
}
}
}
else
{
VisualStateManager.GoToState((o as Control), Collapse, true);
if (panel != null && panel._RollUpStoryboardName != null)
{
Storyboard sb = (o as FrameworkElement).Resources[panel._RollUpStoryboardName] as Storyboard;
if (sb != null)
{
sb.Begin();
}
}
}
}

Well, I did say “real work” but our new friend the VisualStateManager makes going from one state to the other a snap! As you can see, the IsExpanded property is a boilerplate dependancy property. If the new value is true, the property changed callback simply calls the VisualStateManager to go to the Expand state and runs the RollDown storyboard defined by _content_SizeChanged earlier. If the new value is false, the property changed callback does the opposite. Easy! :)

We will also be needing another dependancy property to show a title in the panel’s title bar. Here’s the code for the Title property:

public string Title
{
get { return (string)GetValue(TitleProperty); }
set { SetValue(TitleProperty, value); }
}

// Using a DependencyProperty as the backing store for Title. This enables animation, styling, binding, etc...
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register("Title", typeof(string), typeof(CollapsiblePanel), null);

The last (and some might say, most significant) bit is setting up a default template for our CollapsiblePanel control. Here’s what the XAML looks like:

<ResourceDictionary
xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Knowledgecast.Controls"
xmlns:vsm="clr-namespace:System.Windows;assembly=System.Windows"
>

<vsm:Style TargetType="local:CollapsiblePanel">
<vsm:Setter Property="Template">
<vsm:Setter.Value>
<ControlTemplate TargetType="local:CollapsiblePanel">
<Grid>
<vsm:VisualStateManager.VisualStateGroups>
<vsm:VisualStateGroup x:Name="CommonStates">
<vsm:VisualState x:Name="Collapse">
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="ArrowAngleTransform" Storyboard.TargetProperty="Angle" BeginTime="00:00:00">
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="0">
<SplineDoubleKeyFrame.KeySpline>
<KeySpline ControlPoint1="0,1" ControlPoint2="1,1"/>
</SplineDoubleKeyFrame.KeySpline>
</SplineDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</vsm:VisualState>
<vsm:VisualState x:Name="Expand">
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="ArrowAngleTransform" Storyboard.TargetProperty="Angle" BeginTime="00:00:00">
<SplineDoubleKeyFrame KeyTime="00:00:01" Value="90">
<SplineDoubleKeyFrame.KeySpline>
<KeySpline ControlPoint1="0,1" ControlPoint2="1,1"/>
</SplineDoubleKeyFrame.KeySpline>
</SplineDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</vsm:VisualState>
</vsm:VisualStateGroup>
</vsm:VisualStateManager.VisualStateGroups>
<Grid.RowDefinitions>
<RowDefinition Height="20" />
<RowDefinition />
</Grid.RowDefinitions>
<Grid x:Name="ExpandCollapseButton" Height="20" Grid.Row="0">
<Path x:Name="TitleBack" Opacity="0.8" HorizontalAlignment="Stretch" Margin="0,0,0,0" VerticalAlignment="Stretch" Stretch="Fill" StrokeThickness="0.5" Data="M12.5,7 C47.333332,7 115.85664,7 117,7 C118.14336,7 122.1255,6.7291665 122.25,12 C122.3745,17.270834 122.25,18.333334 122.25,21.5 L12.5,21.5 z">
<Path.Fill>
<RadialGradientBrush GradientOrigin="0.699000000953674,0.792999982833862">
<RadialGradientBrush.RelativeTransform>
<TransformGroup>
<ScaleTransform CenterX="0.5" CenterY="0.5" ScaleX="1.4" ScaleY="2.188"/>
<SkewTransform CenterX="0.5" CenterY="0.5"/>
<RotateTransform CenterX="0.5" CenterY="0.5"/>
<TranslateTransform X="0.017" Y="0.009"/>
</TransformGroup>
</RadialGradientBrush.RelativeTransform>
<GradientStop Color="#FF00008B" Offset="1"/>
<GradientStop Color="#FFADD8E6" Offset="0"/>
</RadialGradientBrush>
</Path.Fill>
</Path>
<TextBlock Cursor="Arrow" HorizontalAlignment="Stretch" Margin="27.75,2.75,-5,1.75" VerticalAlignment="Stretch" FontFamily="Verdana" FontSize="11" FontStyle='Normal' FontWeight='Normal' Foreground='#FFFFFFFF' Text='{TemplateBinding Title}' Opacity='1' x:Name='Title'/>
<Path x:Name="Arrow" HorizontalAlignment="Left" Margin="5.85300016403198,2.81200003623962,0,3.29800009727478" VerticalAlignment="Stretch" Width="10.685" Fill="#FFFFFFFF" Stretch="Fill" Stroke="#FF000000" StrokeThickness="0" Data="M182.75038,211.50015 L216.5,234.50017 L182.81238,257.87216 z" RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<TransformGroup>
<RotateTransform x:Name="ArrowAngleTransform" Angle="90"/>
</TransformGroup>
</Path.RenderTransform>
</Path>
</Grid>
<Grid x:Name="ContentContainer" Grid.Row="1">
<Border x:Name="PanelContent" BorderThickness="0">
<Grid>
<Rectangle Opacity="0.6" Stroke="Gray" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
<Rectangle Opacity="0.6" Stroke="Black" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Rectangle.RenderTransform>
<TranslateTransform X="-1" Y="-1" />
</Rectangle.RenderTransform>
</Rectangle>
<ContentPresenter />
</Grid>
</Border>
</Grid>
</Grid>
</ControlTemplate>
</vsm:Setter.Value>
</vsm:Setter>
</vsm:Style>
</ResourceDictionary>

Pay close attention to where each of the template parts and visual states appear. Let’s take this from the top. The expand and contract visual states define storyboards that manipulate the angle of a rotate transform. If you scroll down and look at a Path element called Arrow defined in the ExpandCollapseButton you will see that this angle transform belongs to that path. The Path is simply an arrow that is rendered inside the expand/collapse title bar.

The whole layout is defined inside a Grid container. The ExpandCollapseButton is in fact a Grid which sits in the first row of the container grid. The ContentContainer (below the ExpandCollapseButton) is another Grid that sits in the second row. At runtime, we will be applying a clipping rectangle to the ContentContainer grid. The PanelContent is a Border container. Aside from a couple of rectangles for visual effect, it also contains a ContentPresenter control. The ContentPresenter is the heart of a ContentControl. This is where all the content that the user provides for this control goes.

To set this up as the default template for our control we are going to put the XAML in a file called generic.xaml that is part of the project that contains the control class. We will also set the Build Action on this file to "Page" and Custom Tool to "MSBuild:MarkupCompilePass1". This should tell Silverlight to render the control using this template when none is specified by the consumer.

To close, lets take a look at a very simple example of the CollapsiblePanel’s use:

<UserControl x:Class="Knowledgecast.DemoApplication.Page"
xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
xmlns:knowledgecast="clr-namespace:Knowledgecast.Controls;assembly=Knowledgecast.Controls"
Width="220" Height="200">
<Grid x:Name="LayoutRoot" Background="White">
<knowledgecast:CollapsiblePanel Margin="10,10,10,10" Title="Collapse/Expand" FontSize="10">
<Grid>
<StackPanel Margin="4,2,2,2" Orientation="Vertical">
<TextBlock Text="This is a content control" />
<TextBlock Text="It can contain anything" />
<TextBox Width="100" Margin="0,10,0,10" Text="a textbox" />
<RadioButton Width="100" Content="a radio button" />
<TextBlock Text="Or that graphic in the background" />
<TextBlock Margin="0,20,0,0" Text="Click the title bar to expand/collapse" />
</StackPanel>
</Grid>
</knowledgecast:CollapsiblePanel>
</Grid>
</UserControl>

All the code for this post along with examples is available here:

The next post in this series which explains the Visual Transitions used in the collapsible panel is here.

CollapsiblePanel.zip