Friday, 17 February 2012

An Accordion Panel in WPF

I recently had need of an Accordian control, such as that found in JQuery UI.

I decided to implement this as a simple Panel. This AccordianPanel can contain any controls, but has special behaviour for child Expander controls: It will only allow a single one to be expanded at any one time.

public AccordianPanel()
{
    AddHandler(Expander.ExpandedEvent, new RoutedEventHandler(ChildExpanded));
}
Starting with the constructor, we register a new event handler for the Expander.ExpandedEvent:
void ChildExpanded(object sender, RoutedEventArgs e)
{
    foreach (UIElement child in InternalChildren)
    {
        Expander expander = FindExpander(child);

        if (expander != null && expander != e.OriginalSource)
        {
            expander.IsExpanded = false;
        }
    }
}
When we detect a child Expander's Expanded state has changed, we look for any other child Expanders and unexpand them. The FindExpander function allows for the Expander to be parented:
Expander FindExpander(UIElement e)
{
    while (e != null && !(e is Expander))
    {
        if (VisualTreeHelper.GetChildrenCount(e) == 1)
        {
            e = VisualTreeHelper.GetChild(e, 0) as UIElement;
        }
        else
        {
            e = null;
        }
    }

    return (Expander)e;
}
Finally, we implement MeasureOverride:
protected override Size MeasureOverride(Size availableSize)
{
    double requiredHeight = 0;
    double resizableHeight = 0;

    foreach (UIElement child in InternalChildren)
    {
        child.Measure(availableSize);
        requiredHeight += child.DesiredSize.Height;

        if (CanResize(child))
        {
            resizableHeight += child.DesiredSize.Height;
        }
    }

    if (requiredHeight > availableSize.Height)
    {
        double pixelsToLose = requiredHeight - availableSize.Height;

        foreach (UIElement child in InternalChildren)
        {
            double height = child.DesiredSize.Height;

            if (CanResize(child))
            {
                height -= (child.DesiredSize.Height / resizableHeight) * pixelsToLose;
                child.Measure(new Size(availableSize.Width, height));
            }
        }
    }

    return base.MeasureOverride(availableSize);
}
And ArrangeOverride:
protected override Size ArrangeOverride(Size finalSize)
{
    double totalHeight = 0;
    double resizableHeight = 0;

    foreach (UIElement child in Children)
    {
        totalHeight += child.DesiredSize.Height;

        if (CanResize(child))
        {
            resizableHeight += child.DesiredSize.Height;
        }
    }

    double pixelsToLose = totalHeight - finalSize.Height;
    double y = 0;

    foreach (UIElement child in InternalChildren)
    {
        double height = child.DesiredSize.Height;

        if (pixelsToLose > 0 && CanResize(child))
        {
            height -= (child.DesiredSize.Height / resizableHeight) * pixelsToLose;
        }

        child.Arrange(new Rect(0, y, finalSize.Width, height));
        y += height;
    }

    return base.ArrangeOverride(finalSize);
}
Plus the small Utility function CanResize:
bool CanResize(UIElement e)
{
    Expander expander = FindExpander(e);
    return expander != null && expander.IsExpanded;
}

And that's it! I initially had problems with the fact that the layout for this type of panel requires two passes, which doesn't immediately seem to be supported by WPF. However, following a question on the ever-reliable StackOverflow, it seems it can in fact be done.

You can even make it animated using something like the AnimatedPanel from here.

Download the code here.

1 comment: