WPF UserControl HitTest

serhio picture serhio · Sep 19, 2010 · Viewed 9.7k times · Source

I have the following user control: a dot and its name:

<UserControl x:Class="ShapeTester.StopPoint"
     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" 
     mc:Ignorable="d" 
     d:DesignHeight="25" d:DesignWidth="100">

   <StackPanel>
      <Ellipse Stroke="DarkBlue" Fill="LightBlue" Height="10" Width="10"/>
      <TextBlock Text="Eiffel Tower"/>        
  </StackPanel>
</UserControl>

This is cool.

Now, I have a panel, in witch I need to recuperate my StopPoints that I hit with the Mouse:

public partial class StopsPanel : UserControl
{
    private List<StopPoint> hitList = new List<StopPoint>();
    private EllipseGeometry hitArea = new EllipseGeometry();

    public StopsPanel()
    {
        InitializeComponent();
        Initialize();
    }

    private void Initialize()
    {
        foreach (StopPoint point in StopsCanvas.Children)
        {
            point.Background = Brushes.LightBlue;
        }
    }

    private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        // Initialization:
        Initialize();
        // Get mouse click point:
        Point pt = e.GetPosition(StopsCanvas);
        // Define hit-testing area:
        hitArea = new EllipseGeometry(pt, 1.0, 1.0);
        hitList.Clear();
        // Call HitTest method:
        VisualTreeHelper.HitTest(StopsCanvas, null,
        new HitTestResultCallback(HitTestCallback),
        new GeometryHitTestParameters(hitArea));
        if (hitList.Count > 0)
        {
            foreach (StopPoint point in hitList)
            {
                // Change rectangle fill color if it is hit:
                point.Background = Brushes.LightCoral;
            }
            MessageBox.Show(string.Format(
                "You hit {0} StopPoint(s)", hitList.Count));
        }
    }

    public HitTestResultBehavior HitTestCallback(HitTestResult result)
    {
        if (result.VisualHit is StopPoint)
        {
            //
            //-------- NEVER ENTER HERE!!! :(
            //

            // Retrieve the results of the hit test.
            IntersectionDetail intersectionDetail =
            ((GeometryHitTestResult)result).IntersectionDetail;
            switch (intersectionDetail)
            {
                case IntersectionDetail.FullyContains:
                // Add the hit test result to the list:
                    hitList.Add((StopPoint)result.VisualHit);
                    return HitTestResultBehavior.Continue;
                case IntersectionDetail.Intersects:
                // Set the behavior to return visuals at all z-order levels:
                    return HitTestResultBehavior.Continue;
                case IntersectionDetail.FullyInside:
                // Set the behavior to return visuals at all z-order levels:
                    return HitTestResultBehavior.Continue;
                default:
                    return HitTestResultBehavior.Stop;
            }
        }
        else
        {
            return HitTestResultBehavior.Continue;
        }
    }
}

So, as you can see, the problem that the HitTest never identifies an UserControl(StopPoint) as it is, but rather its components(TextBlock, Ellipse or even Border).
As I associate the business object to the StopPoint element, I need to obtain it when MouseHitting, and not its composing elements.

Is there a way to do it?

EDIT:

Using filter (now, it does not enter at all in the HitTestCallback):

using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace ShapeTester
{
    /// <summary>
    /// Interaction logic for StopsPanel.xaml
    /// </summary>
    public partial class StopsPanel : UserControl
    {
        private List<StopPoint> hitList = new List<StopPoint>();
        private EllipseGeometry hitArea = new EllipseGeometry();

        public StopsPanel()
        {
            InitializeComponent();
            Initialize();
        }

        private void Initialize()
        {
            foreach (StopPoint point in StopsCanvas.Children)
            {
                point.Background = Brushes.LightBlue;
            }
        }

        private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            // Initialization:
            Initialize();
            // Get mouse click point:
            Point pt = e.GetPosition(StopsCanvas);
            // Define hit-testing area:
            hitArea = new EllipseGeometry(pt, 1.0, 1.0);
            hitList.Clear();
            // Call HitTest method:
            VisualTreeHelper.HitTest(StopsCanvas, 
                new HitTestFilterCallback(MyHitTestFilter),
                new HitTestResultCallback(HitTestCallback),
                new GeometryHitTestParameters(hitArea));

            if (hitList.Count > 0)
            {
                foreach (StopPoint point in hitList)
                {
                    // Change rectangle fill color if it is hit:
                    point.Background = Brushes.LightCoral;
                }
                MessageBox.Show(string.Format(
                    "You hit {0} StopPoint(s)", hitList.Count));
            }
        }

        public HitTestResultBehavior HitTestCallback(HitTestResult result)
        {
            if (result.VisualHit is StopPoint)
            {
                //
                //-------- NEVER ENTER HERE!!! :(
                //

                // Retrieve the results of the hit test.
                IntersectionDetail intersectionDetail =
                ((GeometryHitTestResult)result).IntersectionDetail;
                switch (intersectionDetail)
                {
                    case IntersectionDetail.FullyContains:
                    // Add the hit test result to the list:
                        hitList.Add((StopPoint)result.VisualHit);
                        return HitTestResultBehavior.Continue;
                    case IntersectionDetail.Intersects:
                    // Set the behavior to return visuals at all z-order levels:
                        return HitTestResultBehavior.Continue;
                    case IntersectionDetail.FullyInside:
                    // Set the behavior to return visuals at all z-order levels:
                        return HitTestResultBehavior.Continue;
                    default:
                        return HitTestResultBehavior.Stop;
                }
            }
            else
            {
                return HitTestResultBehavior.Continue;
            }
        }

        // Filter the hit test values for each object in the enumeration.
        public HitTestFilterBehavior MyHitTestFilter(DependencyObject o)
        {
            // Test for the object value you want to filter.
            if (o.GetType() == typeof(StopPoint))
            {
                // Visual object's descendants are 
                // NOT part of hit test results enumeration.
                return HitTestFilterBehavior.ContinueSkipChildren;
            }
            else
            {
                // Visual object is part of hit test results enumeration.
                return HitTestFilterBehavior.Continue;
            }
        }
    }
}

Answer

quetzalcoatl picture quetzalcoatl · Dec 6, 2011

I wanted to write an explanation, but I've already found a decent one:

https://stackoverflow.com/a/7162443/717732

The point is:

Your UserControl.HitTestCore() is left to the default implementation that probaly returns NULL, that this causes the UC to be skipped instead of be passed to the resultCallback.

The default behaviour is not a bug. This is a not obvious, clever design - all in all, your control has no visuals, it is only container for some children who have the shapes, so generally there's no point in the UC to be hittestable and to clutter the walking path. You may see it as a shortcoming, because the brevity of your code could benefit from the UC being hittestable. However, not the brevity is the goal here - it is the speed. In fact this is an important feature, because it really reduces the amount of items on which the treewalker must perform the actual intersections!

So - either implement the HitTestCore and return something non-null, or hittest for the UserControl's children instead, and then when having a proper result but equal to its child, use the VisualTreeHelper.GetParent until you walk upto the UserControl you wanted.