65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (14投票s)

2011年6月16日

CPOL

5分钟阅读

viewsIcon

90182

downloadIcon

3141

使用附加行为模式在 WPF 中创建 ViewModel 使用的选项卡持久化 TabControl。

Sample Image

引言

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 - 底部
<TabControl 
    Grid.Row="2"
    DataContext="{Binding Tab1}"
    ItemsSource="{Binding Pages}"
    SelectedItem="{Binding SelectedPage}"
    ItemTemplate="{StaticResource templateForTheHeader}" />
带有 PersistTabBehavior 的 TabControl - 顶部
<TabControl 
    Grid.Row="1"
    DataContext="{Binding Tab2}"
    b:PersistTabBehavior.ItemsSource="{Binding Pages}"
    b:PersistTabBehavior.SelectedItem="{Binding SelectedPage}"
    ItemContainerStyle="{StaticResource PersistTabItemStyle}" />

正如您所见,我们已将 ItemsSourceSelectedItem 替换为新的附加属性:PersistTabBehavior.ItemsSourcePersistTabBehavior.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,而不会给出位于 TabItemDataContext 中的 ViewModel。

在演示中,我在 TabCotnrolViewModel 中有一个 SelectedPage 属性。这用于跟踪当前活动的选项卡。此项也绑定到窗口的左上方。您可以看到切换选项卡时文本会发生变化。

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

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

双向绑定

PersistTabSelectedItemHandlerPersistTabItemsSourceHandler 非常相似。主要区别在于 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));
© . All rights reserved.