Search code examples
wpfxamltemplatestabcontrol

Using UniformGrid to set Header height within TabControl WPF


I am trying to make a vertical TabControl section on a WPF app and would like the Header buttons to fill the height of the screen (down to a MinHeight) and be of uniform size. I am hoping that this can be done through creating a custom template for the TabControl and TabItem templates.

Tab control template

        <Style TargetType="{x:Type TabControl}">
            <Setter Property="Padding" Value="2"/>
            <Setter Property="HorizontalContentAlignment" Value="Center"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="Background" Value="{StaticResource TabItem.Selected.Background}"/>
            <Setter Property="BorderBrush" Value="{StaticResource TabItem.Selected.Border}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type TabControl}">
                        <Grid x:Name="templateRoot" ClipToBounds="true" SnapsToDevicePixels="true" KeyboardNavigation.TabNavigation="Local">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition x:Name="ColumnDefinition0"/>
                                <ColumnDefinition x:Name="ColumnDefinition1"/>
                            </Grid.ColumnDefinitions>
                            <Grid.RowDefinitions>
                                <RowDefinition x:Name="RowDefinition0" Height="Auto"/>
                                <RowDefinition x:Name="RowDefinition1" Height="*"/>
                            </Grid.RowDefinitions>
                            <TabPanel x:Name="headerPanel" Background="Transparent" Grid.Column="0" IsItemsHost="true" Margin="2,2,2,0" Grid.Row="0" KeyboardNavigation.TabIndex="1" Panel.ZIndex="1"/>
                            <Border x:Name="contentPanel" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Grid.Column="0" KeyboardNavigation.DirectionalNavigation="Contained" Grid.Row="1" KeyboardNavigation.TabIndex="2" KeyboardNavigation.TabNavigation="Local">
                                <ContentPresenter x:Name="PART_SelectedContentHost" ContentSource="SelectedContent" Margin="{TemplateBinding Padding}" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                            </Border>
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="TabStripPlacement" Value="Left">
                                <Setter Property="Width" TargetName="headerPanel" Value="80"/>
                                <Setter Property="Grid.Row" TargetName="headerPanel" Value="0"/>
                                <Setter Property="Grid.Row" TargetName="contentPanel" Value="0"/>
                                <Setter Property="Grid.Column" TargetName="headerPanel" Value="0"/>
                                <Setter Property="Grid.Column" TargetName="contentPanel" Value="1"/>
                                <Setter Property="Width" TargetName="ColumnDefinition0" Value="Auto"/>
                                <Setter Property="Width" TargetName="ColumnDefinition1" Value="*"/>
                                <Setter Property="Height" TargetName="RowDefinition0" Value="*"/>
                                <Setter Property="Height" TargetName="RowDefinition1" Value="0"/>
                                <Setter Property="Margin" TargetName="headerPanel" Value="2,2,0,2"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="false">
                                <Setter Property="TextElement.Foreground" TargetName="templateRoot" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

I have been able to manually set the heights of each TabItem header by setting the properties, however this then isn't dynamic as the user changes the window size


Solution

  • You should not use a UniformGrid to replace the TabPanel because you would lose relevant features like proper multi-line tab item wrapping and other TabControl specific item layout behaviors. Both UnformGrid and TabPanel use completely different algorithms to arrange their children. UnformGrid does not explicitly honor the nature of the TabControl.

    Solution 1

    If you don't care about losing features or the slightly different layout, then the simplest solution would be to simply replace the TabPanel with a UniformGrid. Because you want to stack the TabItem elements vertically, you would lose the multi-line feature anyways (multi-line arrangement is by default only available for horizontal alignment i.e. TabStripPlacement is set to Dock.Top or Dock.Bottom).

    <ControlTemplate TargetType="{x:Type TabControl}">
      <Grid x:Name="templateRoot" ClipToBounds="true" SnapsToDevicePixels="true" KeyboardNavigation.TabNavigation="Local">
        <Grid.ColumnDefinitions>
          <ColumnDefinition x:Name="ColumnDefinition0"/>
          <ColumnDefinition x:Name="ColumnDefinition1"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
          <RowDefinition x:Name="RowDefinition0" Height="Auto"/>
          <RowDefinition x:Name="RowDefinition1" Height="*"/>
        </Grid.RowDefinitions>
        
        <UniformGrid x:Name="headerPanel" 
                     Background="Transparent" 
                     Grid.Column="0" 
                     IsItemsHost="true" 
                     Margin="2,2,2,0" 
                     Grid.Row="0" 
                     KeyboardNavigation.TabIndex="1" 
                     Panel.ZIndex="1" />
    
        <Border x:Name="contentPanel" 
                Background="{TemplateBinding Background}" 
                BorderBrush="{TemplateBinding BorderBrush}" 
                BorderThickness="{TemplateBinding BorderThickness}" 
                Grid.Column="0" 
                KeyboardNavigation.DirectionalNavigation="Contained" 
                Grid.Row="1" 
                KeyboardNavigation.TabIndex="2" 
                KeyboardNavigation.TabNavigation="Local">
          <ContentPresenter x:Name="PART_SelectedContentHost" 
                            ContentSource="SelectedContent" 
                            Margin="{TemplateBinding Padding}" 
                            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" /> 
        </Border>
      </Grid>
    </ControlTemplate>
    

    Solution 2

    A cleaner solution, that respects the special layout requirements of the TabControl is to extend the TabPanel.

    in the following example the new class UniformTabPanel extends TabPanel and overrides the layout arrangement for the vertical arrangement.
    For the horizontal arrangement the UniformTabPanel panel will fall back to the default layout algorithm of the TabPanel.
    To use the UniformTabPanel simply replace the TabPanel in the ControlTemplate of the TabControl:

    UniformTabPanel.cs

    public class UniformTabPanel : TabPanel
    {
      protected override Size ArrangeOverride(Size arrangeSize)
      {
        Dock itemPlacement = this.TemplatedParent is TabControl tabControl
          ? tabControl.TabStripPlacement
          : Dock.Left;  // Prefer vertical stacking for this panel
    
        if (itemPlacement is Dock.Left or Dock.Right)
        {
          ArrangeVertical(arrangeSize);
          return arrangeSize;
        }
        else
        {
          // Apply default layout behavior
          return base.ArrangeOverride(arrangeSize);
        }
      }
    
      private void ArrangeVertical(Size arrangeSize)
      {
        double uniformDesiredChildHeight = GetUniformItemHeight(arrangeSize);
        double childOffsetY = 0d;
    
        foreach (UIElement child in this.InternalChildren)
        {
          if (child.Visibility != Visibility.Collapsed)
          {
            Size childSize = GetDesiredSizeWithoutMargin(uniformDesiredChildHeight, child);
            child.Arrange(new Rect(0, childOffsetY, arrangeSize.Width, childSize.Height));
    
            // Calculate the offset for the next child
            childOffsetY += childSize.Height;
          }
        }
      }
    
      private double GetUniformItemHeight(Size arrangeSize)
      {
        double totalDesiredHeight = this.TemplatedParent is TabControl tabControl
          ? tabControl.DesiredSize.Height
          : arrangeSize.Height;
    
        return totalDesiredHeight / Math.Max(1, this.InternalChildren.Count);
      }
    
      private Size GetDesiredSizeWithoutMargin(double desiredChildHeight, UIElement element)
      {
        Thickness margin = (Thickness)element.GetValue(MarginProperty);
        Size desiredSizeWithoutMargin = new Size
        {
          Height = Math.Max(0d, desiredChildHeight - margin.Top - margin.Bottom),
          Width = Math.Max(0d, element.DesiredSize.Width - margin.Left - margin.Right)
        };
    
        return desiredSizeWithoutMargin;
      }
    }
    
    <ControlTemplate TargetType="{x:Type TabControl}">
      <Grid x:Name="templateRoot" ClipToBounds="true" SnapsToDevicePixels="true" KeyboardNavigation.TabNavigation="Local">
        <Grid.ColumnDefinitions>
          <ColumnDefinition x:Name="ColumnDefinition0"/>
          <ColumnDefinition x:Name="ColumnDefinition1"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
          <RowDefinition x:Name="RowDefinition0" Height="Auto"/>
          <RowDefinition x:Name="RowDefinition1" Height="*"/>
        </Grid.RowDefinitions>
        
        <local:UniformTabPanel x:Name="headerPanel" 
                               Background="Transparent" 
                               Grid.Column="0" 
                               IsItemsHost="true" 
                               Margin="2,2,2,0" 
                               Grid.Row="0" 
                               KeyboardNavigation.TabIndex="1" 
                               Panel.ZIndex="1" />
    
        <Border x:Name="contentPanel" 
                Background="{TemplateBinding Background}" 
                BorderBrush="{TemplateBinding BorderBrush}" 
                BorderThickness="{TemplateBinding BorderThickness}" 
                Grid.Column="0" 
                KeyboardNavigation.DirectionalNavigation="Contained" 
                Grid.Row="1" 
                KeyboardNavigation.TabIndex="2" 
                KeyboardNavigation.TabNavigation="Local">
          <ContentPresenter x:Name="PART_SelectedContentHost" 
                            ContentSource="SelectedContent" 
                            Margin="{TemplateBinding Padding}" 
                            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" /> 
        </Border>
      </Grid>
    </ControlTemplate>