Introduction
Have you ever designed a screen where you have pairs of "Label
: TextBox
" in rows beneath each other?
My guess is yes, since this UI is pretty much standard in every configuration window ever designed. My guess is also that while designing this, you got frustrated
with having to define new rows all the time in the Grid
. Or that your StackPanel
s inside StackPanel
s didn't align the columns properly.
Wouldn't it be nice to combine the row/column layout of a grid with the simple no-need-to-define-anything style of the StackPanel
?
Read on and I'll show a custom panel that achieves this.
The Problem
The Grid
in WPF is extremely powerful and can achieve pretty much any layout thinkable. Unfortunately, it requires you to define all the rows and columns up front,
and also forces you to specify for each of your child items in which cell of the grid they should be placed. To achieve the layout in the screenshot above, you would have to write
something like this in XAML:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="Name:" />
<TextBox Text="John Doe" VerticalAlignment="Center" Grid.Column="1" />
<Label Content="Address:" Grid.Row="1" />
<TextBox Text="34 Some Street, 123 45 SomeTown, Some Country"
VerticalAlignment="Center" Height="70" Grid.Row="1" Grid.Column="1"/>
<Label Content="Position:" Grid.Row="2" />
<TextBox Text="Manager" Grid.Row="2" Grid.Column="1"/>
</Grid>
What's worse is that if you want to move stuff around (or add things in the middle), you have to update all the Grid.Column="x"
and Grid.Row="y"
entries manually.
The Solution
Let's instead define a custom panel which has these properties:
- No row/column definitions. Instead, implicitly assume two columns and an unbounded amount of rows.
- No explicit positioning. Deduce position in the grid based on the order of the child elements.
As for column sizing, we define the following:
- Column 1 will use only as much space as is needed by its widest child
- Column 2 will use the remaining width
This has the effect that all items in column 2 will be aligned nicely which matches best practices in UI design for these kind of screens.
Furthermore, we'll add two properties to control the extra spacing between children.
RowSpacing
- add a number of pixels between each row (default to zero)
ColumnSpacing
- add a number of pixels between the two columns (default to zero)
The Code
Two create your own panel, all you need to do is to create a class that inherits from the Panel
class. This class is abstract
and requires you to override two methods:
MeasureOverride
- Given a size constraint,
measure all children and calculate a desired size for the panel
ArrangeOverride
- Arrange all children according
to the provided area given by the parent control/panel
Measuring the Children
In MeasureOverride
, we start by measuring all the children that will appear in the first column:
for (int i = 0; i < VisualChildrenCount; i += 2)
{
var child = Children[i];
child.Measure(constraint);
col1Width = Math.Max(child.DesiredSize.Width, col1Width);
RowHeights.Add(child.DesiredSize.Height);
}
Notice that we store the maximum width of the column 1 children. This will become the final width of the first column. We also store all the row heights so we can
calculate our final height at the end. The reason we store the values in a list instead of simply adding them up is because we haven't yet measured the second column children.
It's perfectly valid for the two children of a row to be of different heights.
Now, we have enough information to start measuring the second column:
var newWidth = Math.Max(0, constraint.Width - col1Width - ColumnSpacing);
Size newConstraint = new Size(newWidth, constraint.Height);
for (int i = 1; i < VisualChildrenCount; i += 2)
{
var child = Children[i];
child.Measure(newConstraint);
col2Width = Math.Max(child.DesiredSize.Width, col2Width);
RowHeights[i/2] = Math.Max(RowHeights[i/2], child.DesiredSize.Height);
}
The newWidth
variable holds the remaining width available. It's basically the provided bounds minus the width of column 1 (minus any column
spacing). Math.Max
is there to make sure we don't get a negative size in case column 1 wants to occupy the entire width.
Notice also how we update the RowHeights
values in case the right hand child happens to be higher than it's left hand sibling.
Finally, we have enough information to return our desired size to our parent:
return new Size(
col1Width + ColumnSpacing + col2Width,
RowHeights.Sum() + ((RowHeights.Count - 1) * RowSpacing));
Arranging the Children
This is a bit more straightforward than the measure phase since we already have all the sizes ready:
protected override Size ArrangeOverride(Size arrangeSize)
{
double y = 0;
for (int i = 0; i < VisualChildrenCount; i++)
{
var child = Children[i];
double height = RowHeights[i/2];
if (i % 2 == 0)
{
var r = new Rect(0, y, Column1Width, height);
child.Arrange(r);
}
else
{
var r = new Rect(Column1Width + ColumnSpacing, y,
arrangeSize.Width - Column1Width -
ColumnSpacing, height);
child.Arrange(r);
y += height;
y += RowSpacing;
}
}
return base.ArrangeOverride(arrangeSize);
}
We basically just iterate over all the children and place them in the correct location. The variable y
maintains the vertical position of the current row,
and each child's x position is calculated from the previously measured column widths.
That's basically it. The layout shown in the first screenshot at the top can now be achieved like this:
<local:TwoColumnGrid>
<Label Content="Name:" />
<TextBox Text="John Doe" VerticalAlignment="Center" />
<Label Content="Address:" />
<TextBox Text="34 Some Street, 123 45 SomeTown, Some Country"
VerticalAlignment="Center" Height="70" />
<Label Content="Position:" />
<TextBox Text="Manager" />
</local:TwoColumnGrid>
History
- 2011-08-09: First version.