The blog of dlaa.me

This one time, at band camp... [A banded StackPanel implementation for Silverlight and WPF!]

Recently, I came across this article in the Expression Newsletter showing how to create a three-column ListBox. In it, Victor Gaudioso shows a Blend-only technique for creating a Silverlight/WPF ListBox with three columns. [And I award him extra credit for using the Silverlight Toolkit to do so! :) ] The technique described in that article is a great way to custom the interface layout without writing any code.

But it can get a little harder when the elements in the ListBox aren't all the same size: a row with larger elements may only be able to fit two items across before wrapping, while a row with smaller elements may fit four items or more! Such things can typically be avoided by specifying the exact size of all the items and the container itself (otherwise the user might resize the window and ruin the layout). But while hard-coding an application's UI size can be convenient at times, it's not always an option.

 

For those times when it's possible to write some code, specialized layout requirements can usually be implemented more reliably and more accurately with a custom Panel subclass. Victor started by looking for a property he could use to set the number of columns for the ListBox, and there's nothing like that by default. So I've written some a custom layout container to provide it - and because I've created a new Panel, this banded layout can be applied anywhere in an application - not just inside a ListBox!

The class I created is called BandedStackPanel and it behaves like a normal StackPanel, except that it includes a Bands property to specify how many vertical/horizontal bands to create. When Bands is set to its default value of 1, BandedStackPanel looks the same as StackPanel - when the value is greater than 1, additional bands show up automatically! And because BandedStackPanel is an integral part of the layout process, it's able to ensure that there are always exactly as many bands as there should be - regardless of how big or small the elements are and regardless of how the container is resized.

 

Vertical BandedStackPanel on Silverlight

 

[Click here to download the source code for BandedStackPanel and the Silverlight/WPF sample application in a Visual Studio 2010 solution]

 

The sample application shown here has a test panel on the left to play around with and a ListBox at the right to demonstrate how BandedStackPanel can be used to satisfy the original requirements of the article. The ComboBoxes at the top of the window allow you to customize the behavior of both BandedStackPanel instances. And, of course, BandedStackPanel works great on WPF as well as on Silverlight. :)

 

Horizontal BandedStackPanel on WPF

 

The way to use BandedStackPanel with a ListBox is the same as any other ItemsControl layout customization: via the ItemsControl.ItemsPanel property:

<ListBox>
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <delay:BandedStackPanel Bands="3"/>
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBoxItem Content="ListBoxItem 0"/>
    <ListBoxItem Content="ListBoxItem 1"/>
    <ListBoxItem Content="ListBoxItem 2"/>
    ...
</ListBox>

 

That's pretty much all there is to it! The Silverlight/WPF layout system is very powerful and can be made to do all kinds of weird and wonderful things without writing a single line of code. But for those times when your needs are very specific, nothing beats a custom Panel for really nailing the scenario!

 

PS - The implementation of BandedStackPanel is fairly straightforward. The only real complexity is abstracting out the handling of both the horizontal and vertical orientations so the same code can be used for both. I followed much the same approach the Silverlight Toolkit's WrapPanel uses and introduced a dedicated OrientedLength class that allows the code to deal in terms of primary/secondary growth directions instead of specifically manipulating width and height. Once the abstraction of OrientedLength is in place, the implementations of MeasureOverride and ArrangeOverride are pretty much what you'd expect. Here they are for those who might be interested:

/// <summary>
/// Implements custom measure logic.
/// </summary>
/// <param name="constraint">Constraint to measure within.</param>
/// <returns>Desired size.</returns>
protected override Size MeasureOverride(Size constraint)
{
    var bands = Bands;
    var orientation = Orientation;
    // Calculate the Size to Measure children with
    var constrainedLength = new OrientedLength(orientation, constraint);
    constrainedLength.PrimaryLength = double.PositiveInfinity;
    constrainedLength.SecondaryLength /= bands;
    var availableLength = constrainedLength.Size;
    // Measure each child
    var band = 0;
    var levelLength = new OrientedLength(orientation);
    var usedLength = new OrientedLength(orientation);
    foreach (UIElement child in Children)
    {
        child.Measure(availableLength);
        // Update for the band/level of this child
        var desiredLength = new OrientedLength(orientation, child.DesiredSize);
        levelLength.PrimaryLength = Math.Max(levelLength.PrimaryLength, desiredLength.PrimaryLength);
        levelLength.SecondaryLength += desiredLength.SecondaryLength;
        // Move to the next band
        band = (band + 1) % bands;
        if (0 == band)
        {
            // Update for the complete level; reset for the next one
            usedLength.PrimaryLength += levelLength.PrimaryLength;
            usedLength.SecondaryLength = Math.Max(usedLength.SecondaryLength, levelLength.SecondaryLength);
            levelLength.PrimaryLength = 0;
            levelLength.SecondaryLength = 0;
        }
    }
    // Update for the partial level at the end
    usedLength.PrimaryLength += levelLength.PrimaryLength;
    usedLength.SecondaryLength = Math.Max(usedLength.SecondaryLength, levelLength.SecondaryLength);
    // Return the used size
    return usedLength.Size;
}

/// <summary>
/// Implements custom arrange logic.
/// </summary>
/// <param name="arrangeSize">Size to arrange to.</param>
/// <returns>Used size.</returns>
protected override Size ArrangeOverride(Size arrangeSize)
{
    var bands = Bands;
    var orientation = Orientation;
    var count = Children.Count;
    // Prepare the Rect to arrange children with
    var arrangeLength = new OrientedLength(orientation, arrangeSize);
    arrangeLength.SecondaryLength /= bands;
    // Arrange each child
    for (var i = 0; i < count; i += bands)
    {
        // Determine the length of the current level
        arrangeLength.PrimaryLength = 0;
        arrangeLength.SecondaryOffset = 0;
        for (var band = 0; (band < bands) && (i + band < count); band++)
        {
            var desiredLength = new OrientedLength(orientation, Children[i + band].DesiredSize);
            arrangeLength.PrimaryLength = Math.Max(arrangeLength.PrimaryLength, desiredLength.PrimaryLength);
        }
        // Arrange each band within the level
        for (var band = 0; (band < bands) && (i + band < count); band++)
        {
            Children[i + band].Arrange(arrangeLength.Rect);
            arrangeLength.SecondaryOffset += arrangeLength.SecondaryLength;
        }
        // Update for the next level
        arrangeLength.PrimaryOffset += arrangeLength.PrimaryLength;
    }
    // Return the arranged size
    return arrangeSize;
}