切换 WPF TabControl 的标签时保留视觉树






4.76/5 (14投票s)
使用附加行为模式在 WPF 中创建 ViewModel 使用的选项卡持久化 TabControl。
引言
WPF TabControl
有两种不同的行为。当您将选项卡项从 ViewModel 绑定到 ItemsSource
属性时,TabControl
会在您每次切换选项卡时创建一个新的视觉树。但是,当您直接将 TabItem
添加到 Items
集合时,当选项卡不活动时,视觉树将被持久化。虽然数据绑定行为是预期的,但这确实会带来一些问题
- 渲染缓慢。当您有一个繁重的选项卡时,您会明显看到延迟。
- 视觉变化未持久化。如果您更改了选项卡中的列宽,在视觉树重新创建后,您会丢失这些更改。
在本文中,我假设您对 MVVM 和附加属性有基本的了解。您可以在互联网上找到大量关于这些主题的资料。要了解附加行为是什么,请查看 Josh Smith 的文章。
解决方法
不要使用 ItemsSource
。而是有一个包装器将您的源转换为 TabItem
并将其添加到 TabControl
。在这种情况下,您仍然可以获得数据绑定的所有优点,同时保留所需的行为。
这个想法很简单,有很多实现方式。您可以扩展 TabControl
类,或者直接在代码隐藏中完成。我的做法是使用附加行为模式。这样,我们可以将代码与 XAML 和代码隐藏分开,这意味着它可以重用。此外,将其作为附加模式比覆盖 TabControl
类更灵活。
演示
本次演示的目的是展示使用 PersistTabBehavior
和原始 ItemsSource
之间的区别。
此演示包含一个带有两个 TabControl
的 WPF 窗口。每个 TabControl
有两个选项卡,每个选项卡都有一个大的 DataGrid
以便减慢渲染速度。顶部的控件使用了本文介绍的附加属性。底部的控件只是一个普通的 TabControl
。您可以看到在切换选项卡时,顶部 TabControl
的性能更好。
下面是 XAML 中两个控件的区别。
<TabControl
Grid.Row="2"
DataContext="{Binding Tab1}"
ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
ItemTemplate="{StaticResource templateForTheHeader}" />
<TabControl
Grid.Row="1"
DataContext="{Binding Tab2}"
b:PersistTabBehavior.ItemsSource="{Binding Pages}"
b:PersistTabBehavior.SelectedItem="{Binding SelectedPage}"
ItemContainerStyle="{StaticResource PersistTabItemStyle}" />
正如您所见,我们已将 ItemsSource
和 SelectedItem
替换为新的附加属性:PersistTabBehavior.ItemsSource
和 PersistTabBehavior.SelectedItem
。这就是附加行为的美妙之处。无需创建扩展的 TabControl
类。我们将自定义行为附加到了原始 TabControl
,并且所有实现都在一个单独的类中完成。
第一部分 - PersistTabBehavior.ItemsSource
由于附加属性的本质是静态的,当您有多个 TabControl
并且这些 TabControl
可能在应用程序退出之前从窗口中移除时,管理所有事件和对象会变得很复杂。因此,我创建了一个单独的类 PersistTabItemsSourceHandler
来处理这些问题。
所有 PersistTabItemsSourceHandler
实例都将保存在 ItemSourceHandlers
字典中。并在 TabControl
从 UI 中卸载后将其处置。
private static readonly Dictionary<TabControl, PersistTabItemsSourceHandler>
ItemSourceHandlers = new Dictionary<TabControl, PersistTabItemsSourceHandler>();
PersistTabItemsSourceHandler
每个 TabControl
都会创建一个新的 PersistTabItemsSourceHandler
实例。此对象负责以下两项任务:
- 当
TabControl
加载到屏幕上时,添加所有TabItem
。 - 当集合发生变化时,添加或删除选项卡。
TabControl 加载时添加所有 TabItem
选项卡加载逻辑将由 PersistTabItemsSourceHandler
对象处理。
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;
}
更改集合时添加和删除选项卡
如果您的可枚举对象实现了 INotifyPropertyChanged
接口,PersistTabItemsSourceHandler
将监听 CollectionChanged
事件。它将使可枚举对象与 TabControl
中的选项卡项保持同步。
在演示中,您可以通过点击窗口顶部的 **添加页面** 和 **删除页面** 按钮来查看此功能。
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);
}
标题
在常规的 TabControl
中,您可以像下面这样简单地覆盖 ItemTemplate
,其中 **Header** 是包含选项卡标题名称的字符串属性。
<DataTemplate x:Key="templateForTheHeader" DataType="{x:Type vm:TabPageViewModel}">
<TextBlock Text="{Binding Header}" />
</DataTemplate>
不幸的是,您不能在 PersistTabBehavior
中这样做,因为您没有将 ViewModel 绑定到选项卡。您现在添加的是真正的 TabItem
到选项卡。
一种解决方案是覆盖 TabItem
的默认模板,您可以在那里进行绑定。在此演示中,我使用了 MSDN 的默认模板。在 ContentPresenter
中,我已将 Content
分配给了 Header
属性。
<ContentPresenter x:Name="ContentSite"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Content="{Binding Header}"
Margin="12,2,12,2"
RecognizesAccessKey="True"/>
需要复制大量的样式代码,但这是我能想到的最好的方法。
处置对象
为了使 TabControl
在从 UI 中释放后能够被垃圾回收,我们必须确保清除所有引用。与 Windows Forms 控件不同,WPF 控件没有 Disposed
事件(因为没有需要处置的东西)。我们所要做的就是监听 Unloaded
事件。当控件从 UI 中消失时,将触发此事件。此时,我们可以丢弃我们的 PersistTabItemsSourceHandler
对象。
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;
}
第二部分 - PersistTabSelectedItemHandler
在完成 PersistTabItemsSourceHandler
后,我认为我完成了。然而,我忽略了一个重要的问题——选中的选项卡。由于我们将真正的 TabItem
添加到 TabControl
,如果您只是将 ViewModel 绑定到 SelectedItem
属性,它将无法工作。SelectedItem
属性只会给出选中的 TabItem
,而不会给出位于 TabItem
的 DataContext
中的 ViewModel。
在演示中,我在 TabCotnrolViewModel
中有一个 SelectedPage
属性。这用于跟踪当前活动的选项卡。此项也绑定到窗口的左上方。您可以看到切换选项卡时文本会发生变化。
public TabPageViewModel SelectedPage
{
get { return selectedPage; }
set
{
selectedPage = value;
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs("SelectedPage"));
}
}
双向绑定
PersistTabSelectedItemHandler
和 PersistTabItemsSourceHandler
非常相似。主要区别在于 PersistTabSelectedItemHandler
支持双向绑定。这意味着当用户选择一个选项卡时,SelectedPage
属性将被更新。反之,当 SelectedTab
属性更改时,TabControl
将激活相应的选项卡。
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;
}
}
}
请注意,我们已将 FrameworkPropertyMetadata
设置为 SelectedItemProperty
。这将默认启用双向绑定。
public static readonly DependencyProperty SelectedItemProperty =
DependencyProperty.RegisterAttached(
"SelectedItem", typeof(object), typeof(PersistTabBehavior),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemPropertyChanged));