Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

WPF Squeeze TabPanel

0.00/5 (No votes)
26 Apr 2009 1  
Custom TabPanel that squeezes TabItems rather than wrap them to new rows

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 TextBlocks, and then using TabItems.

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;

// This Panel lays its children out horizontally.
// If all children cannot fit in the allocated space,
// the available space is divided proportionally between them.
protected override Size MeasureOverride(Size availableSize) {
    // See how much room the children want
    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 not enough room, scale the
    // children to the available 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:

// Perform arranging of children based on the final size
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;
            // Last-tab-selected "fix"
        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 TabItems seems to be different from arranging other items due to the way margins are used for selected and unselected TabItems. 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:

// Return element's size
// after subtracting 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.

// Ensure tabbing works correctly
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
    • Initial release
  • April 24, 2009
    • Initialize _rowHeight in MeasureOverride
    • In ArrangeOverride add a scaled margin to point.X

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here