0

I have a MVVM setup with TabControl and an ObservableCollection<ViewModel> of tabitems.

I open a file and load the model made of that file in a TabItem:

var model = new ViewModel(data, filename);
ViewModels.Tabs.Add(model);

The TabItem has a DataTemplate for it's Header and Content.
The Contentis defined in a separate UserControl, the Headeris in the main file itself.

When I run the header is shown, but only when I click on the header, the loaded event of the tabitem fires and the content is displayed.

I expected that it would load immediately, why not?

When I add two tabs at the same time:

var model = new ViewModel(data, filename);
ViewModels.Tabs.Add(model);
ViewModels.Tabs.Add(model);

then the loaded event of the first tab does fire and it's content is shown.

How can I achieve the desired behavior?

Gerard
  • 13,023
  • 14
  • 72
  • 125
  • This is a virtualization thing. The other usual problem with this is that visual state is lost when a TabItem is unselected. The solution is to get rid of virtualization; in effect, write code so that when you bind an `ObservableCollection` to `ItemsSource`, it'll add a `TabItem` for each item in the collection. There's a version on codeproject that I can't personally vouch for: http://stackoverflow.com/a/36209166/424129 – 15ee8f99-57ff-4f92-890c-b56153 Sep 23 '16 at 13:37
  • Correction: I *can* vouch for it personally. I remembered having done the same last year, just checked that project, and it turns out I simply used his code. It works fine. – 15ee8f99-57ff-4f92-890c-b56153 Sep 23 '16 at 13:39
  • I see, complicated stuff. Well I probably just add `ViewModels.Tabs.Add(null); ViewModels.Tabs.Remove(null);` as a workaround. – Gerard Sep 23 '16 at 13:47

2 Answers2

2

N.B. AddValueChanged is a memory leak, which I wasn't aware of when I wrote this answer. Depending on your situation, it might be wise to omit EnsureContentTemplateIsNotModified from the code below. I don't have time at the moment to fix and re-test that change, so I'm leaving it as it is. See eih's comment below for more detail.

This is due to virtualization. As with any Selector subclass, only the visible items actually exist. And in a TabControl, the only visible item is the selected one. I don't think that was an ideal design choice for most common uses of a tab control, but here we are.

The best fix I've found is to add an attached property which steps in and creates an actual TabItem for each item in ItemsSource. From this woefully unappreciated answer, I found this CodeProject thing by Ivan Krivyakov. I've used it and it works.

<TabControl
    xmlns:ikriv="clr-namespace:IKriv.Windows.Controls.Behaviors"
    ikriv:TabContent.IsCached="True"

It's 285 lines of C# code, but things on the internet go away. Here it is:

// TabContent.cs, version 1.2
// The code in this file is Copyright (c) Ivan Krivyakov
// See http://www.ikriv.com/legal.php for more information
//
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Markup;

/// <summary>
/// http://www.codeproject.com/Articles/460989/WPF-TabControl-Turning-Off-Tab-Virtualization
/// </summary>
namespace IKriv.Windows.Controls.Behaviors
{
    /// <summary>
    /// Attached properties for persistent tab control
    /// </summary>
    /// <remarks>By default WPF TabControl bound to an ItemsSource destroys visual state of invisible tabs. 
    /// Set ikriv:TabContent.IsCached="True" to preserve visual state of each tab.
    /// </remarks>
    public static class TabContent
    {
        public static bool GetIsCached(DependencyObject obj)
        {
            return (bool)obj.GetValue(IsCachedProperty);
        }

        public static void SetIsCached(DependencyObject obj, bool value)
        {
            obj.SetValue(IsCachedProperty, value);
        }

        /// <summary>
        /// Controls whether tab content is cached or not
        /// </summary>
        /// <remarks>When TabContent.IsCached is true, visual state of each tab is preserved (cached), even when the tab is hidden</remarks>
        public static readonly DependencyProperty IsCachedProperty =
            DependencyProperty.RegisterAttached("IsCached", typeof(bool), typeof(TabContent), new UIPropertyMetadata(false, OnIsCachedChanged));


        public static DataTemplate GetTemplate(DependencyObject obj)
        {
            return (DataTemplate)obj.GetValue(TemplateProperty);
        }

        public static void SetTemplate(DependencyObject obj, DataTemplate value)
        {
            obj.SetValue(TemplateProperty, value);
        }

        /// <summary>
        /// Used instead of TabControl.ContentTemplate for cached tabs
        /// </summary>
        public static readonly DependencyProperty TemplateProperty =
            DependencyProperty.RegisterAttached("Template", typeof(DataTemplate), typeof(TabContent), new UIPropertyMetadata(null));


        public static DataTemplateSelector GetTemplateSelector(DependencyObject obj)
        {
            return (DataTemplateSelector)obj.GetValue(TemplateSelectorProperty);
        }

        public static void SetTemplateSelector(DependencyObject obj, DataTemplateSelector value)
        {
            obj.SetValue(TemplateSelectorProperty, value);
        }

        /// <summary>
        /// Used instead of TabControl.ContentTemplateSelector for cached tabs
        /// </summary>
        public static readonly DependencyProperty TemplateSelectorProperty =
            DependencyProperty.RegisterAttached("TemplateSelector", typeof(DataTemplateSelector), typeof(TabContent), new UIPropertyMetadata(null));

        [EditorBrowsable(EditorBrowsableState.Never)]
        public static TabControl GetInternalTabControl(DependencyObject obj)
        {
            return (TabControl)obj.GetValue(InternalTabControlProperty);
        }

        [EditorBrowsable(EditorBrowsableState.Never)]
        public static void SetInternalTabControl(DependencyObject obj, TabControl value)
        {
            obj.SetValue(InternalTabControlProperty, value);
        }

        // Using a DependencyProperty as the backing store for InternalTabControl.  This enables animation, styling, binding, etc...
        [EditorBrowsable(EditorBrowsableState.Never)]
        public static readonly DependencyProperty InternalTabControlProperty =
            DependencyProperty.RegisterAttached("InternalTabControl", typeof(TabControl), typeof(TabContent), new UIPropertyMetadata(null, OnInternalTabControlChanged));


        [EditorBrowsable(EditorBrowsableState.Never)]
        public static ContentControl GetInternalCachedContent(DependencyObject obj)
        {
            return (ContentControl)obj.GetValue(InternalCachedContentProperty);
        }

        [EditorBrowsable(EditorBrowsableState.Never)]
        public static void SetInternalCachedContent(DependencyObject obj, ContentControl value)
        {
            obj.SetValue(InternalCachedContentProperty, value);
        }

        // Using a DependencyProperty as the backing store for InternalCachedContent.  This enables animation, styling, binding, etc...
        [EditorBrowsable(EditorBrowsableState.Never)]
        public static readonly DependencyProperty InternalCachedContentProperty =
            DependencyProperty.RegisterAttached("InternalCachedContent", typeof(ContentControl), typeof(TabContent), new UIPropertyMetadata(null));

        [EditorBrowsable(EditorBrowsableState.Never)]
        public static object GetInternalContentManager(DependencyObject obj)
        {
            return (object)obj.GetValue(InternalContentManagerProperty);
        }

        [EditorBrowsable(EditorBrowsableState.Never)]
        public static void SetInternalContentManager(DependencyObject obj, object value)
        {
            obj.SetValue(InternalContentManagerProperty, value);
        }

        // Using a DependencyProperty as the backing store for InternalContentManager.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty InternalContentManagerProperty =
            DependencyProperty.RegisterAttached("InternalContentManager", typeof(object), typeof(TabContent), new UIPropertyMetadata(null));

        private static void OnIsCachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (obj == null) return;

            var tabControl = obj as TabControl;
            if (tabControl == null)
            {
                throw new InvalidOperationException("Cannot set TabContent.IsCached on object of type " + args.NewValue.GetType().Name +
                    ". Only objects of type TabControl can have TabContent.IsCached property.");
            }

            bool newValue = (bool)args.NewValue;

            if (!newValue)
            {
                if (args.OldValue != null && ((bool)args.OldValue))
                {
                    throw new NotImplementedException("Cannot change TabContent.IsCached from True to False. Turning tab caching off is not implemented");
                }

                return;
            }

            EnsureContentTemplateIsNull(tabControl);
            tabControl.ContentTemplate = CreateContentTemplate();
            EnsureContentTemplateIsNotModified(tabControl);
        }

        private static DataTemplate CreateContentTemplate()
        {
            const string xaml =
                "<DataTemplate><Border b:TabContent.InternalTabControl=\"{Binding RelativeSource={RelativeSource AncestorType=TabControl}}\" /></DataTemplate>";

            var context = new ParserContext();

            context.XamlTypeMapper = new XamlTypeMapper(new string[0]);
            context.XamlTypeMapper.AddMappingProcessingInstruction("b", typeof(TabContent).Namespace, typeof(TabContent).Assembly.FullName);

            context.XmlnsDictionary.Add("", "http://schemas.microsoft.com/winfx/2006/xaml/presentation");
            context.XmlnsDictionary.Add("b", "b");

            var template = (DataTemplate)XamlReader.Parse(xaml, context);
            return template;
        }

        private static void OnInternalTabControlChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (obj == null) return;
            var container = obj as Decorator;

            if (container == null)
            {
                var message = "Cannot set TabContent.InternalTabControl on object of type " + obj.GetType().Name +
                    ". Only controls that derive from Decorator, such as Border can have a TabContent.InternalTabControl.";
                throw new InvalidOperationException(message);
            }

            if (args.NewValue == null) return;
            if (!(args.NewValue is TabControl))
            {
                throw new InvalidOperationException("Value of TabContent.InternalTabControl cannot be of type " + args.NewValue.GetType().Name +", it must be of type TabControl");
            }

            var tabControl = (TabControl)args.NewValue;
            var contentManager = GetContentManager(tabControl, container);
            contentManager.UpdateSelectedTab();
        }

        private static ContentManager GetContentManager(TabControl tabControl, Decorator container)
        {
            var contentManager = (ContentManager)GetInternalContentManager(tabControl);
            if (contentManager != null)
            {
                /*
                 * Content manager already exists for the tab control. This means that tab content template is applied 
                 * again, and new instance of the Border control (container) has been created. The old container 
                 * referenced by the content manager is no longer visible and needs to be replaced
                 */
                contentManager.ReplaceContainer(container);
            }
            else
            {
                // create content manager for the first time
                contentManager = new ContentManager(tabControl, container);
                SetInternalContentManager(tabControl, contentManager);
            }

            return contentManager;
        }

        private static void EnsureContentTemplateIsNull(TabControl tabControl)
        {
            if (tabControl.ContentTemplate != null)
            {
                throw new InvalidOperationException("TabControl.ContentTemplate value is not null. If TabContent.IsCached is True, use TabContent.Template instead of ContentTemplate");
            }
        }

        private static void EnsureContentTemplateIsNotModified(TabControl tabControl)
        {
            var descriptor = DependencyPropertyDescriptor.FromProperty(TabControl.ContentTemplateProperty, typeof(TabControl));
            descriptor.AddValueChanged(tabControl, (sender, args) =>
                {
                    throw new InvalidOperationException("Cannot assign to TabControl.ContentTemplate when TabContent.IsCached is True. Use TabContent.Template instead");
                });
        }

        public class ContentManager
        {
            TabControl _tabControl;
            Decorator _border;

            public ContentManager(TabControl tabControl, Decorator border)
            {
                _tabControl = tabControl;
                _border = border;
                _tabControl.SelectionChanged += (sender, args) => { UpdateSelectedTab(); };
            }

            public void ReplaceContainer(Decorator newBorder)
            {
                if (Object.ReferenceEquals(_border, newBorder)) return;

                _border.Child = null; // detach any tab content that old border may hold
                _border = newBorder;
            }

            public void UpdateSelectedTab()
            {
                _border.Child = GetCurrentContent();
            }

            private ContentControl GetCurrentContent()
            {
                var item = _tabControl.SelectedItem;
                if (item == null) return null;

                var tabItem = _tabControl.ItemContainerGenerator.ContainerFromItem(item);
                if (tabItem == null) return null;

                var cachedContent = TabContent.GetInternalCachedContent(tabItem);
                if (cachedContent == null)
                {
                    cachedContent = new ContentControl 
                    { 
                        DataContext = item,
                        ContentTemplate = TabContent.GetTemplate(_tabControl), 
                        ContentTemplateSelector = TabContent.GetTemplateSelector(_tabControl)
                    };

                    cachedContent.SetBinding(ContentControl.ContentProperty, new Binding());
                    TabContent.SetInternalCachedContent(tabItem, cachedContent);
                }

                return cachedContent;
            }
        }
    }
}
  • I think this is a wonderful solution and it helped some performance issues I was facing. I did however discover a memory leak that prevented the TabControl instances using this behavior from being collected. Using .Net Memory Profiler I tracked it down to what happens in EnsureContentTemplateIsNotModified. Search for AddValueChanged and you will find warnings about this. You could resolve this by detaching the event at the right time or using Weak Event Pattern, but I just removed the method as it felt superfluous. – eih Feb 05 '20 at 07:17
  • @eih Yes, since I posted (and forgot) this answer, we've made the same discovery about AddValueChanged in our own application. I'll update the answer. Thanks. – 15ee8f99-57ff-4f92-890c-b56153 Feb 05 '20 at 13:41
0

To achieve what you want you must bind to SelectedItem property of TabControl and in your viewmodel you should have that pointing to whatever element of your collection. It should look like this: XAML

<TabControl ItemsSource="{Binding Items}" SelectedItem="{Binding Item}">
</TabControl>

ViewModel

public ViewModel() {
       SelectedItem = Items.First();
    }

public ObservableCollection<Item> Items { get; set; } = new ObservableCollection<Item> {
        new Item("test1", 5),
        new Item("test2", 2)
    };

public Item SelectedItem { get; set; } //don't forget to implement ChangeNotifications for it
3615
  • 3,787
  • 3
  • 20
  • 35
  • I already did this and it works for the selection. But again, in my case at least, the tabitem is not loaded until I click on the header. – Gerard Sep 23 '16 at 20:29
  • Not sure I understand what you mean. If you binding to SelectedItem is setup correctly it should show you immediatly selected tab, without the need to click on it's header. Try to create a new WPF project and test it there. Maybe there is something else in your code that prevents loading... – 3615 Sep 23 '16 at 20:32
  • What I mean is, that although the selected tab is indeed shown immediately, without the need to click on it's header, the content of the rest of the tab control is empty. I have to click on the header to load that content. So try it out when your tabitem has content like datagrids, whether these datagrids are shown immediately? – Gerard Oct 04 '16 at 09:28
  • Well, if you have some complex content, that for example requires explicit initialization that could be a problem, so you have to initialize it before setting it as SelectedItem. From your example code there is no way to know about these problems. What I've managed to reproduce is [this](http://screencast.com/t/g9LNRdeptTd). Where it clearly shows that if SelectedItem is not binded correctly, tab control indeed has no tab selected. – 3615 Oct 04 '16 at 10:04