Introduction
This article presents an extension to the WPF WrapPanel
control that infers aesthetically pleasing ItemWidth
and ItemHeight
properties. This inference is important in data-binding applications, when you may not know a good value for ItemWidth
or ItemHeight
to choose at design time.
Background
The System.Windows.Controls.WrapPanel
control that comes with WPF positions its child elements sequentially from left to right (or top to bottom), breaking content to the next line at the edge of the containing box. Subsequent ordering happens sequentially from left to right (or top to bottom). The following is the output of using a WrapPanel
to display a set of buttons:
The code used to generate this is straightforward:
<WrapPanel>
<Button Margin="5,5,5,5">Alpha</Button>
<Button Margin="5,5,5,5">Bravo</Button>
-->
<Button Margin="5,5,5,5">Zebra</Button>
</WrapPanel>
The output looks ugly, since each button has a different size. To make the button size uniform, we can set the ItemWidth
property:
<WrapPanel ItemWidth="69">
<Button Margin="5,5,5,5">Alpha</Button>
<Button Margin="5,5,5,5">Bravo</Button>
-->
<Button Margin="5,5,5,5">Zebra</Button>
</WrapPanel>
Now, we get a better looking output:
Picking the right value for ItemWidth
requires trial and error. The objective is to make the ItemWidth
just big enough to fully display the child control with the largest desired width (the Yesterday Button
in this case) without making it any bigger. In this example, if the size is too big, the buttons will look silly. If the size is too small, the text of one or more of the buttons will get cut off.
This problem gets harder when we introduce data binding. When we bind the children of the WrapPanel
to a set of data with a corresponding set of data-bound controls, we may not know the maximum child control desired width at design time. To demonstrate this, I first factor the same set of names into the new class TextToDisplay
, which I will use to data bind the set of button names:
public class TextToDisplay
{
public TextToDisplay()
{
Text = new ObservableCollection<string>();
Text.Add("Alpha");
Text.Add("Bravo");
Text.Add("Zebra");
}
public ObservableCollection<string> Text
{
get;
private set;
}
}
This new view code uses data biding to display the Text
elements of the TextToDisplay
class:
<Window
x:Class="UniformWrapPanelExample.WrapPanelWithDataBindingWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
xmlns:local="clr-namespace:UniformWrapPanelExample"
Title="WrapPanelWithDataBindingWindow" Height="300" Width="300">
<Window.Resources>
<local:TextToDisplay x:Key="TextContainer"/>
</Window.Resources>
<ItemsControl
ItemsSource="{Binding Text,
Source={StaticResource TextContainer}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel ItemWidth="69"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type sys:String}">
<Button
Margin="5,5,5,5"
Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Window>
This new implementation allows someone to change the button collection by simply adding or removing strings from the Text
property of the TextToDisplay
object. The view code handles translating this string into a button automatically.
With data binding, we can no longer reliably use trial and error to guess the right ItemWidth
value. Our assumptions about the text width of the buttons may be invalidated at run time. For example, if you guess that 50 is a good value for ItemWidth
, you would end up with some words being cut off in the example set:
To solve the guess work involved in choosing the right ItemWidth
(or ItemHeight
) value, I created the UniformWrapPanel
. The UnfiormWrapPanel
is just a WrapPanel
, with a special property IsAutoUniform
. When this property is true
, the ItemWidth
(or ItemHeight
) will be automatically set to the largest desired width of the children before layout, causing all the items to receive an aesthetically pleasing uniform layout without any guesswork. The IsAutoUniformProperty
is defined below:
public class UniformWrapPanel : WrapPanel
{
public bool IsAutoUniform
{
get { return (bool)GetValue(IsAutoUniformProperty); }
set { SetValue(IsAutoUniformChanged, value); }
}
public static readonly DependencyProperty
IsAutoUniformProperty = DependencyProperty.Register(
"IsAutoUniform", typeof(bool), typeof(UniformWrapPanel),
new FrameworkPropertyMetadata(true,
new PropertyChangedCallback(IsAutoUniformChanged)));
}
}
This property is true
by default; setting it to false
will make this panel behave like the standard WrapPanel
. Because changing this value may affect layout, the PropertyChangedCallback
IsAutoUniformChanged
will cause the panel to recompute the layout of its elements whenever the IsAutoUniform
property gets changed:
private static void IsAutoUniformChanged(DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
if (sender is UniformWrapPanel)
{
((UniformWrapPanel)sender).InvalidateVisual();
}
}
To produce the correct behavior, the UniformWrapPanel
computes an appropriate ItemWidth
(or ItemHeight
) property, and then defers to the WrapPanel
base class to complete the layout:
protected override Size MeasureOverride(Size availableSize)
{
if (Children.Count > 0 && IsAutoUniform)
{
if (Orientation == Orientation.Horizontal)
{
double totalWidth = availableSize.Width;
ItemWidth = 0.0;
foreach (UIElement el in Children)
{
el.Measure(availableSize);
Size next = el.DesiredSize;
if (!(Double.IsInfinity(next.Width) ||
Double.IsNaN(next.Width)))
{
ItemWidth = Math.Max(next.Width, ItemWidth);
}
}
}
else
{
double totalHeight = availableSize.Height;
ItemHeight = 0.0;
foreach (UIElement el in Children)
{
el.Measure(availableSize);
Size next = el.DesiredSize;
if (!(Double.IsInfinity(next.Height) ||
Double.IsNaN(next.Height)))
{
ItemHeight = Math.Max(next.Height, ItemHeight);
}
}
}
}
return base.MeasureOverride(availableSize);
}
Using the Code
To use this new panel, replace the ItemsPanel
in the above data binding example with:
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<local:UniformWrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
which produces the following output:
Points of Interest
When I originally made this panel, I made it as an extension to Panel
, rather than WrapPanel
. In the original version, there was no ItemWidth
or ItemHeight
property, since these were inferred automatically. This seemed limiting, since it couldn't always be used in place of WrapPanel
, so I switched to the above model. Sure enough, the code actually got simpler when I did this :)
History
Any future changes or improvements I make will later be posted here.