Introduction
While working on a project, I wanted a TabControl
that keeps all its tabs in the same row rather than wrapping them to new rows, so I designed a "squeeze" TabPanel
. SqueezeTabPanel
is derived from Panel
, and proportionally scales its children if there isn't enough room to display them at their desired size.
The screen shot above shows the results of comparing the TabPanel
and the SqueezeTabPanel
, first using a pair of TextBlock
s, and then using TabItem
s.
This article examines the C# code necessary to create a custom panel that lays out its children horizontally. A TabControl
template style is defined that replaces the default TabPanel
with the custom panel.
Before creating the SqueezeTabPanel
, I wasn't able to find any examples of a custom panel that limited the size of its children. I hope this information helps you in whatever you are trying to do.
WPF Layout System
Laying out child elements is a two step process which is performed by overriding two methods, MeasureOverride
and ArrangeOverride
. The code that follows accomplishes the first step.
private double _rowHeight;
private double _scaleFactor;
protected override Size MeasureOverride(Size availableSize) {
double width = 0.0;
this._rowHeight = 0.0;
foreach(UIElement element in this.Children) {
element.Measure(availableSize);
Size size = this.GetDesiredSizeLessMargin(element);
this._rowHeight = Math.Max(this._rowHeight, size.Height);
width += size.Width;
}
if(width > availableSize.Width) {
this._scaleFactor = availableSize.Width / width;
width = 0.0;
foreach(UIElement element in this.Children) {
element.Measure(new Size(
element.DesiredSize.Width * this._scaleFactor,
availableSize.Height));
width += element.DesiredSize.Width;
}
}
else
this._scaleFactor = 1.0;
return new Size(width, this._rowHeight);
}
MeasureOverride
is called with the amount of space available to it for laying out its children. In the first loop, each child's Measure
method is called so the child can report back the amount of space it needs. Note that each child is called with the same availableSize
passed to the panel.
At the end of the first loop, _rowHeight
contains the height of the tallest child, and width
is the amount of space needed to display the children side-by-side horizontally.
Next, the width
is compared with the width available to the panel to see if there's enough room to display the full-sized children. If there's not enough room, _scaleFactor
is set to the fractional value by which each child needs to be reduced in order to fit the available space.
In the second loop, each child's Measure
method is called with the reduced width, and the total space needed is again accumulated in the variable width
. MeasureOverride
returns the actual space needed to display its children.
The following code accomplishes the second and final step of the layout process:
protected override Size ArrangeOverride(Size arrangeSize) {
Point point = new Point();
foreach(UIElement element in this.Children) {
Size size1 = element.DesiredSize;
Size size2 = this.GetDesiredSizeLessMargin(element);
Thickness margin = (Thickness)element.GetValue(FrameworkElement.MarginProperty);
double width = size.Width;
if(element.DesiredSize.Width != size.Width)
width = arrangeSize.Width - point.X;
element.Arrange(new Rect(
point,
new Size(Math.Min(width, size.Width), this._rowHeight)));
double leftRightMargin = Math.Max(0.0, -(margin.Left + margin.Right));
point.X += size1.Width + (leftRightMargin * this._scaleFactor);
}
return arrangeSize;
}
ArrangeOverride
is called with the space it can use for displaying its children. point
is created and initialized to (0,0)
, and its X
value is incremented by the width of each child after it is displayed.
While testing the panel, I noticed that when the last tab was selected, and while resizing the window, its right edge would sometimes get cut off. I figured that due to rounding issues, I needed to limit the width used by the last tab. The element.DesiredSize.Width != size.Width
statement is true
when the TabItem
is selected, as its margin
is -2,-2,-2,-1
(an unselected margin
is 0,0,0,0
). In the case of the last TabItem
, when selected, its width is limited to what is left of the allocated space (arrangeSize.Width - point.X
).
Notice that the margin value added to point.X
is scaled. This ensures that the tabs are displayed more smoothly when the window is resized and different tabs are selected.
Arranging TabItem
s seems to be different from arranging other items due to the way margin
s are used for selected and unselected TabItem
s. As a result, both the desiredSize
of the element and the desiredSize
after subtracting the margin thickness are used.
The following method returns the size of the item after subtracting the margin:
private Size GetDesiredSizeLessMargin(UIElement element) {
Thickness margin = (Thickness)element.GetValue(FrameworkElement.MarginProperty);
Size size = new Size();
size.Height = Math.Max(
0.0, element.DesiredSize.Height - (margin.Top + margin.Bottom));
size.Width = Math.Max(
0.0, element.DesiredSize.Width - (margin.Left + margin.Right));
return size;
}
You may ask, how do we get the TabControl
to use the custom SqueezeTabPanel
? I'm glad you asked. Microsoft provides sample theme files, and here is an edited version of the TabControl
style from aero.normalcolor.xaml. Aside from deleting the unused triggers for TabStripPlacement
values Bottom
, Left
, and Right
, the TabPanel
was changed to local:SqueezeTabPanel
. The modified style is added to Window1
's resources, and any TabControl
that you wish to use the SqueezeTabPanel
in must use this style.
<Style TargetType="TabControl" x:Key="SqueezeTabPanel">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TabControl}">
<Grid KeyboardNavigation.TabNavigation="Local"
SnapsToDevicePixels="true"
ClipToBounds="true">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="ColumnDefinition0"/>
<ColumnDefinition x:Name="ColumnDefinition1" Width="0"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition x:Name="RowDefinition0" Height="Auto"/>
<RowDefinition x:Name="RowDefinition1" Height="*"/>
</Grid.RowDefinitions>
<local:SqueezeTabPanel
x:Name="HeaderPanel"
Panel.ZIndex ="1"
KeyboardNavigation.TabIndex="1"
Grid.Column="0"
Grid.Row="0"
Margin="2,2,2,0"
IsItemsHost="true"/>
<Border x:Name="ContentPanel"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}"
KeyboardNavigation.TabNavigation="Local"
KeyboardNavigation.DirectionalNavigation="Contained"
KeyboardNavigation.TabIndex="2"
Grid.Column="0"
Grid.Row="1">
<ContentPresenter
x:Name="PART_SelectedContentHost"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
Margin="{TemplateBinding Padding}"
ContentSource="SelectedContent"/>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
There is one more thing that needs to be done to the SqueezeTabPanel
in order for the tabs to work correctly using keyboard navigation: the following static
constructor must be added.
static SqueezeTabPanel() {
KeyboardNavigation.TabNavigationProperty.OverrideMetadata(
typeof(SqueezeTabPanel),
new FrameworkPropertyMetadata(KeyboardNavigationMode.Once));
KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(
typeof(SqueezeTabPanel), new
FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
}
As a final note, I want to call your attention to TextBlock
's TextTrimming
property. When set to CharacterEllipsis
, it will end the string
with an ellipsis to indicate a partial string
when there is insufficient room to display the entire string
.
What's Next?
This article only scratches the surface of creating custom panels. There are many fine articles here on CodeProject that detail other aspects of custom panels.
The custom panel presented in this article works when the tab strip is placed on the top. It needs to be expanded to support tab strip placement in the other three directions.
The default TabItem
text does not display ellipses when there isn't enough room to fully display the text. Another exercise is to enable that functionality.
Further Reading
History
- April 11, 2009
- April 24, 2009
- Initialize
_rowHeight
in MeasureOverride
- In
ArrangeOverride
add a scaled margin to point.X