Thursday, November 12, 2009

Adventures in Making a Custom Accordion Control

As I mentioned in my previous post, I have been trying to create an Accordion control.  The one I had grabbed off the web worked pretty well, but I couldn't resist the urge to tweak it.  After more than a day of head-scratching, googling, pondering and revelation I now have a much better handle on custom controls, and a rudimentary Accordion control to show for my efforts.  I record here for posterity the steps I took along the way.

Creating a Custom Control from Scratch

WPF has numerous ways to create a custom control.  The first one I came across was called a User Control.  This control actually has it's own XAML tag.  Sounds easy (because it is) but it comes with limitations.  To get the full access to events, custom properties, etc. I needed a Custom Control. A good short summary of the differences between User and Custom can be found here.  I also found the Custom Control "tutorial" from Christian Moser to be extremely enlightening (but more of an overview than a tutorial).

The Accordion control that I started with was initially declared with a base class of StackPanel.  This gave it the functionality it needed to stack the Expander elements. Now I wanted to add the ability for the control to dynamically re-size if/when the user re-sized the containing element.  The StackPanel doesn't really seem support this functionality so at first I thought that I would just snag a reference to the parent container in the OnInitialized event handler and add a handler delegate to its SizeChanged Event:

    protected override void OnInitialized(EventArgs e)
    {
      base.OnInitialized(e);
      _parentContainer = this.Parent as FrameworkElement;
      if (_parentContainer != null)
        _parentContainer.SizeChanged += new SizeChangedEventHandler(this.OnSizeChanged);
       ...


This worked okay, but I was having trouble getting the control to size itself correctly on the initial display.  In addition, there were parent containers that could cause potential problems and I didn't have a way to guarantee that the parent of my Accordion would behave as I expected.  What if I could place my StackPanel inside a Grid element?  How could I do this?  That is where the Generic.xaml file comes in!


Expression Blend to the Rescue

In my previous post I mentioned the ability of Microsoft's Expression Blend to dump out the Style Template of a control so you could examine/change it.  This proved extremely useful in this case because I didn't know anything about ControlTemplates and dumping a few of these out and reading through them was a great help to me in getting a handle on them.  As a starter for my control's template I dumped out the template for the GroupBox control (I tried to do the StackPanel and the Grid, but apparently they don't have Templates since they are only containers and have no visual elements of their own(?)).  This was very instructive and also gave me some better ideas about XAML layout (REVELATION: I had no idea you could set Width/Height to "*" for "fill remaining" or ".3*" for "30% of remaining").

Anyway I copied this GroupBox style XAML over to my Generic.xaml and changed it accordingly.  I put a Grid element around a StackPanel around the ContentPresenter tag since I was pretty sure that the ContentPresenter was supposed to hold my Content (duh!).  When I tried to build, I got several errors about my setter trying to set non-existent dependency properties in my Accordion (like BorderBrush and BorderThickness). Darn.  I went about several rounds of adding and deleting dependency properties from my Accordion class before I realized that what I really needed to do was reconsider the base class I was using (it was still set to StackPanel).  This would have been a good time for me to re-read Mr. Moser's overview (especially section 3), but why read when you can beat your head against the wall? Eventually I settled on ContentControl which had every property my ControlTemplate needed right out of the box.  Oops, now it complained about trying to add multiple items to the ContentPresenter.  What?

ContentPresenter vs. ItemsPresenter

Okay, so in Blend I had grabbed the GroupBox template which inherits from the HeaderedContentControl which is based on the ContentControl.  ContentControl is designed to have a single (one, uno, singular) piece of content, not multiple pieces like I wanted in my Accordion.  Turns out that what I needed was a ItemsControl!  The ItemsControl works with the XAML ItemsPresenter tag.  I changed my Accordion's base class to ItemsControl and replaced the ContentPresenter tag with ItemsPresenter (which apparently doesn't need a binding statement like ContentPresenter does).  I also had to change references to the StackPanel's Children collection to the ItemsControl's Items collection.  Now I was in business, right?

Accessing the Template's Elements

I had given my Grid element in my ControlTemplate the Name of  "Container" and now all I had to do was use the following code to grab a reference to it:

_containerGrid = Template.FindName("Container", this) as Grid

At first I put this statement in the OnInitialize event handler.  It returned null.  Breaking the code there appeared to be no Template at all!  I went in to Generic.xaml and tweaked some stuff and my changes were reflected in the resulting control, so I knew that it was getting set eventually but where could I grab it?  The answer was the OnApplyTemplate event handler (sure, it sounds easy now).  This gave me a reference to the containing Grid, but unfortunately it was not laid out yet as it returned and ActualHeight property equal to 0.  That was no good.  The solution (for me) was to use the Grid's Loaded event to set the initial height of my control:

    public override void  OnApplyTemplate()
    {
      base.OnApplyTemplate();
      _containerGrid = Template.FindName("Container", this) as Grid;
      if (_containerGrid != null)
      {
        _containerGrid.SizeChanged += new SizeChangedEventHandler(this.OnSizeChanged);
        _containerGrid.Loaded += new RoutedEventHandler(this.SetInitialHeights);
      }
    }

That did it! Now my control was sizing and re-sizing appropriately.

Template.FindName Gotcha

One other thing I discovered in doing my control was that the Template.FindName() is specific to the Template.  Sure that makes sense, but I discovered that some ControlTemplates contain other Templates.  What this means is that if your Template contains a named item that also contains a Template with a named item you will have to call FindName() twice.  I discovered this when trying to get a reference to the Path named "arrow" in  the Expander's ControlTemplate.  Check out this excerpt from the Expander's ControlTemplate (extracted using Expression Blend).  I have highlighted the ControlTemplate and Named elements:

  <ControlTemplate TargetType="{x:Type Expander}">
    <Border ... >
      <DockPanel>
        <ToggleButton x:Name="HeaderSite" ... >
          <ToggleButton.Style>
            <Style TargetType="{x:Type ToggleButton}">
                <Setter Property="Template">
                <Setter.Value>
                  <ControlTemplate TargetType="{x:Type ToggleButton}">
                    <Border Padding="{TemplateBinding Padding}">
                      <Grid SnapsToDevicePixels="False" Background="Transparent">
                        <Grid.ColumnDefinitions>
                          <ColumnDefinition Width="19"/>
                          <ColumnDefinition Width="*"/>
                        </Grid.ColumnDefinitions>
                        <Microsoft_Windows_Themes:ClassicBorderDecorator x:Name="Bd" ... >
                          <Microsoft_Windows_Themes:ClassicBorderDecorator.BorderBrush>
                            <SolidColorBrush/>
                          </Microsoft_Windows_Themes:ClassicBorderDecorator.BorderBrush>
                          <Path x:Name="arrow" ... Data="M1,1.5L4.5,5 8,1.5"/>

In order to get at the "arrow" Path object you will need to first FindName() the "HeaderSite" ToggleButton and then call FindName() again on the ToggleButton's Template.  The following C# code show how I did it:

      Path arrow;
      ToggleButton toggle = expander.Template.FindName("HeaderSite", expander) as ToggleButton;
      if(toggle != null)
        arrow = toggle.Template.FindName("arrow", toggle) as Path;

Thus I obtained access to the Path for the arrows so I could make sure they were pointing appropriately. If anyone has any better more efficient ways to do any of this, I welcome your advice/wisdom!

No comments:

Post a Comment