0

I'm refactoring some code at the moment, and am attempting to make a custom 'TabControl'. Ideally I would just style the built-in one but there's some quirks to our codebase and I have to keep the existing behaviour.

I've simplified the example as much as I can. TabControl displays the content of the first TabItem (which is always a UI element) in its content presenter. The content renders as expected, but is not able to access the data context. Can anyone explain why?

My searches so far have found answers that talk about elements not belonging to the logical tree or the visual tree, or to try explicitly setting the DataContext of the content presenter. These answers haven't worked for me.

MainWindow.xaml

<Window x:Class="WpfControls.MainWindow" ...>
    <StackPanel>
        <!-- The binding works as expected here -->
        <TextBlock Text="{Binding Example}" />
        <local:TabControl>
            <local:TabItem>
                <StackPanel>
                    <TextBlock>Some tab content</TextBlock>
                    <!-- The data context is always null here for some reason -->
                    <TextBlock Text="{Binding Example}" />
                </StackPanel>
            </local:TabItem>
            <local:TabItem>
                <TextBlock>Some other tab content</TextBlock>
            </local:TabItem>
        </local:TabControl>
    </StackPanel>
</Window>

TabControl.xaml

<UserControl x:Class="WpfControls.Controls.TabControl" ...>
    <UserControl.ContentTemplate>
        <DataTemplate>
            <ContentPresenter Content="{Binding SelectedContent, RelativeSource={RelativeSource AncestorType=UserControl}}" />
        </DataTemplate>
    </UserControl.ContentTemplate>
</UserControl>

TabControl.xaml.cs

namespace WpfControls.Controls
{
    [ContentProperty(nameof(Items))]
    public partial class TabControl : UserControl
    {
        public static readonly DependencyProperty ItemsProperty = DependencyProperty.Register(nameof(Items), typeof(List<TabItem>), typeof(TabControl));
        private static readonly DependencyPropertyKey SelectedContentPropertyKey = DependencyProperty.RegisterReadOnly(nameof(SelectedContent), typeof(object), typeof(TabControl), new FrameworkPropertyMetadata((object)null));
        public static readonly DependencyProperty SelectedContentProperty = SelectedContentPropertyKey.DependencyProperty;

        public List<TabItem> Items
        {
            get => (List<TabItem>)GetValue(ItemsProperty);
            set => SetValue(ItemsProperty, value);
        }

        public object SelectedContent => GetValue(SelectedContentProperty);

        public TabControl()
        {
            Items = new List<TabItem>();
            Loaded += OnLoaded;
            InitializeComponent();
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            if (Items != null && Items.Count > 0)
                SetValue(SelectedContentPropertyKey, Items[0].Content);
        }
    }
}

TabItem.xaml

<UserControl x:Class="WpfControls.Controls.TabItem" ...>
    <UserControl.ContentTemplate>
        <DataTemplate>
            <TextBlock>
                There is no ContentPresenter in the tab item.
                The content of the selected tab is presented by the parent TabControl.
                This template is for the tab header instead.
            </TextBlock>
        </DataTemplate>
    </UserControl.ContentTemplate>
</UserControl>
12
  • The list of TabItems that are assigned to the Items collection in XAML do not seem to build a visual or logical element tree, so that there is no value inheritance of the DataContext property across this boundary. Try to derive your custom TabControl from ItemsControl or Selector. Commented Jan 2, 2024 at 6:57
  • @Clemens the list of tab items are logical children of the TabControl, by virtue of being assigned via Content. When the content is rendered by the ContentPresenter, it is added to the visual tree. I will read more and try inheriting from ItemsControl or Selector but I don't understand what makes those classes special Commented Jan 2, 2024 at 7:58
  • Selector is the base class of ListBox, TabControl etc. It's the most obvious choice for a base class of a custom TabControl. Commented Jan 2, 2024 at 8:10
  • You are using the userControl totraally wrong. It's a ContenControl. As such it can only have a single child. Aside from the misconception about how the ContentTemplate works you would have to make TabControl extend at least ItemsControl in order to allow the definition of multiple TabItems in XAML. But then you end up replicating the original TabControl. It would be helpful if you could explain why you believe the original TabControl is not useful. It should be possible to modify the original control to meet your requirements. You really should extend the existing TabControl. Commented Jan 2, 2024 at 13:01
  • By the way, the data context is the data context you'd expect. You only override the content by defining the ContentTemplatem, which completely ignores the original Content value. It looks like you got the idea of a ContentCOntrol copletely wrong. That's ok. But why don't you use the WPF TabControl? Maybe we can help to modify its behavior. Commented Jan 2, 2024 at 13:08

1 Answer 1

0

I agree with @Clemens that you should customize the ItemsControl or TabControl instead of creating a new element from scratch. But I will still show you the errors in your implementation.

  1. You set the ContentTemplate for the CustomTabControl. But this template is used for an object located in the Content property. And this property is empty. Therefore, you need to pass DataContext to this property or set Content instead of ContentTemplate.

  2. ContentPresenter - is not a ContentControl. Therefore, even if Content is set to the content of a UI element, this content will not have the same DataContext as the ContentPresenter itself.

I suggest two fixes:

<UserControl x:Class="Core2023.SO.Andrew_Williamson.question77744376.CustomTabControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Core2023.SO.Andrew_Williamson.question77744376"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             Content="{Binding}">
    <UserControl.ContentTemplate>
        <DataTemplate>
            <ContentControl Content="{Binding SelectedContent, RelativeSource={RelativeSource AncestorType=UserControl}}" />
        </DataTemplate>
    </UserControl.ContentTemplate>
</UserControl>
<UserControl x:Class="Core2023.SO.Andrew_Williamson.question77744376.CustomTabControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Core2023.SO.Andrew_Williamson.question77744376"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             Content="{Binding SelectedContent, RelativeSource={RelativeSource Self}}">
</UserControl>

I also suggest you refactor all the code:

using System.Windows;
using System.Windows.Controls;

namespace Core2023.SO.Andrew_Williamson.question77744376
{
    public partial class CustomTabItem : CustomTabItemBase
    {
        public CustomTabItem()
        {
            InitializeComponent();
        }
    }
    public partial class CustomTabItemBase : UserControl
    {
        protected override void OnContentChanged(object oldContent, object newContent)
        {
            base.OnContentChanged(oldContent, newContent);

            RaiseContentChangedEvent(oldContent, newContent);
        }

        // Register a custom routed event using the Bubble routing strategy.
        public static readonly RoutedEvent ContentChangedEvent = EventManager.RegisterRoutedEvent(
            name: nameof(ContentChanged),
            routingStrategy: RoutingStrategy.Bubble,
            handlerType: typeof(ContentChangedEventHandler),
            ownerType: typeof(ContentControl));

        // Provide CLR accessors for assigning an event handler.
        public event ContentChangedEventHandler ContentChanged
        {
            add => AddHandler(ContentChangedEvent, value);
            remove => RemoveHandler(ContentChangedEvent, value);
        }

        protected void RaiseContentChangedEvent(object? oldContent, object? newContent)
        {
            // Create a RoutedEventArgs instance.
            ContentChangedEventArgs routedEventArgs = new(
                routedEvent: ContentChangedEvent,
                source: this,
                oldContent: oldContent,
                newContent: newContent);

            // Raise the event, which will bubble up through the element tree.
            RaiseEvent(routedEventArgs);
        }
    }

    public delegate void ContentChangedEventHandler(object sender, ContentChangedEventArgs e);

    public class ContentChangedEventArgs : RoutedEventArgs
    {
        public object? OldContent { get; }
        public object? NewContent { get; }

        public ContentChangedEventArgs(RoutedEvent routedEvent, ContentControl source, object? oldContent, object? newContent)
            : base(routedEvent, source)
        {
            OldContent = oldContent;
            NewContent = newContent;
        }
    }
}
<local:CustomTabItemBase x:Class="Core2023.SO.Andrew_Williamson.question77744376.CustomTabItem"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Core2023.SO.Andrew_Williamson.question77744376"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.ContentTemplate>
        <DataTemplate>
            <TextBlock>
                There is no ContentPresenter in the tab item.
                The content of the selected tab is presented by the parent TabControl.
                This template is for the tab header instead.
            </TextBlock>
        </DataTemplate>
    </UserControl.ContentTemplate>
</local:CustomTabItemBase>
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Markup;

namespace Core2023.SO.Andrew_Williamson.question77744376
{
    public partial class CustomTabControl : CustomTabControlBase
    {
        public CustomTabControl()
        {
            InitializeComponent();
        }

    }

    [ContentProperty(nameof(Items))]
    public class CustomTabControlBase : UserControl
    {
        public static readonly DependencyPropertyKey ItemsPropertyKey
            = DependencyProperty.RegisterReadOnly(
                nameof(Items),
                typeof(FreezableCollection<CustomTabItemBase>),
                typeof(CustomTabControlBase),
                new PropertyMetadata(null));
        public static readonly DependencyProperty ItemsProperty = ItemsPropertyKey.DependencyProperty;

        private static readonly DependencyPropertyKey SelectedContentPropertyKey
            = DependencyProperty.RegisterReadOnly(
                nameof(SelectedContent),
                typeof(object),
                typeof(CustomTabControlBase),
                new FrameworkPropertyMetadata((object?)null) { });
        public static readonly DependencyProperty SelectedContentProperty = SelectedContentPropertyKey.DependencyProperty;



        public CustomTabItemBase SelectedTabItem
        {
            get => (CustomTabItemBase)GetValue(SelectedTabItemProperty);
            set => SetValue(SelectedTabItemProperty, value);
        }

        // Using a DependencyProperty as the backing store for SelectedTabItem.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty SelectedTabItemProperty =
            DependencyProperty.Register(
                nameof(SelectedTabItem),
                typeof(CustomTabItemBase),
                typeof(CustomTabControlBase),
                new PropertyMetadata(null)
                {
                    CoerceValueCallback = (d, value) =>
                    {
                        if (((CustomTabControlBase)d).Items.Contains((CustomTabItemBase)value))
                        {
                            return value;
                        }
                        return null;
                    },
                    PropertyChangedCallback = (d, e) =>
                    {
                        CustomTabControlBase control = (CustomTabControlBase)d;
                        if (e.OldValue is CustomTabItemBase old)
                        {
                            old.ContentChanged -= control.OnItemContentChanged;
                        }
                        CustomTabItemBase @new = (CustomTabItemBase)e.NewValue;
                        if (@new is not null)
                        {
                            @new.ContentChanged += control.OnItemContentChanged;
                        }
                        control.SetValue(SelectedContentPropertyKey, @new?.Content);
                    }
                });

        private void OnItemContentChanged(object sender, ContentChangedEventArgs e)
        {
            SetValue(SelectedContentPropertyKey, e.NewContent);
        }

        public FreezableCollection<CustomTabItemBase> Items
        {
            get => (FreezableCollection<CustomTabItemBase>)GetValue(ItemsProperty);
            private set => SetValue(ItemsPropertyKey, value);
        }

        public object SelectedContent => GetValue(SelectedContentProperty);

        public CustomTabControlBase()
        {
            Items = new FreezableCollection<CustomTabItemBase>();
            Items.Changed += OnItemsChanged;
        }

        private int itemsCount = 0;
        private void OnItemsChanged(object? sender, EventArgs e)
        {
            int count = Items.Count;
            if (itemsCount != count)
            {
                if (itemsCount == 0) SetValue(SelectedTabItemProperty, Items[0]);
                itemsCount = count;
            }
            InvalidateProperty(SelectedTabItemProperty);
        }
    }
}
<local:CustomTabControlBase x:Class="Core2023.SO.Andrew_Williamson.question77744376.CustomTabControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Core2023.SO.Andrew_Williamson.question77744376"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             Content="{Binding SelectedContent, RelativeSource={RelativeSource Self}}">
</local:CustomTabControlBase>
using System.Windows;

namespace Core2023.SO.Andrew_Williamson.question77744376
{
    public partial class CustomTabControlWindow : Window
    {
        public CustomTabControlWindow()
        {
            InitializeComponent();
        }
    }

    public class ExampleViewModel
    {
        public string Example { get; set; } = "Some text";
    }
}
<Window x:Class="Core2023.SO.Andrew_Williamson.question77744376.CustomTabControlWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Core2023.SO.Andrew_Williamson.question77744376"
        mc:Ignorable="d"
        Title="CustomTabControlWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:ExampleViewModel/>
    </Window.DataContext>
    <StackPanel>
        <!-- The binding works as expected here -->
        <TextBlock Text="{Binding Example}" />
        <local:CustomTabControl>
            <local:CustomTabItem>
                <StackPanel>
                    <TextBlock>Some tab content</TextBlock>
                    <!-- The data context is always null here for some reason -->
                    <TextBlock Text="{Binding Example}" />
                </StackPanel>
            </local:CustomTabItem>
            <local:CustomTabItem>
                <TextBlock>Some other tab content</TextBlock>
            </local:CustomTabItem>
        </local:CustomTabControl>
    </StackPanel>
</Window>
Sign up to request clarification or add additional context in comments.

5 Comments

"But this template is used for an object located in the Content property. And this property is empty." Actually the content is not empty, it's being set to a list of tab items via the [ContentProperty(nameof(Items))] attribute and the template is being applied
Regarding "2. ContentPresenter", it looks like I've simplified my MCVE too much. In my application, the tab headers are also included in that template as an ItemsControl so I can't just pass the SelectedContent straight through. The TabControl contains auth logic to determine which tab headers should show, and strange pagination/scrolling logic for the tab headers once there is more than eight (this is the part I had trouble with when using the default TabControl)
You are confusing the ContentControl.Content and ContentPresenter.Content properties with the property specified in the ContentProperty attribute. The Items property, in this case, is simply the default property for populating the contents of an element in XAML. It has nothing to do with the transmission of Data Context.
It is difficult for me to tell you the right solution, since there are too many unknown details of your task. You should note that the passing of Data Context does not always occur to child elements, as the purpose of child elements may be different. In some cases, it must be transferred explicitly; in others, other containers and layout must be used. In any case, you should understand that the TabItem is highlighted and its content must be selected after selection. In my example, this happens in the "PropertyChangedCallback = (d, e) => ..." and "OnItemContentChanged" methods.
I've customized the existing TabControl instead. I still have to figure out how to get the pagination working for pages with more than 8 tabs but I'll post that as a separate question when I get to those pages (there's only a few). Thanks for the help

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.