C# – How to set a PlacementTarget for a WPF tooltip without messing up the DataContext

cwpfxaml

I have a typical MVVM setup of Listbox and vm + DataTemplate and item vm's. The data templates have tooltips, which have elements bound to the item vm's. All works great.

Now, I'd like to have the tooltip placed relative to the listbox itself. It's fairly large and gets in the way when casually mousing over the listbox. So I figured I'd do something like this in the DataTemplate:

<Grid ...>
    <TextBlock x:Name="ObjectText"
        ToolTipService.Placement="Left"
        ToolTip="{StaticResource ItemToolTip}"
        ToolTipService.PlacementTarget="{Binding RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}">
    </TextBlock>
...

…with the static resource…

<ToolTip x:Key="ItemToolTip">
    <StackPanel>
        <TextBlock Text="{Binding DisplayName.Name}"/>
        <TextBlock Text="{Binding Details}" FontStyle="Italic"/>
        ...
    </StackPanel>
</ToolTip>

Here's my problem. When I use that PlacementTarget I get a binding error that the DisplayName.Name and Details are not binding. The object it's trying to bind to is not the item vm but the overall Listbox vm.

So my question is: how can I set the ToolTipService.PlacementTarget for a tooltip yet keep the DataContext inherited from its owner?

Best Answer

Ok, a friend at work mostly figured it out for me. This way is super clean, doesn't feel hacky.

Here's the basic problem: as user164184 mentioned, tooltips are popups and therefore not part of the visual tree. So there's some magic that WPF does. The DataContext for the popup comes from the PlacementTarget, which is how the bindings work most of the time, despite the popup not being part of the tree. But when you change the PlacementTarget this overrides the default, and now the DataContext is coming from the new PlacementTarget, whatever it may be.

Totally not intuitive. It would be nice if MSDN had, instead of spending hours building all those pretty graphs of where the different tooltips appear, said one sentence about what happens with the DataContext.

Anyway, on to the SOLUTION! As with all fun WPF tricks, attached properties come to the rescue. We're going to add two attached properties so we can directly set the DataContext of the tooltip when it's generated.

public static class BindableToolTip
{
    public static readonly DependencyProperty ToolTipProperty = DependencyProperty.RegisterAttached(
        "ToolTip", typeof(FrameworkElement), typeof(BindableToolTip), new PropertyMetadata(null, OnToolTipChanged));

    public static void SetToolTip(DependencyObject element, FrameworkElement value) { element.SetValue(ToolTipProperty, value); }
    public static FrameworkElement GetToolTip(DependencyObject element) { return (FrameworkElement)element.GetValue(ToolTipProperty); }

    static void OnToolTipChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
    {
        ToolTipService.SetToolTip(element, e.NewValue);

        if (e.NewValue != null)
        {
            ((ToolTip)e.NewValue).DataContext = GetDataContext(element);
        }
    }

    public static readonly DependencyProperty DataContextProperty = DependencyProperty.RegisterAttached(
        "DataContext", typeof(object), typeof(BindableToolTip), new PropertyMetadata(null, OnDataContextChanged));

    public static void SetDataContext(DependencyObject element, object value) { element.SetValue(DataContextProperty, value); }
    public static object GetDataContext(DependencyObject element) { return element.GetValue(DataContextProperty); }

    static void OnDataContextChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
    {
        var toolTip = GetToolTip(element);
        if (toolTip != null)
        {
            toolTip.DataContext = e.NewValue;
        }
    }
}

And then in the XAML:

<Grid ...>
    <TextBlock x:Name="ObjectText"
        ToolTipService.Placement="Left"
        ToolTipService.PlacementTarget="{Binding RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"
        mystuff:BindableToolTip.DataContext="{Binding}">
        <mystuff:BindableToolTip.ToolTip>
            <ToolTip>
                <StackPanel>
                    <TextBlock Text="{Binding DisplayName.Name}"/>
                    <TextBlock Text="{Binding Details}" FontStyle="Italic"/>
                    ...
                </StackPanel>
            </ToolTip>
        </mystuff:BindableToolTip.ToolTip>
    </TextBlock>
...

Just switch the ToolTip over to BindableToolTip.ToolTip instead, then add a new BindableToolTip.DataContext that points at whatever you want. I'm just setting it to the current DataContext, so it ends up inheriting the viewmodel bound to the DataTemplate.

Note that I embedded the ToolTip instead of using a StaticResource. That was a bug in my original question. Obviously has to be generated unique per item. Another option would be to use a ControlTemplate Style trigger thingy.

One improvement could be to have BindableToolTip.DataContext register for notifications on the ToolTip changing, then I could get rid of BindableToolTip.ToolTip. A task for another day!