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

Persist the Visual Tree when switching tabs in the WPF TabControl

0.00/5 (No votes)
16 Jun 2011 1  
Using attached behavior pattern to create a tab persist TabControl in WPF when ViewModel is used.

Sample Image

Introduction

There are two different behaviors in the WPF TabControl. When you are binding the tab items from the ViewModel to the ItemsSource property, the TabControl will create a new visual tree every time you switch between tabs. However, when you are adding a TabItem to the Items collection directly, the visual tree will be persisted when the tab is not active. While the databinding behavior is expected, this does create some issues:

  • Slow rendering. You can obviously see the delay when you have a heavy tab.
  • Visual change not persisted. If you change a column width in the tab, you will lose it after the visual tree is recreated.

In this article, I assume you have a basic understand about MVVM and attached properties. There is lots of material you can find on the Internet about these topics. To understand what attached behaviors is, check out Josh Smith's article.

Workaround

Don't use ItemsSource. Instead, have a wrapper convert your source to a TabItem and add it to the TabControl. In this case, you will still have all the cool things from data binding, while keeping the desired behavior.

The idea is simple, and there are lots of ways to implement this. You can extend the TabControl class, or just do it in code-behind. The way I have done is using the attached behaviour pattern. In this way, we can keep the code out from XAML and code-behind, which means it can be reused. Also, doing this as an attached pattern would be more flexible than overriding the TabControl class.

The Demo

The idea of this demo is to show the differences between using PersistTabBehavior and the original ItemsSource.

This demo contains a WPF window with two TabControls. Each TabControl has two tabs, and each tab has a big DataGrid for the purpose of slowing down the rendering. The top control uses attached properties explained in this article. The bottom control is just a regular TabControl. You can see that the performance of the top TabControl is better when switching between tabs.

Here is the differences between the two controls in XAML.

The Regular TabControl - Bottom
<TabControl 
    Grid.Row="2"
    DataContext="{Binding Tab1}"
    ItemsSource="{Binding Pages}"
    SelectedItem="{Binding SelectedPage}"
    ItemTemplate="{StaticResource templateForTheHeader}" />
The TabControl with PersistTabBehavior - Top
<TabControl 
    Grid.Row="1"
    DataContext="{Binding Tab2}"
    b:PersistTabBehavior.ItemsSource="{Binding Pages}"
    b:PersistTabBehavior.SelectedItem="{Binding SelectedPage}"
    ItemContainerStyle="{StaticResource PersistTabItemStyle}" />

As you can see, we have replaced ItemsSource and SelectedItem with the new attached properties: PersistTabBehavior.ItemsSource and PersistTabBehavior.SelectedItem. This is the beauty of attached behaviors. It is not necessary to create an extended TabControl class. We have attached our customized behavior to the original TabControl, and all the implementation is done in a separate class.

Part 1 - PersistTabBehavior.ItemsSource

Since the nature of attached properties is static, it gets complicated to manage all the events and objects when you have more than one TabControl, and that TabControl may be removed from the windows before the application exits. Therefore, I have created a separate class, PersistTabItemsSourceHandler, to handle that.

All the PersistTabItemsSourceHandler instances will be saved to the ItemSourceHandlers dictionary. And it will be disposed after the TabControl is unloaded from the UI.

private static readonly Dictionary<TabControl, PersistTabItemsSourceHandler> 
    ItemSourceHandlers = new Dictionary<TabControl, PersistTabItemsSourceHandler>();

PersistTabItemsSourceHandler

A new instance of PersistTabItemsSourceHandler will be created for each TabControl. This object is responsible for the following two tasks:

  • Add all TabItems when the TabControl is loaded on the screen.
  • Add or remove tab when the collection has changed.

Add all TabItems when the Tabcontrol is loaded

The tab loading logic will be handled by the PersistTabItemsSourceHandler object.

private void Load(IEnumerable sourceItems)
{
    Tab.Items.Clear();

    foreach (var page in sourceItems)
    AddTabItem(page);

    // If there is selected item,
    // select it after setting the initial tabitem collection
    SelectItem();
}

private void AddTabItem(object view)
{
    var contentControl = new ContentControl();
    contentControl.SetBinding(ContentControl.ContentProperty, new Binding());
    var item = new TabItem { DataContext = view, Content = contentControl };

    Tab.Items.Add(item);

    // When there is only 1 Item, the tab can't be rendered without have it selected
    // Don't do Refresh(). This may clear
    // the Selected item, causing issue in the ViewModel
    if (Tab.SelectedItem == null)
        item.IsSelected = true;
}

Adding and removing tab while changing the collection

If your enumerable object has implemented the INotifyPropertyChanged interface, PersistTabItemsSourceHandler will listen to the CollectionChanged event. It will keep the enumerable object in sync with the tab items in the TabControl.

In the demo, you can see how this works by clicking the Add Page and Remove Page buttons on top of the window.

private void AttachCollectionChangedEvent()
{
    var source = Tab.GetValue(PersistTabBehavior.ItemsSourceProperty) 
                              as INotifyCollectionChanged;

    // This property is not necessary to implement INotifyCollectionChanged.
    // Everything else will still work. We just can't add or remove tab.
    if (source == null)
        return;

    source.CollectionChanged += SourceCollectionChanged;
}

private void SourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    switch (e.Action)
    {
        case NotifyCollectionChangedAction.Add:
            foreach (var view in e.NewItems)
                AddTabItem(view);
            break;
        case NotifyCollectionChangedAction.Remove:
            foreach (var view in e.OldItems)
                RemoveTabItem(view);
            break;
    }
}

private void AddTabItem(object view)
{
    var contentControl = new ContentControl();
    contentControl.SetBinding(ContentControl.ContentProperty, new Binding());
    var item = new TabItem { DataContext = view, Content = contentControl };

    Tab.Items.Add(item);

    // When there is only 1 Item, the tab can't be rendered without have it selected
    // Don't do Refresh(). This may clear
    // the Selected item, causing issue in the ViewModel
    if (Tab.SelectedItem == null)
        item.IsSelected = true;
}

private void RemoveTabItem(object view)
{
    var foundItem = Tab.Items.Cast<tabitem />().FirstOrDefault(t => t.DataContext == view);

    if (foundItem != null)
        Tab.Items.Remove(foundItem);
}

The header

In a regular TabControl, you can simply override the ItemTemplate as follow, where Header is your string property that contains the name of the tab header.

<DataTemplate x:Key="templateForTheHeader" DataType="{x:Type vm:TabPageViewModel}">
    <TextBlock Text="{Binding Header}" />
</DataTemplate>

Unfortunately, you can't do this in PersistTabBehavior because you are not binding your ViewModel to the tab. You are now adding a real TabItem to the tab.

One of the solutions is to override the default template of the TabItem, and you can do the binding there. In this demo, I am using the default template from MSDN. In the ContentPresenter, I have assigned the Content to the Header property.

<ContentPresenter x:Name="ContentSite"
    VerticalAlignment="Center"
    HorizontalAlignment="Center"
    Content="{Binding Header}"
    Margin="12,2,12,2"
    RecognizesAccessKey="True"/>

There is quite a lot of style code you need to copy, but this is the best way I can think of.

Disposing the object

In order for the TabControl to be garbage collected after it is released from the UI, we have to make sure we clear all the references. Unlike Windows Forms controls, WPF controls have no Disposed event (because there is nothing to dispose). What we have to do is to listen for the Unloaded event. When the control is gone from the UI, this event will be triggered. At this point, we can dump our PersistTabItemsSourceHandler object.

private static void RemoveFromItemSourceHandlers(TabControl tabControl)
{
    if (!ItemSourceHandlers.ContainsKey(tabControl))
        return;

    ItemSourceHandlers[tabControl].Dispose();
    ItemSourceHandlers.Remove(tabControl);
}

public void Dispose()
{
    var source = Tab.GetValue(PersistTabBehavior.ItemsSourceProperty) 
                              as INotifyCollectionChanged;

    if (source != null)
        source.CollectionChanged -= SourceCollectionChanged;

    Tab = null;
}

Part 2 - PersistTabSelectedItemHandler

After I had completed PersistTabItemsSourceHandler, I thought I was done. However, I had missed one important point - the selected tab. Since we are adding a real TabItem to the TabControl, it won't work if you just bind your ViewModel to the SelectedItem property. The SelectedItem property will just give you the selected TabItem, but not the ViewModel sitting in the TabItem's DataContext.

In the demo, I have a SelectedPage property in the TabCotnrolViewModel. This is used to keep track of which tab is currently active. This item is also bound to the top left of the window. You can see the text changing when switching tabs.

public TabPageViewModel SelectedPage
{
    get { return selectedPage; }
    set
    {
        selectedPage = value;

        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs("SelectedPage"));
    }
}

TwoWay Binding

PersistTabSelectedItemHandler and PersistTabItemsSourceHandler are very similar. The main difference is that PersistTabSelectedItemHandler supports TwoWay binding. Which means that when the user selects a tab, the SelectedPage property will be updated. The other way around, when the SelectedTab property changes, the TabControl will activate the corresponding tab.

public void ChangeSelectionFromProperty()
{
    var selectedObject = Tab.GetValue(PersistTabBehavior.SelectedItemProperty);

    if (selectedObject == null)
    {
        Tab.SelectedItem = null;
        return;
    }

    foreach (TabItem tabItem in Tab.Items)
    {
        if (tabItem.DataContext == selectedObject)
        {
            if (!tabItem.IsSelected)
                tabItem.IsSelected = true;

            break;
        }
    }
}

private void ChangeSelectionFromUi(object sender, SelectionChangedEventArgs e)
{
    if (e.AddedItems.Count >= 1)
    {
        var selectedObject = e.AddedItems[0];

        var selectedItem = selectedObject as TabItem;

        if (selectedItem != null)
            SelectedItemProperty(selectedItem);
    }
}

private void SelectedItemProperty(TabItem selectedTabItem)
{
    var tabObjects = Tab.GetValue(PersistTabBehavior.ItemsSourceProperty) as IEnumerable;

    if (tabObjects == null)
        return;

    foreach (var tabObject in tabObjects)
    {
        if (tabObject == selectedTabItem.DataContext)
        {
            PersistTabBehavior.SetSelectedItem(Tab, tabObject);
            return;
        }
    }
}

Please notice that we have set FrameworkPropertyMetadata to the SelectedItemProperty. This will turn on TwoWay binding by default.

public static readonly DependencyProperty SelectedItemProperty =
            DependencyProperty.RegisterAttached(
            "SelectedItem", typeof(object), typeof(PersistTabBehavior),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                OnSelectedItemPropertyChanged));

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