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 TabControl
s. 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
TabItem
s 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);
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);
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;
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);
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));