다음을 통해 공유


WPF: Prevent The Selected TabItem From "Moving" In a TabControl

If you display a lot of tabs in a TabControl, you may have noticed that the currently selected one always "jumps" to the last row of tabs regardless of the actual order of the items in the Items or ItemsSource collection of the TabControl.

This potential issue is easy to reproduce. Create a TabControl in a view and bind it to a source collection of a view model that includes a few items:

<Window x:Class="WpfApp.Tabs.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp.Tabs"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="300">
    <Window.DataContext>
        <local:ViewModel />
    </Window.DataContext>
    <Grid>
        <TabControl ItemsSource="{Binding Items}">
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Header, Mode=OneTime}" />
                </DataTemplate>
            </TabControl.ItemTemplate>
            <TabControl.ContentTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Contents}" />
                </DataTemplate>
            </TabControl.ContentTemplate>
        </TabControl>
    </Grid>
</Window>
public class  ViewModel
{
    private const  int n = 20;
 
    public ViewModel()
    {
        for (int i = 0; i < n; ++i)
            Items.Add(new Item(i));
    }
 
    public List<Item> Items { get; } = new  List<Item>();
}
 
 
public class  Item
{
    public Item(int n)
    {
        Header = $"Tab {n}";
        Contents = $"Contents of tab {n}...";
    }
    public string  Header { get; }
    public string  Contents { get; }
}

If you run the application, you will notice that the last tab ("Tab 19") is located on the second last row and the first and selected tab ("Tab 0") is located on the last row. If you select any tab, you will notice that this tab is always moved to the bottom row right above the content panel:

https://magnusmontin.files.wordpress.com/2017/08/anim1.gif

This behavior might not be what you want and luckily there is a quite easy way to fix it and keep each tab in a "fixed" position. Here is how to do it.

1. Right-click on the TabControl in design mode in Visual Studio or in Blend and choose "Edit Template"->"Edit a Copy...". This will let you copy the default style of the TabControl into your XAML markup. You could put the resources that Visual Studio or Blend create for you in a ResourceDictionary that you for example merge into your App.xaml:

<Application x:Class="WpfApp1.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Tab.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

2. Once you have done this you need to modify the ControlTemplate slightly. Look for the <TabPanel> element and replace it with a <WrapPanel> element. You don't need to remove or add any attributes. Just change the type of the panel.

You may also want to remove the x:Key attribute from the Style in order to make it an implicit one that gets applied to all TabControl elements by default.

3. The next thing to do is to modify the template of the TabItem container. You need to do this for the borders around each individual tab to play nice with the new layout.

Again, right-click on the TabControl and this time you select "Edit Additional Templates"->"Edit Generated Item Container (ItemContainerStyle)"->"Edit a Copy ...". Don't forget to merge the resources into your App.xaml and remove the x:Key attribute from the generated TabItem style just like you did with the TabControl style before.

In this template, there is quite a few MultiDataTriggers that changes some properties of some elements in the template depending on whether the tab is currently selected or hovered with the mouse. The properties also have different values depending on the value of the TabStripPlacement property of the parent TabControl. The default value of this property is Top and that's why the tabs appear on top of the content panel unless you set the property to something else.

In the Setters collection of the two last MultiDataTriggers in the default template, you simply change the BorderThickness property of the Border elements named "innerBorder" and "mainBorder" to a uniform thickness of 1. By default, there is no bottom border:

<MultiDataTrigger>
    <MultiDataTrigger.Conditions>
        <Condition Binding="{Binding IsSelected, RelativeSource={RelativeSource Self}}" Value="false"/>
        <Condition Binding="{Binding TabStripPlacement, RelativeSource={RelativeSource AncestorType={x:Type TabControl}}}" Value="Top"/>
    </MultiDataTrigger.Conditions>
    <Setter Property="BorderThickness" TargetName="innerBorder" Value="1,1,1,1"/>
    <Setter Property="BorderThickness" TargetName="mainBorder" Value="1,1,1,1"/>
    <Setter Property="Margin" Value="0,0,0,-1"/>
</MultiDataTrigger>
<MultiDataTrigger>
    <MultiDataTrigger.Conditions>
        <Condition Binding="{Binding IsSelected, RelativeSource={RelativeSource Self}}" Value="true"/>
        <Condition Binding="{Binding TabStripPlacement, RelativeSource={RelativeSource AncestorType={x:Type TabControl}}}" Value="Top"/>
    </MultiDataTrigger.Conditions>
    <Setter Property="Panel.ZIndex" Value="1"/>
    <Setter Property="Margin" Value="-2,-2,-2,-1"/>
    <Setter Property="Opacity" TargetName="innerBorder" Value="1"/>
    <Setter Property="BorderThickness" TargetName="innerBorder" Value="1,1,1,1"/>
    <Setter Property="BorderThickness" TargetName="mainBorder" Value="1,1,1,1"/>
</MultiDataTrigger>

You probably also want to add a negative bottom margin for the borders of two tabs that are placed on top of each other to collapse. This has been done in the code snippet above.

With these changes made, the TabControl should now look something like this:

https://magnusmontin.files.wordpress.com/2017/08/screen1.png

Note that "Tab 10" is selected and that is still maintains its original position within the tab strip.

If there is a chance that you that you may change the value of the TabStripPlacement property of the TabControl to something else than the default value of Top, you should modify the Margin and Border setters in the other MultiDataTriggers accordingly.

Back to Top