Tutorial: Writing a Templated Silverlight 2 Control

摘要:Tutorial: Writing a Templated Silverlight 2 Control

Tutorial: Writing a Templated Silverlight 2 Control

[UPDATE: For a Beta 2 version of this control, click here

We're going to create this control:

image

It's called the ExpandoHeaderControl, and it does two basic things.  First, it has an area for a header, and a collapsible content area, with a ToggleButton to manage when the content is visible or not (e.g. collapsed).

For this tutorial, I'm trying to simplify concepts as much as possible. For more sophisticated, industrial-strength concepts, check out the full control source available for the Source Code for Silverlight 2 Beta 1 Controls.

I'm continually amazed by how much power Silveright 2 provides.  This control will require about 100 lines of code to write.  Really!  Let's get started.

Step 1: Defining your control's template parts. 

What are the parts of your control that the logic has to interact with?  These should be the the defined "parts" of your control.  There are two types of objects that your control cares about.  First, it's the elements that are going to drive the UI or layout.  And, second, are the transition Storyboard animations that will cause your control's UI to move from one state to the next.  Let's focus on the UI parts right now.

We want to make sure that we give designers the most flexibility possible.  So our code should be as general as possible.  If you look at the code in the Silverlight 2 Beta 1 Controls package, you'll see great examples of this.  The code running the control is very agnostic about what the UI is actually doing.

So for our control, we'll define a few parts:

  1. The RootElement, which is the container for all of the UI.  This is important because it's also where we'll stick the resources that have our Transitions.
  2. The HeaderElement, which is the element that contains the header content.
  3. The ContentElement, which is the element that will parent the main content in the collapsible area.
  4. The ButtonElement, which is the ToggleButton that will control the collapsed state.

Usually, when we start writing our control, the first step is to add these attributes to our control class:


   [TemplatePart(Name=ExpandoHeaderControl.RootElement, Type=typeof(FrameworkElement))]
   [TemplatePart(Name=ExpandoHeaderControl.HeaderElement, Type=typeof(ContentControl))]
   [TemplatePart(Name=ExpandoHeaderControl.ContentElement, Type=typeof(ContentControl))]
   [TemplatePart(Name=ExpandoHeaderControl.ButtonElement, Type=typeof(ToggleButton))]    
   public class ExpandoHeaderControl : ContentControl
   {        
       private const string RootElement = "RootElement";
       private const string HeaderElement = "HeaderElement";
       private const string ContentElement = "ContentElement";
       private const string ButtonElement = "ButtonElement";  

Note that we do two small, but important, things here.  First, we use static strings for the element names.  This is good practice because we'll use them again later.  Second, we specify what type of element the Template Part needs to be.  This should be the most general type that you need for a given element.  If you use specific types here (for example Grid instead of FrameworkElement for the root), it will be much more difficult to template your control.  In the case of the ButtonElement, we chose ToggleButton rather than Button so we can ensure that it has a specific state.  If we used just Button (or ButtonBase), we'd need to track this ourselves. 

Step 2: Define your control's transition animations

What are the animations that your control will need to transition from one state to the next?  Defining transitions is just the same as defining the parts - use the TemplatePart attribute.  The main difference here is that the type will always be Storyboard.

Let's add in the animation declarations:


[TemplatePart(Name=ExpandoHeaderControl.RootElement, Type=typeof(FrameworkElement))]
    [TemplatePart(Name=ExpandoHeaderControl.HeaderElement, Type=typeof(ContentControl))]
    [TemplatePart(Name=ExpandoHeaderControl.ContentElement, Type=typeof(ContentControl))]
    [TemplatePart(Name=ExpandoHeaderControl.ButtonElement, Type=typeof(ToggleButton))]    
    [TemplatePart(Name = ExpandoHeaderControl.OpenAnimation, Type = typeof(Storyboard))]
    [TemplatePart(Name = ExpandoHeaderControl.CloseAnimation, Type = typeof(Storyboard))]
    public class ExpandoHeaderControl : ContentControl
    {        
        private const string RootElement = "RootElement";
        private const string HeaderElement = "HeaderElement";
        private const string ContentElement = "ContentElement";
        private const string ButtonElement = "ButtonElement";        
        private const string OpenAnimation = "OpenAnimation";
        private const string CloseAnimation = "CloseAnimation";

Step 3:  Write your OnApplyTemplate logic

After a control is loaded, its UI is populated from it's template.  To do this, the override of OnApplyTemplate is called by the framework.   For the most part, this code always looks about the same. 

For each of your parts, you're likely to want to have a member variable to access the element later, if you'll need to manipulate it:

        private FrameworkElement _rootElement;
        private ToggleButton _buttonElement;
        private Storyboard _openAnimation, _closeAnimation;
        
        protected override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            // Fish out all of the template children and wire them up.
            //
            _rootElement = GetTemplateChild(ExpandoHeaderControl.RootElement) as FrameworkElement;

            if (_rootElement != null)
            {
                     
                _buttonElement = GetTemplateChild(ExpandoHeaderControl.ButtonElement) as ToggleButton;
                _openAnimation = _rootElement.Resources[ExpandoHeaderControl.OpenAnimation] as Storyboard;
                _closeAnimation = _rootElement.Resources[ExpandoHeaderControl.CloseAnimation] as Storyboard;
            }
        }

One thing to note is that we access parts and transitions differently.  Note how we access the transitions out of the root element's Resources collection. 

Step 4: Create any properties you'll want to bind to in your template

Silverlight and WPF have a feature called Template Binding.  Template Binding allows you to "pass through" settings from a control to the UI defined in the Template.  For example, imagine your control has a TextBlock in it.  You'd like to be able to set the font properties of that TextBlock in a way that doesn't force the user to understand how the template is put together.  Template Binding allows you to reference properties on the object being templated and set those values into the template, similar to data binding.

Our control has two content areas:  the header, and the content itself.   Since our control derives from ContentControl, it already has a property called "Content" that we can use for the main content area.  But we want another one called HeaderContent to specify that, so we'll create one.  For a property to be bindable, it has to be defined as a DependancyProperty.  Visual Studio 2008 has a built in snippet for defining these.  It's meant for WPF, but it's close enough.  Just type "propdp".

image

When you do this, fill in the various fields.  In this case, the property's type should be object, and it's called HeaderContent:

       // Content property for the header
       //
       public static readonly DependencyProperty HeaderContentProperty =
           DependencyProperty.Register("HeaderContent", typeof(object), typeof(ExpandoHeaderControl), null);

       public object HeaderContent
       {
           get { return (object)GetValue(HeaderContentProperty); }
           set { SetValue(HeaderContentProperty, value); }
       }

The snippet also creates the CLR property with accessors as well!

Step 5: Create a simple Template for Testing

Okay, now it's time to throw some UI in there.  We'll create a very simple template that does everything we need.

<Grid x:Name="RootElement">
        <Grid.Resources>
            <Storyboard x:Key="OpenAnimation">
                <DoubleAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="(UIElement.Opacity)">
                    <SplineDoubleKeyFrame KeyTime="00:00:00.1000000" Value="1.0"/>
                </DoubleAnimationUsingKeyFrames>
            </Storyboard>
            
            <Storyboard x:Key="CloseAnimation">
                <DoubleAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="(UIElement.Opacity)">
                    <SplineDoubleKeyFrame KeyTime="00:00:00.1000000" Value="0"/>
                </DoubleAnimationUsingKeyFrames>
            </Storyboard>
            
        </Grid.Resources>
    <Grid.RowDefinitions>                                
        <RowDefinition Height="50"/>
        <RowDefinition Height="auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="30" />
    </Grid.ColumnDefinitions>
        <ContentControl  HorizontalAlignment="Left" Background="Red" Content="{TemplateBinding HeaderContent}"/>
        <ToggleButton Grid.Column="1" HorizontalAlignment="Right" x:Name="ButtonElement" Content="X"/>
    
    <ContentControl Grid.ColumnSpan="2" Grid.Row="1"  Grid.Column="0" x:Name="ContentElement" 
                    Content="{TemplateBinding Content}" Background="Blue"/>
    
    

</Grid>

Okay, there's our template.  When instantiated, it looks like this:

image

Told ya it was simple.  Let's take a look at what parts we have in the template and how they match up with the control.

In the template, we define the animations at the top as resources off of the RootElement.

Notice we reference them by Key, not by Name.

For this control, we've defined a 2 row, 2 column Grid for our layout.  In the top row, we have the HeaderElement and the ButtonElement.  In the second row, we have the ContentElement, with a RowSpan of 2 so it fills up the bottom.  The red area and the words "header" and "Content Area" come from the control's usage, not the control itself or the template (see below).  In the Template above, you'll see two instances of "TemplateBinding" like "{TemplateBinding HeaderContent}".  When the control is templated, it will substitute the value of the controls "HeaderContent" property in that position. 

In our main XAML page, we've declared this control like this:

<my:ExpandoHeaderControl Width="200" x:Name="Header" Style="{StaticResource SimpleTemplate}" HeaderContent="header">
                        
       <my:ExpandoHeaderControl.Content>
          <Grid>
             <Rectangle Fill="Red" Stretch="Fill"/>
             <TextBlock Text="Content Area" HorizontalAlignment="Center" VerticalAlignment="Center"/>
         </Grid>
       </my:ExpandoHeaderControl.Content>

</my:ExpandoHeaderControl>
 

You may be wondering where to put that template.

In most cases, the Template will either be inline with the control's declaration (as it's Template property value, example in Step 8 below) in the XAML. This is good for one-off templates but doesn't let you share a template across controls. The more common way is as a resource defined in App.XAML (as we've done here). then it's hooked us as a StaticResource into the controls style property, as you see above.

Step 6: Managing State Changes

Now we've got the UI working, but the button doesn't know what to do when we push it.   Let's go back to our code and fix that.

The way this is usually done is to create properties to represent the state of your control.  In our case, the main piece of state is whether-or-not the control is collapsed.  So we'll add a property "IsOpened" to manage that.

// Property that determines if the expando is opened.
//
public static readonly DependencyProperty IsOpenedProperty =
    DependencyProperty.Register("IsOpened", typeof(bool), typeof(ExpandoHeaderControl), new PropertyChangedCallback(NotifyIsOpenedChanged));

private static void NotifyIsOpenedChanged(DependencyObject dpObj, DependencyPropertyChangedEventArgs change)
{
    bool isOpened = (bool)change.NewValue;

    ((ExpandoHeaderControl)dpObj).IsOpened = isOpened;
}

public bool IsOpened
{
    get
    {
        if (_buttonElement != null)
        {
            return _buttonElement.IsChecked ?? false;
        }
        else
        {
            return (bool)GetValue(IsOpenedProperty);
        }
    }
    set
    {
        if (_buttonElement != null)
        {
            _buttonElement.IsChecked = value;
        }
        else
        {
            SetValue(IsOpenedProperty, value);
        }
        ChangeVisualState();
    }
}

This looks a bit complicated.  We want to be able to bind to this property, so we've declared it as a DependancyProperty.   And we want to make sure it mirrors the actual state of the button, so if we have one of those, we'll just use its checked state.   But if we don't have one from the template, we'll just fall back to our own value.

One other note - we're hooking the changed value here.  If someone changes this value, we want to know about it so that we can update our state.  When the DP changes, our static handler will be called.  From that, we figure out which control fired the change, and update the state as appropriate.

Now that we've added the property, we can add some code in OnApplyTemplate to hook up to the Button's Click event:

bool opened = IsOpened;

_buttonElement = GetTemplateChild(ExpandoHeaderControl.ButtonElement) as ToggleButton;

if (_buttonElement != null)
{
    // setup the buttons initial state.
    //
    _buttonElement.IsChecked = opened;
    _buttonElement.Click += new RoutedEventHandler(ToggleButtonElement_Click);
    
}

Finally, we want a unified place to manage the state animations.  For that we've created a method called ChangeVisualState which plays the right animations for each state change.

This code may look funny, but to make sure we handle the case of an interrupted transition, we should start the "to" state StoryBoard before stopping the "From" one.

void ToggleButtonElement_Click(object sender, RoutedEventArgs e)
{
    // just update our state.
    //
    ChangeVisualState();
}
private void ChangeVisualState()
{
    // manage the animations base on the current state.
    //                        
    Storyboard toState = IsOpened ? _openAnimation : _closeAnimation;
    Storyboard fromState = !IsOpened ? _openAnimation : _closeAnimation;

    if (toState != null)
    {
        
        if (!_templateLoaded)
        {
            toState.SkipToFill();                    
        }
        else
        {
            toState.Begin();
        }
    }

    if (fromState != null)
    {
        fromState.Stop();
    }
}

We now have a working control!

Step 7: Specifying your default template

You'll notice that all of the SL 2 Beta1 controls have a nice default look and feel to them.  How is this accomplished?  Well, it takes a small amount of trickery.

The way you do this is by defining a XAML file called "generic.xaml" in your project and specifying it as a resource:

image

The Silveright Runtime will look for this resource and hook up templates for each specified type.  Your generic.xaml should look like this:

<ResourceDictionary 
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:HeaderControl;assembly=HeaderControl">

<Style TargetType="local:ExpandoHeaderControl">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local:ExpandoHeaderControl">
                    <!-- template xml -->
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

</ResourceDictionary>

Step 8: Making it look nice!

I've been slowly going through Blend Kindergarten over the last few weeks.  I finally managed to get the hang of making nice glassy looking UI and was able to create a template to match.  If you'd like to learn this stuff, I highly recommend the tutorials over at the Liquid Boy blog.  They're simple and quickly taught me enough to be dangerous to myself and others.

I spent quite a bit of time mocking up the UI you see at the top of this post, and I think it looks pretty good.  Notice that I also templated the ToggleButton (as a nested template inside of the ExpandoHeaderControl Template in generic.xaml.

<ToggleButton Margin="4" Grid.Column="1" x:Name="ButtonElement" IsChecked="true">
    <ToggleButton.Template>
        <ControlTemplate TargetType="ToggleButton">
            <!-- glassy round Toggle Button Template XAML -->

        </ControlTemplate>
    </ToggleButton.Template>                                
</ToggleButton>

After I got the hang of this, it became a lot of fun.  The glassy look on the toggle button, and the funny little arrow didn't take too long to put together really.  For someone who doesn't have an ounce of design sense, I'm quite proud of myself.

Finally, here's how the control is declared on the page:

<my:ExpandoHeaderControl x:Name="sample" HeaderContent="Choose Color">
            
        <my:ExpandoHeaderControl.Content>
             <ListBox x:Name="lb" Height="100">
                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <Grid Margin="2" Width="180">
                            <Rectangle Stretch="Fill" Fill="{Binding}"/>
                            <ContentPresenter Content="{Binding}"/>
                        </Grid>
                    </DataTemplate>                                
                </ListBox.ItemTemplate>
             </ListBox>                       
        </my:ExpandoHeaderControl.Content>

    </my:ExpandoHeaderControl>

Note the "{Binding}" syntax in the data template. Usually you specify a property in there like {Binding Name}, but in this case we're just using the item value itself, as in this case we're binding to an array of strings.

Again, here's the final, working product (if you're on IE/FF on Windows at least, not sure about Safari on Mac - shoehorning this into the blog has issues :))

 

Attached is the full project.  Enjoy!

 
Attachment(s): HeaderControl.zip