47

I have an IList of viewmodels which are bound to a TabControl. This IList will not change over the lifetime of the TabControl.

<TabControl ItemsSource="{Binding Tabs}" SelectedIndex="0" >
    <TabControl.ItemContainerStyle>
        <Style TargetType="TabItem">
            <Setter Property="Content" Value="{Binding}" />
        </Style>
    </TabControl.ItemContainerStyle>
</TabControl>

Each viewmodel has a DataTemplate which is specified in a ResourceDictionary.

<DataTemplate TargetType={x:Type vm:MyViewModel}>
    <v:MyView/>
</DataTemplate>

Each of the views specified in the DataTemplate are resource intensive enough to create that I would rather create each view just once, but when I switch tabs, the constructor for the relevant view is called. From what I have read, this is the expected behavior for the TabControl, but it is not clear to me what the mechanism is which calls the constructor.

I have taken a look at a similar question which uses UserControls but the solution offered there would require me to bind to views which is undesirable.

10
  • Have you tried ItemsSource="{Binding Tabs, Mode=OneTime}"?
    – Silvermind
    Commented Mar 20, 2012 at 20:15
  • 1
    @Silvermind Hadn't until you mentioned it, but it has no effect. Since the Tabs are an IList which doesn't notify of PropertyChanged I think this was effectively the case already.
    – Mike
    Commented Mar 20, 2012 at 20:34
  • There is a different behavior with TabControl ItemsSource then when you manually create the tabs. If you manually create the tabs they will get loaded when the Window or Page is loaded regardless if the tab is selected - but they will only get loaded once. With ItemsSource the tab only get loaded when it is selected but it get loaded every time it is selected.
    – paparazzo
    Commented Mar 20, 2012 at 20:56
  • 1
    You can write your own derived tabcontrol where you can redefine the ItemsSource property. I've created such control for Silverlight vortexwolf.wordpress.com/2011/04/09/…, so I think you can write something similar for the WPF. The idea is to use the Items property which will be created only once.
    – vortexwolf
    Commented Mar 20, 2012 at 20:58
  • @Blam This is true, and if I do as you say, everything is fine, but in order to manually create the tabs, I have to wrap the viewmodels in ContentPresenters for each tab which means I need to know how many tabs I'll need a priori. Although the tabs won't change when the Tabs are bound to the TabControl, I will not know how many tabs will be there a priori.
    – Mike
    Commented Mar 20, 2012 at 21:34

5 Answers 5

57

By default, the TabControl shares a panel to render it's content. To do what you want (and many other WPF developers), you need to extend TabControl like so:

TabControlEx.cs

[TemplatePart(Name = "PART_ItemsHolder", Type = typeof(Panel))]
public class TabControlEx : TabControl
{
    private Panel ItemsHolderPanel = null;

    public TabControlEx()
        : base()
    {
        // This is necessary so that we get the initial databound selected item
        ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;
    }

    /// <summary>
    /// If containers are done, generate the selected item
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
    {
        if (this.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated)
        {
            this.ItemContainerGenerator.StatusChanged -= ItemContainerGenerator_StatusChanged;
            UpdateSelectedItem();
        }
    }

    /// <summary>
    /// Get the ItemsHolder and generate any children
    /// </summary>
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        ItemsHolderPanel = GetTemplateChild("PART_ItemsHolder") as Panel;
        UpdateSelectedItem();
    }

    /// <summary>
    /// When the items change we remove any generated panel children and add any new ones as necessary
    /// </summary>
    /// <param name="e"></param>
    protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnItemsChanged(e);

        if (ItemsHolderPanel == null)
            return;

        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Reset:
                ItemsHolderPanel.Children.Clear();
                break;

            case NotifyCollectionChangedAction.Add:
            case NotifyCollectionChangedAction.Remove:
                if (e.OldItems != null)
                {
                    foreach (var item in e.OldItems)
                    {
                        ContentPresenter cp = FindChildContentPresenter(item);
                        if (cp != null)
                            ItemsHolderPanel.Children.Remove(cp);
                    }
                }

                // Don't do anything with new items because we don't want to
                // create visuals that aren't being shown

                UpdateSelectedItem();
                break;

            case NotifyCollectionChangedAction.Replace:
                throw new NotImplementedException("Replace not implemented yet");
        }
    }

    protected override void OnSelectionChanged(SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);
        UpdateSelectedItem();
    }

    private void UpdateSelectedItem()
    {
        if (ItemsHolderPanel == null)
            return;

        // Generate a ContentPresenter if necessary
        TabItem item = GetSelectedTabItem();
        if (item != null)
            CreateChildContentPresenter(item);

        // show the right child
        foreach (ContentPresenter child in ItemsHolderPanel.Children)
            child.Visibility = ((child.Tag as TabItem).IsSelected) ? Visibility.Visible : Visibility.Collapsed;
    }

    private ContentPresenter CreateChildContentPresenter(object item)
    {
        if (item == null)
            return null;

        ContentPresenter cp = FindChildContentPresenter(item);

        if (cp != null)
            return cp;

        // the actual child to be added.  cp.Tag is a reference to the TabItem
        cp = new ContentPresenter();
        cp.Content = (item is TabItem) ? (item as TabItem).Content : item;
        cp.ContentTemplate = this.SelectedContentTemplate;
        cp.ContentTemplateSelector = this.SelectedContentTemplateSelector;
        cp.ContentStringFormat = this.SelectedContentStringFormat;
        cp.Visibility = Visibility.Collapsed;
        cp.Tag = (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
        ItemsHolderPanel.Children.Add(cp);
        return cp;
    }

    private ContentPresenter FindChildContentPresenter(object data)
    {
        if (data is TabItem)
            data = (data as TabItem).Content;

        if (data == null)
            return null;

        if (ItemsHolderPanel == null)
            return null;

        foreach (ContentPresenter cp in ItemsHolderPanel.Children)
        {
            if (cp.Content == data)
                return cp;
        }

        return null;
    }

    protected TabItem GetSelectedTabItem()
    {
        object selectedItem = base.SelectedItem;
        if (selectedItem == null)
            return null;

        TabItem item = selectedItem as TabItem;
        if (item == null)
            item = base.ItemContainerGenerator.ContainerFromIndex(base.SelectedIndex) as TabItem;

        return item;
    }
}

XAML

<Style TargetType="{x:Type controls:TabControlEx}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type TabControl}">
                <Grid Background="{TemplateBinding Background}" ClipToBounds="True" KeyboardNavigation.TabNavigation="Local" SnapsToDevicePixels="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>
                    <DockPanel Margin="2,2,0,0" LastChildFill="False">
                        <TabPanel x:Name="HeaderPanel" Margin="0,0,0,-1" VerticalAlignment="Bottom" Panel.ZIndex="1" DockPanel.Dock="Right"
                                  IsItemsHost="True" KeyboardNavigation.TabIndex="1" />
                    </DockPanel>
                    <Border x:Name="ContentPanel" Grid.Row="1" Grid.Column="0"
                            Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            KeyboardNavigation.DirectionalNavigation="Contained" KeyboardNavigation.TabIndex="2" KeyboardNavigation.TabNavigation="Local">
                        <Grid x:Name="PART_ItemsHolder" Margin="{TemplateBinding Padding}" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
                    </Border>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Note: I did not come up with this solution. It has been shared in programming forums for several years and believe that it is in now one of those WPF recipes books. The oldest or original source for I believe was PluralSight .NET blog post and this answer on StackOverflow.

HTH,

9
  • 5
    I had run across this, but I never found it with its accompanying ControlTemplate which is necessary for it to work. Thanks!
    – Mike
    Commented Mar 21, 2012 at 14:26
  • I have added additional an additional answer which is designed to support information given in this answer.
    – Contango
    Commented Sep 9, 2014 at 13:16
  • 3
    Very nice. I'm trying this in VS2017, .NET 4.6.1, and I had to move the DockPanel in the template after the Border in order to have it sit on top and hide the top line of the border underneath the selected tab header. I also was able to remove the margin on the Tabpanel; with the default TabPanel style I'm getting, it already extends the selected item downward by one pixel/unit/whatever. Commented Oct 11, 2017 at 17:44
  • @EdPlunkett feel free to edit the answer with the new details. i was thinking that when i last used this code, i was using net35 with VS 2010 :)
    – Dennis
    Commented Oct 12, 2017 at 2:25
  • 1
    Thanks, this helped me. One improvement: In OnItemsChanged, move the UpdateSelectedItem() outside the switch. I had an issue that the initially selected tab was empty, because the ItemsSource binding of the TabControl updated a few times, triggering the NotifyCollectionChangedAction.Reset. OnSelectionChanged did not trigger, so the _itemsHolderPanel was cleared while a tab was selected. Forcing an UpdateSelectedItem after every OnItemsChanged fixes this issue.
    – Coder14
    Commented Jun 7, 2019 at 6:54
16

The answer by Dennis is superb, and worked very nicely for me. However, the original article referred to in his post is now missing, so his answer needs a bit more information to be usable right out of the box.

This answer is given from an MVVM point of view, and was tested under VS 2013.

First, a bit of background. The way the first answer from Dennis works is that it hides and shows the tab contents, instead of destroying and recreating said tab contents, every time the user switches a tab.

This has the following advantages:

  • The contents of edit boxes do not disappear when the tab is switched.
  • If you are using a Tree View in a tab, it does not collapse between tab changes.
  • The current selection for any grids is retained between tab switches.
  • This code is more agreeable with an MVVM style of programming.
  • We do not have to write code to save and load the settings on a tab between tab changes.
  • If you are using a 3rd party control (like Telerik or DevExpress), settings like the grid layout are retained between tab switches.
  • Great performance improvements - the tab switching is virtually instant, as we are not redrawing everything every time a tab changes.

TabControlEx.cs

// Copy C# code from @Dennis's answer, and add the following property after the 
// opening "<Style" tag (this sets the key for the style):
// x:Key="TabControlExStyle"
// Ensure that the namespace for this class is the same as your DataContext.

This goes into the same class as pointed to by the DataContext.

XAML

// Copy XAML from @Dennis's answer.

This is a style. It goes into the header of the XAML file. This style never changes, and is referred to by all tab controls.

Original Tab

Your original tab might look something like this. If you switch tabs, you will notice that the contents of the edit boxes will disappear, as the tab's contents are being dropped and recreated again.

<TabControl
  behaviours:TabControlBehaviour.DoSetSelectedTab="True"
  IsSynchronizedWithCurrentItem="True">
<TabItem Header="Tab 1">
  <TextBox>Hello</TextBox>
</TabItem>
<TabItem Header="Tab 2" >
  <TextBox>Hello 2</TextBox>
</TabItem>

Custom Tab

Alter the tab to use our new custom C# class, and point it at our new custom style using the Style tag:

<sdm:TabControlEx
  behaviours:TabControlBehaviour.DoSetSelectedTab="True"
  IsSynchronizedWithCurrentItem="True"
  Style="{StaticResource TabControlExStyle}">
<TabItem Header="Tab 1">
  <TextBox>Hello</TextBox>
</TabItem>
<TabItem Header="Tab 2" >
  <TextBox>Hello 2</TextBox>
</TabItem>

Now, when you switch tabs, you will find that the contents of the edit boxes are retained, which proves that everything is working nicely.

Update

This solution works very well. However, there is a more modular and MVVM friendly way to do this, which uses an attached behaviour to achieve the same result. See Code Project: WPF TabControl: Turning Off Tab Virtualization. I have added this as an additional answer.

Update

If you happen to be using DevExpress, you can use the CacheAllTabs option to get the same effect (this switches off tab virtualization):

<dx:DXTabControl TabContentCacheMode="CacheAllTabs">
    <dx:DXTabItem Header="Tab 1" >
        <TextBox>Hello</TextBox>
    </dx:DXTabItem>
    <dx:DXTabItem Header="Tab 2">
        <TextBox>Hello 2</TextBox>
    </dx:DXTabItem>
</dx:DXTabControl>

For the record, I am not affiliated with DevExpress, I'm sure that Telerik has the equivalent.

Update

Telerik does have the equivalent: IsContentPreserved. Thanks to @Luishg in the comments below.

7
  • 1
    Nice work @contango. Thank-you for sharing.It has been almost 18months since I've written any WPF.
    – Dennis
    Commented Sep 9, 2014 at 13:27
  • 3
    Implementation from CodeProject (the link in this answer) leaks memory big time due to use DependencyPropertyDescriptor.AddValueChanged. It's possible to remove that particular line of code without breaking any functionality, apart of some safety check, which is obvious from the code.
    – zmechanic
    Commented Sep 22, 2015 at 15:20
  • 1
    Thanks. Since I'm using devexpress this solution works great and it's simple. Commented Aug 28, 2018 at 2:36
  • 1
    The Telerik equivalent option would be: IsContentPreserved
    – Luishg
    Commented Jul 2, 2019 at 17:45
  • 1
    @Luishg Great, thanks for the update! I've updated my answer.
    – Contango
    Commented Jul 2, 2019 at 19:50
6

This existing solution by @Dennis (with additional notes by @Gravitas) works very well.

However, there is another solution which is more modular and MVVM friendly as it uses an attached behaviour to achieve the same result.

See Code Project: WPF TabControl: Turning Off Tab Virtualization. As the author is technical lead at Reuters, the code is probably solid.

The demo code is really well put together, it shows a regular TabControl, alongside the one with the attached behavior.

enter image description here

2
  • 5
    Implementation from CodeProject (the link in this answer) leaks memory big time due to use DependencyPropertyDescriptor.AddValueChanged. It's possible to remove that particular line of code without breaking any functionality, apart of some safety check, which is obvious from the code.
    – zmechanic
    Commented Sep 22, 2015 at 15:20
  • It seems to fail for simple cases, like <TabControl ikriv:TabContent.IsCached="True"><TabItem Header="Tab1"></TabItem></TabControl>. Error, "Must disconnect specified child from current parent Visual before attaching to new parent Visual".
    – Vimes
    Commented Sep 30, 2020 at 20:49
0

There is a not very obvious but elegant solution. The main idea is manually generate VisualTree for Content property of TabItem via a custom converter.

Define some resources

<Window.Resources>
    <converters:ContentGeneratorConverter x:Key="ContentGeneratorConverter"/>

    <DataTemplate x:Key="ItemDataTemplate">
        <StackPanel>
            <TextBox Text="Try to change this text and choose another tab"/>
            <TextBlock Text="{Binding}"/>
        </StackPanel>
    </DataTemplate>

    <markup:Set x:Key="Items">
        <system:String>Red</system:String>
        <system:String>Green</system:String>
        <system:String>Blue</system:String>
    </markup:Set>
</Window.Resources>

where

public class ContentGeneratorConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var control = new ContentControl {ContentTemplate = (DataTemplate) parameter};
        control.SetBinding(ContentControl.ContentProperty, new Binding());
        return control;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
        throw new NotImplementedException();
}

and Set is something like this

public class Set : List<object> { }

Then instead classical using of ContentTemplate property

    <TabControl
        ItemsSource="{StaticResource Items}"
        ContentTemplate="{StaticResource ItemDataTemplate}">
    </TabControl>

we should specify ItemContainerStyle by the following way

    <TabControl
        ItemsSource="{StaticResource Items}">
        <TabControl.ItemContainerStyle>
            <Style TargetType="TabItem" BasedOn="{StaticResource {x:Type TabItem}}">
                <Setter Property="Content" Value="{Binding Converter={StaticResource ContentGeneratorConverter}, ConverterParameter={StaticResource ItemDataTemplate}}"/>
            </Style>
        </TabControl.ItemContainerStyle>
    </TabControl>

Now, try to compare both variations to see difference into behavior of TextBox at ItemDataTemplate during tabs switching.

0

@Dennis answered works well for me.

The only small "issue" is Windows Automation not working with TabControlEx while implementing automated UI tests. Symptom would be AutomationElement.FindFirst(TreeScope, Condition) Method always returning null

To fix that I would add

public class TabControlEx : TabControl
{
// Dennis' version here
...
    public Panel ItemsHolderPanel => _itemsHolderPanel;
    protected override AutomationPeer OnCreateAutomationPeer()
    {
        return new TabControlExAutomationPeer(this);
    }
}

With this new types added:

public class TabControlExAutomationPeer : TabControlAutomationPeer
{
    public TabControlExAutomationPeer(TabControlEx owner) : base(owner)
    {
    }
    protected override ItemAutomationPeer CreateItemAutomationPeer(object item)
    {
        return new TabItemExAutomationPeer(item, this);
    }
}

public class TabItemExAutomationPeer : TabItemAutomationPeer
{
    public TabItemExAutomationPeer(object owner, TabControlExAutomationPeer tabControlExAutomationPeer) 
        : base(owner, tabControlExAutomationPeer)
    {
    }
    
    protected override List<AutomationPeer> GetChildrenCore()
    {
        var headerChildren = base.GetChildrenCore();

        if (ItemsControlAutomationPeer.Owner is TabControlEx parentTabControl)
        {
            var contentHost = parentTabControl.ItemsHolderPanel;
            if (contentHost != null)
            {
                AutomationPeer contentHostPeer = new FrameworkElementAutomationPeer(contentHost);
                var contentChildren = contentHostPeer.GetChildren();
                if (contentChildren != null)
                {
                    if (headerChildren == null)
                        headerChildren = contentChildren;
                    else
                        headerChildren.AddRange(contentChildren);
                }
            }
        }


        return headerChildren;
    }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.