实现TabControl标签页缓存

TabControl Content Preservation

Posted by eagleboost on June 17, 2019

The problem

Usually WPF developers use the TabControl in two ways:

  1. Hard code TabItems in the XAML with a specific UI controls as the content. Each TabItem can have its own different content:
1
2
3
4
5
6
7
8
9
10
<TabControl>
  <TabControl.Items>
    <TabItem Header="Tab 1">
      <TextBlock Text="Tab Item Content 1"/>
    </TabItem>
    <TabItem Header="Tab 2">
      <TextBlock Text="Tab Item Content 2"/>
    </TabItem>
  </TabControl.Items>
</TabControl>
  1. Instead of hard code the UI content, bind or set ItemsSource to a collection and let the control generate content based on ContentTemplate/ContentTemplateSelector
1
2
3
4
5
6
7
8
9
10
11
12
<TabControl ItemsSource="{Binding DataSource}">
  <TabControl.ItemTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Header}"/>
    </DataTemplate>
  </TabControl.ItemTemplate>
  <TabControl.ContentTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Content}"/>
    </DataTemplate>
  </TabControl.ContentTemplate>  
</TabControl>

There’s a difference between the two modes by design:

  1. directly set the Content property of the TabItem, thus the content would be created and cached. When switching to another tab, the associated TabItem’s Content would replace the TabControl.SelectedContent and be rendered immediately, no delays.
  2. With ContentTemplate/ContentTemplateSelector kicks in, the TabItem’s Content property it the data item, and TabControl.SelectedContent would also be the data item first, and the UI content would be re-generated by ContentTemplate/ContentTemplateSelector of the ContentPresenter every time TabControl.SelectedContent is set. This may not be what we want sometimes because when the template is complex, it would be slow if tab switching happens frequently.

#2 has been indeed a problem as most of the time developers would choose to drive the TabControl content by data template, however there’s no way provided by TabControl to solve this problem.

Solutions out there are many. Third party control libraries is one option, like Telerik RadTabControl has a IsContentPreserved property, other ways like subclass TabControl also works but not idea, as it would not work for existing controls derived from TabControl (derive from WPF controls is usually not a good practice but that’s the reality).

This article demonstrates a non-intrusive and simple solution to this problem, let’s get started:

Analysis

If you first hard code UI content for the TabItems and use Snoop to inspect the Visual Tree, you can see that the TabItem.Content is the UI control already; Do the same for the ContentTemplate way you’ll find out TabItem.Content is the data item.

So if we can somehow generate the UI control based on the ContentTemplate/ContentTemplateSelector and set it to TabItem.Content in advance and make the TabControl skip the process of applying template, the problem would be solved. Of cause we still want the usage to be as simple as:

1
2
3
<TabControl ItemsSource="{Binding ...}" TabContentPreservation.IsContentPreserved="True">
  ...
</TabControl>

It is exactly the case as the rest of this article shows.

Solution

Let’s start with writing an attached behavior TabContentPreservation with only one attached boolean property IsContentPreserved and a value changed callback to create a TabContentManager which will do the real work that we’ll be discussing next.

Note: codes are simplified for demo purpose, please read the source code for complete implementations.

1
2
3
4
5
6
7
8
9
10
private static void OnIsContentPreservedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
  var tc = obj as TabControl;
  if ((bool)e.NewValue)
  {
    var manager = new TabContentManager(tc);
    manager.Start();
    SetTabContentManager(tc, manager);
  }
}

The first thing TabContentManager does is handling the DataContextChanged event of the tab control for preparations, see comments (again codes are simplified for demonstration purpose).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void HandleTabDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
  var tc = (TabControl)sender;
  var coll = (INotifyCollectionChanged)tc.Items;
  if (e.NewValue != null)
  {
    if (tc.Items.Count > 0)
    {
      ////There's Items already, which means the TabControl already has UI controls, we stop here
      var firstTab = tc.Items[0] as DependencyObject;
      if (firstTab != null)
      {
        throw new InvalidOperationException(string.Format("Content type of {0} is already preserved", tc.Items[0]));
      }
    }

    ////Save a copy of the ContentTemplate and clear it from the TabControl, so the TabControl would not use it generate the content
    _contentTemplate = tc.ContentTemplate;
    tc.ContentTemplate = null;

    ////SelectionChanged event is where we're going to do one-time realization the UI content for each tab when it becomes selected
    tc.SelectionChanged += HandleTabSelectionChanged;

    ////We also need to handle CollectionChanged in case items are removed/cleared in the TabControl.Items
    coll.CollectionChanged += HandleDataItemCollectionChanged;
  }
}

There’s no need to realize the content for all of the tabs immediately, we delay the creation of the UI content to the first time the tab is selected and only create the UI content once per tab.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void HandleTabSelectionChanged(object sender, SelectionChangedEventArgs e)
{
  var tc = (TabControl)sender;

  if (e.AddedItems.Count > 0)
  {
    var dataItem = e.AddedItems[0];
    var tabItem = (TabItem)tc.ItemContainerGenerator.ContainerFromItem(dataItem);
    if (tabItem != null)
    {
      ////Check to see if we already have a realized UI content, if not, create one
      var contentControl = GetRealizedContent(dataItem);
      if (contentControl == null)
      {
        ////This is where we do the job for the TabControl, while TabControl reuse the same content presenter for all of the tabs, we create and cache the ContentControl for each tab
        var template = _contentTemplate;
        if (template != null)
        {
          contentControl = new ContentControl
          {
            DataContext = dataItem,
            ContentTemplate = template,
            ContentTemplateSelector = tc.ContentTemplateSelector
          };

          contentControl.SetBinding(ContentControl.ContentProperty, new Binding());
          
          SetRealizedContent(dataItem, contentControl);
          ////Delay a little bit to update TabItem.Content to the realized UI content
          tc.Dispatcher.BeginInvoke((Action)(() => tabItem.Content = contentControl));
        }
      }
    }
  }
}

The CollectionChanged handler part is quite simple, it’s just to update the cache when items are removed/cleared (added is ignored because we want HandleTabSelectionChanged to handle it, after all a tab maybe added but never gets selected by the user), please refer to the codes for more details.

Conclusion

It would be great if TabControl can provide this functionality out of box but it’s also understandable that its behavior covers most of the usages, for more complex scenarios people might as well be using third party control libraries anyway, with those libraries, advanced features like Tab content caching are likely to be included. The up side is that the WPF framework is designed to be so flexible that developers can always come up with tactical solutions like this, and they’re also elegant enough.

问题

一般来说TabControl有两种用法:

  1. 在XAML里直接把TabItem添加到TabControl.Items,再把要显示的控件作为TabItem的内容。
1
2
3
4
5
6
7
8
9
10
<TabControl>
  <TabControl.Items>
    <TabItem Header="Tab 1">
      <TextBlock Text="Tab Item Content 1"/>
    </TabItem>
      <TabItem Header="Tab 2">
    <TextBlock Text="Tab Item Content 2"/>
    </TabItem>
  </TabControl.Items>
</TabControl>
  1. M-V-VM的编程模式下则通常把TabControl的ItemsSource属性绑定到数据源,再用ContentTemplateContentTemplateSelector根据数据项生成UI上的控件,不同的数据项(可以是类型不同,也可以是属性不同)可以映射到不同的模板,最终生成的控件也不同。
1
2
3
4
5
6
7
8
9
10
11
12
<TabControl ItemsSource="{Binding DataSource}">
  <TabControl.ItemTemplate>
    <DataTemplate>
     <TextBlock Text="{Binding Header}"/>
    </DataTemplate>
  </TabControl.ItemTemplate>
  <TabControl.ContentTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Content}"/>
    </DataTemplate>
  </TabControl.ContentTemplate>  
</TabControl>

虽然看起来差不多,然而就TabControl的设计来说,这两种方式存在一个最大的不同:

  1. 在XAML里直接签加控件作为TabItem的内容以后,控件会在所属的TabItem第一次激活时被创建并且缓存在所属TabItem的Content属性里。当同一个TabItem再次被激活的时候,它Content属性的内容就会被直接用来替换TabControl.SelectedContent,从而立刻显示出来。
  2. 在使用ContentTemplate/ContentTemplateSelector的情况下, TabItem的内容不再是控件而变成了数据项,TabControl.SelectedContent也首先会是数据项,TabControl会在SelectedContent属性每次改变的时候根据ContentTemplate/ContentTemplateSelector重新按模板生成控件。模板简单的时候这种方式问题不大,但如果模板很复杂,每次重新创建控件都花费较长时间的话用户体验就会大打折扣。

事实上#2在真实开发场景中用得做多。M-V-VM作为开发模式的首选,通过绑定加上数据映射来生成界面再自然不过。遗憾的是TabControl并没有提供在使用模板的情况下缓存控件的机制。

解决的办法不是没有。常见方案一是用第三方控件,比如著名开发商Telerik的RadTabControl就提供了一个IsContentPreserved 属性来实现这种行为。通过继承来改写TabControl的行为也是一个方案,实现不难,但问题是对于已有的那些从TabControl继承的控件没法重用(继承WPF的控件本身不是好的实践,我不推荐,但实践中常常发生)。

本文以下内容给出一种简单并且易于重用的方式来解决这个问题。

分析

假如用Snoop来查看第一种方式生成的Visual Tree,你会发现当控件加载完成以后每个TabItem的Content属性就已经是XAML里写的控件了,但查看第二种方式的话TabItem的Content则是数据项,模板只会套用在当前激活的那个TabItem上。

很直观的想法是,在第二种方式下,如果能预先根据数据项相应的模板生成控件并设置给TabItem.Content,并使得TabControl跳过套用模板的过程直接显示控件,就能达到目的。事实也是如此。

当然使用方式也应该足够简单,能用在任何TabControl上,如下所示:

1
2
3
<TabControl ItemsSource="{Binding ...}" TabContentPreservation.IsContentPreserved="True">
  ...
</TabControl>

解决方案

先写一个叫TabContentPreservation的类,它只需要包含一个附加属性IsContentPreserved,在该附加属性的值变化回调函数里我们再创建TabContentManager类的一个实例来做具体的事。

: 为简化讨论以下代码有删减,完整实现请移步github

1
2
3
4
5
6
7
8
9
10
private static void OnIsContentPreservedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
  var tc = obj as TabControl;
  if ((bool)e.NewValue)
  {
    var manager = new TabContentManager(tc);
    manager.Start();
    SetTabContentManager(tc, manager);
  }
}

TabContentManager首先侦听TabControl的DataContextChanged事件来做一些准备工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void HandleTabDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
  var tc = (TabControl)sender;
  var coll = (INotifyCollectionChanged)tc.Items;
  if (e.NewValue != null)
  {
    if (tc.Items.Count > 0)
    {
      ////Items属性已经有内容,也就是说TabItem.Content已经在XAML里有控件,同学你用错场景了
      var firstTab = tc.Items[0] as DependencyObject;
      if (firstTab != null)
      {
        throw new InvalidOperationException(string.Format("Content type of {0} is already preserved", tc.Items[0]));
      }
    }

    ////把TabControl的ContentTemplate保存下来并清除原来的引用,这样一来TabControl就跳过套用模板的过程。
    _contentTemplate = tc.ContentTemplate;
    tc.ContentTemplate = null;

    ////侦听SelectionChanged并对每个被激活的TabItem创建内容控件。
    tc.SelectionChanged += HandleTabSelectionChanged;

    ////也需要侦听CollectionChanged事件,当TabControl.Items中数据项发生删除/清除的时候清除数据项相应的缓存。
    coll.CollectionChanged += HandleDataItemCollectionChanged;
  }
}

尽管TabControl.Items中可能有多个数据项,但控件的创建可以延迟到当TabItem第一次被激活的时候。这样的话如果某些TabItem从来没有激活则无需耗费内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void HandleTabSelectionChanged(object sender, SelectionChangedEventArgs e)
{
  var tc = (TabControl)sender;

  if (e.AddedItems.Count > 0)
  {
    var dataItem = e.AddedItems[0];
    var tabItem = (TabItem)tc.ItemContainerGenerator.ContainerFromItem(dataItem);
    if (tabItem != null)
    {
      ////如果相应控件不存在则创建一次
      var contentControl = GetRealizedContent(dataItem);
      if (contentControl == null)
      {
        ////模仿TabControl的行为。TabControl每次重用同一个ContentPresenter来套用模板,我们为每个TabItem创建一个ContentControl来套用模板
        var template = _contentTemplate;
        if (template != null)
        {
          contentControl = new ContentControl
          {
            DataContext = dataItem,
            ContentTemplate = template,
            ContentTemplateSelector = tc.ContentTemplateSelector
          };

          contentControl.SetBinding(ContentControl.ContentProperty, new Binding());
          
          SetRealizedContent(dataItem, contentControl);
          ////延迟到TabControl完成其余操作后把TabItem.Content更新为缓存好的控件
          tc.Dispatcher.BeginInvoke((Action)(() => tabItem.Content = contentControl));
        }
      }
    }
  }
}

CollectionChanged事件处理很简单,当数据项发生删除或清除的时候把缓存的控件也清除,具体实现请移步github

结论

虽然TabControl并未直接支持此行为,但也容易理解,毕竟大多数使用场景已经涵盖,更专业和更复杂的场景很大概率会使用第三方组件库。而第三方组件库提供的额外功能中大都支持在使用模板的情况下缓存TabItem的内容。好在WPF框架设计得足够灵活,使得我们很容易就能写出本文讨论的方法来解决相关问题。