Here's a trivial example of the problem I'm having:
<StackPanel Orientation="Horizontal">
<Label>Foo</Label>
<TextBox>Bar</TextBox>
<ComboBox>
<TextBlock>Baz</TextBlock>
<TextBlock>Bat</TextBlock>
</ComboBox>
<TextBlock>Plugh</TextBlock>
<TextBlock VerticalAlignment="Bottom">XYZZY</TextBlock>
</StackPanel>
Every one of those elements except the TextBox
and ComboBox
vertically position the text they contain differently, and it looks plain ugly.
I can line the text in these elements up by specifying a Margin
for each. That works, except that the margin is in pixels, and not relative to the resolution of the display or the font size or any of the other things that are going to be variable.
I'm not even sure how I'd calculate the correct bottom margin for a control at runtime.
What's the best way to do this?
So as I understand it the problem is that you want to lay out controls horizontally in a StackPanel
and align to the top, but have the text in each control line up. Additionally, you don't want to have to set something for every control: either a Style
or a Margin
.
The root of the problem is that different controls have different amounts of "overhead" between the boundary of the control and the text within. When these controls are aligned at the top, the text within appears in different locations.
So what we want to do is apply an vertical offset that's customized to each control. This should work for all font sizes and all DPIs: WPF works in device-independent measures of length.
Now we can apply a Margin
to get our offset, but that means we need to maintain this on every control in the StackPanel
.
How do we automate this? Unfortunately it would be very difficult to get a bulletproof solution; it's possible to override a control's template, which would change the amount of layout overhead in the control. But it's possible to cook up a control that can save a lot of manual alignment work, as long as we can associate a control type (TextBox, Label, etc) with a given offset.
There are several different approaches you could take, but I think that this is a layout problem and needs some custom Measure and Arrange logic:
public class AlignStackPanel : StackPanel
{
public bool AlignTop { get; set; }
protected override Size MeasureOverride(Size constraint)
{
Size stackDesiredSize = new Size();
UIElementCollection children = InternalChildren;
Size layoutSlotSize = constraint;
bool fHorizontal = (Orientation == Orientation.Horizontal);
if (fHorizontal)
{
layoutSlotSize.Width = Double.PositiveInfinity;
}
else
{
layoutSlotSize.Height = Double.PositiveInfinity;
}
for (int i = 0, count = children.Count; i < count; ++i)
{
// Get next child.
UIElement child = children[i];
if (child == null) { continue; }
// Accumulate child size.
if (fHorizontal)
{
// Find the offset needed to line up the text and give the child a little less room.
double offset = GetStackElementOffset(child);
child.Measure(new Size(Double.PositiveInfinity, constraint.Height - offset));
Size childDesiredSize = child.DesiredSize;
stackDesiredSize.Width += childDesiredSize.Width;
stackDesiredSize.Height = Math.Max(stackDesiredSize.Height, childDesiredSize.Height + GetStackElementOffset(child));
}
else
{
child.Measure(layoutSlotSize);
Size childDesiredSize = child.DesiredSize;
stackDesiredSize.Width = Math.Max(stackDesiredSize.Width, childDesiredSize.Width);
stackDesiredSize.Height += childDesiredSize.Height;
}
}
return stackDesiredSize;
}
protected override Size ArrangeOverride(Size arrangeSize)
{
UIElementCollection children = this.Children;
bool fHorizontal = (Orientation == Orientation.Horizontal);
Rect rcChild = new Rect(arrangeSize);
double previousChildSize = 0.0;
for (int i = 0, count = children.Count; i < count; ++i)
{
UIElement child = children[i];
if (child == null) { continue; }
if (fHorizontal)
{
double offset = GetStackElementOffset(child);
if (this.AlignTop)
{
rcChild.Y = offset;
}
rcChild.X += previousChildSize;
previousChildSize = child.DesiredSize.Width;
rcChild.Width = previousChildSize;
rcChild.Height = Math.Max(arrangeSize.Height - offset, child.DesiredSize.Height);
}
else
{
rcChild.Y += previousChildSize;
previousChildSize = child.DesiredSize.Height;
rcChild.Height = previousChildSize;
rcChild.Width = Math.Max(arrangeSize.Width, child.DesiredSize.Width);
}
child.Arrange(rcChild);
}
return arrangeSize;
}
private static double GetStackElementOffset(UIElement stackElement)
{
if (stackElement is TextBlock)
{
return 5;
}
if (stackElement is Label)
{
return 0;
}
if (stackElement is TextBox)
{
return 2;
}
if (stackElement is ComboBox)
{
return 2;
}
return 0;
}
}
I started from the StackPanel's Measure and Arrange methods, then stripped out references to scrolling and ETW events and added the spacing buffer needed based on the type of element present. The logic only affects horizontal stack panels.
The AlignTop
property controls whether the spacing will make text align to the top or bottom.
The numbers needed to align the text may change if the controls get a custom template, but you don't need to put a different Margin
or Style
on each element in the collection. Another advantage is that you can now specify Margin
on the child controls without interfering with the alignment.
Results:
<local:AlignStackPanel Orientation="Horizontal" AlignTop="True" >
<Label>Foo</Label>
<TextBox>Bar</TextBox>
<ComboBox SelectedIndex="0">
<TextBlock>Baz</TextBlock>
<TextBlock>Bat</TextBlock>
</ComboBox>
<TextBlock>Plugh</TextBlock>
</local:AlignStackPanel>
AlignTop="False"
: