1

I have an irregularly shaped item (a line shape) contained within a ContentControl-derived class ("ShapeItem"). I style it with a custom cursor and I handle mouse-clicks within the ShapeItem class.

Unfortunately WPF thinks that the mouse is "over" my item if it is anywhere within the rectangular bounding box of the ContentControl. That's OK for closed shapes like a rect or circle, but it's a problem for a diagonal line. Consider this image with 3 such shapes on display and their bounding boxes shown in white:

enter image description here

Even if I am in the very bottom left hand corner of the bounding box around the line, it still shows the cursor and the mouse clicks still reach my custom item.

I want to change this so that that the mouse is only considered to be "over" the line detected if I am within a certain distance of it. Like, this region in red (forgive the crude drawing).

enter image description here

My question is, how do I approach this? Do I override some virtual "HitTest" related function on my ShapeItem?

I already know the math to figure out if I'm in the right place. I'm just wondering what approach is the best to choose. What functions do I override? Or what events do I handle, etc. I've gotten lost in the WPF documentation on Hit testing. Is it a matter of overriding HitTestCore or something like that?

Now for code. I host the items in a custom ItemsControl called "ShapesControl". which uses the custom "ShapeItem" container to to host my view-model objects :

<Canvas x:Name="Scene" HorizontalAlignment="Left" VerticalAlignment="Top">

    <gcs:ShapesControl x:Name="ShapesControl" Canvas.Left="0" Canvas.Top="0"
                       ItemsSource="{Binding Shapes}">

        <gcs:ShapesControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Canvas Background="Transparent" IsItemsHost="True" />
            </ItemsPanelTemplate>
        </gcs:ShapesControl.ItemsPanel>
        <gcs:ShapesControl.ItemTemplate>
            <DataTemplate DataType="{x:Type gcs:ShapeVm}">
                <Path ClipToBounds="False"
                      Data="{Binding RelativeGeometry}"
                      Fill="Transparent"/>
            </DataTemplate>
        </gcs:ShapesControl.ItemTemplate>

        <!-- Style the "ShapeItem" container that the ShapesControl wraps each ShapeVm ine -->

        <gcs:ShapesControl.ShapeItemStyle>
            <Style TargetType="{x:Type gcs:ShapeItem}"
                   d:DataContext="{d:DesignInstance {x:Type gcs:ShapeVm}}"
                   >
                <!-- Use a custom cursor -->

                <Setter Property="Background"  Value="Transparent"/>
                <Setter Property="Cursor"      Value="SizeAll"/>
                <Setter Property="Canvas.Left" Value="{Binding Path=Left, Mode=OneWay}"/>
                <Setter Property="Canvas.Top"  Value="{Binding Path=Top, Mode=OneWay}"/>


                <Setter Property="Template">
                    <Setter.Value>
                        <ControlTemplate  TargetType="{x:Type gcs:ShapeItem}">
                            <Grid SnapsToDevicePixels="True" Background="{TemplateBinding Panel.Background}">

                                <!-- First draw the item (i.e. the ShapeVm) -->

                                <ContentPresenter x:Name="PART_Shape"
                                                  Content="{TemplateBinding ContentControl.Content}"
                                                  ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}"
                                                  ContentTemplateSelector="{TemplateBinding ContentControl.ContentTemplateSelector}"
                                                  ContentStringFormat="{TemplateBinding ContentControl.ContentStringFormat}"
                                                  HorizontalAlignment="{TemplateBinding Control.HorizontalContentAlignment}"
                                                  VerticalAlignment="{TemplateBinding Control.VerticalContentAlignment}"
                                                  IsHitTestVisible="False"
                                                  SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}"
                                                  RenderTransformOrigin="{TemplateBinding ContentControl.RenderTransformOrigin}"/>

                            </Grid>

                        </ControlTemplate>
                    </Setter.Value>
                </Setter>
            </Style>

        </gcs:ShapesControl.ShapeItemStyle>
    </gcs:ShapesControl>
</Canvas>

My "ShapesControl"

public class ShapesControl : ItemsControl
{
    protected override bool IsItemItsOwnContainerOverride(object item)
    {
        return (item is ShapeItem);
    }

    protected override DependencyObject GetContainerForItemOverride()
    {
        // Each item we display is wrapped in our own container: ShapeItem
        // This override is how we enable that.
        // Make sure that the new item gets any ItemTemplate or
        // ItemTemplateSelector that might have been set on this ShapesControl.

        return new ShapeItem
        {
            ContentTemplate = this.ItemTemplate,
            ContentTemplateSelector = this.ItemTemplateSelector,
        };
    }
}

And my "ShapeItem"

/// <summary>
/// A ShapeItem is a ContentControl wrapper used by the ShapesControl to
/// manage the underlying ShapeVm.  It is like the the item types used by
/// other ItemControls, including ListBox, ItemsControls, etc.
/// </summary>
[TemplatePart(Name="PART_Shape", Type=typeof(ContentPresenter))]
public class ShapeItem : ContentControl
{
    private ShapeVm Shape => DataContext as ShapeVm;
    static ShapeItem()
    {
        DefaultStyleKeyProperty.OverrideMetadata
            (typeof(ShapeItem), 
             new FrameworkPropertyMetadata(typeof(ShapeItem)));
    }

    protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
    {
        // Toggle selection when the left mouse button is hit

        base.OnMouseLeftButtonDown(e);
        ShapeVm.IsSelected = !ShapeVm.IsSelected;
        e.Handled = true;

    }

    internal ShapesControl ParentSelector =>
        ItemsControl.ItemsControlFromItemContainer(this) as ShapesControl;
}

The "ShapeVm" is just an abstract base class for my view models. Roughly this:

public abstract class ShapeVm : BaseVm, IShape
{
    public virtual Geometry RelativeGeometry { get; }
    public bool   IsSelected { get; set; }
    public double Top        { get; set; }
    public double Left       { get; set; }
    public double Width      { get; }
    public double Height     { get; }      
 }
Joe
  • 5,394
  • 3
  • 23
  • 54
  • 1
    Have you looked at overriding [HitTestCore](https://learn.microsoft.com/en-us/dotnet/api/system.windows.uielement.hittestcore?view=netframework-4.7.2#System_Windows_UIElement_HitTestCore_System_Windows_Media_PointHitTestParameters_) method? – Mehrzad Chehraz Sep 12 '18 at 01:37
  • Is it really that simple? I just override HitTestCore? – Joe Sep 12 '18 at 01:51
  • And you should probably implement your hit test logic – Yves Israel Sep 12 '18 at 02:19
  • I just tried that but unfortunately it still doesn't solve the problem of the changing mouse cursor. I also want the cursor to reflect that its over the irregular shape as the user moves it. Unfortunately, even with a custom override of HitTestCore, the cursor still changes whenever the mouse is anywhere over the entire bounding box of the content control. – Joe Sep 12 '18 at 02:37
  • As Mehrzad pointed out, overriding HitTestCore was part of the solution (Thanks Mehrzad). But regarding the problem of showing the proper mouse cursor: After quite a bit more searching and fiddling, it is starting to look like if I a) remove the setting of the Cursor in the ShapeItem style and b) hook up to the Mouse.QueryCursorEvent, I can do my manual hit test in my event handler and effectively override the cursor there. It seems to work anyway. If there is a cleaner way to do it, I'm all ears... – Joe Sep 12 '18 at 03:56

1 Answers1

3

You could use a ShapeItem class like shown below. It is a Canvas with two Path children, one for hit testing and one for display. It resembles a few of the typical Shape properties (which you may extend according to your needs).

public class ShapeItem : Canvas
{
    public ShapeItem()
    {
        var path = new Path
        {
            Stroke = Brushes.Transparent,
            Fill = Brushes.Transparent
        };
        path.SetBinding(Path.DataProperty,
            new Binding(nameof(Data)) { Source = this });
        path.SetBinding(Shape.StrokeThicknessProperty,
            new Binding(nameof(HitTestStrokeThickness)) { Source = this });
        Children.Add(path);

        path = new Path();
        path.SetBinding(Path.DataProperty,
            new Binding(nameof(Data)) { Source = this });
        path.SetBinding(Shape.FillProperty,
            new Binding(nameof(Fill)) { Source = this });
        path.SetBinding(Shape.StrokeProperty,
            new Binding(nameof(Stroke)) { Source = this });
        path.SetBinding(Shape.StrokeThicknessProperty,
            new Binding(nameof(StrokeThickness)) { Source = this });
        Children.Add(path);
    }

    public static readonly DependencyProperty DataProperty =
        Path.DataProperty.AddOwner(typeof(ShapeItem));

    public static readonly DependencyProperty FillProperty =
        Shape.FillProperty.AddOwner(typeof(ShapeItem));

    public static readonly DependencyProperty StrokeProperty =
        Shape.StrokeProperty.AddOwner(typeof(ShapeItem));

    public static readonly DependencyProperty StrokeThicknessProperty =
        Shape.StrokeThicknessProperty.AddOwner(typeof(ShapeItem));

    public static readonly DependencyProperty HitTestStrokeThicknessProperty =
        DependencyProperty.Register(nameof(HitTestStrokeThickness), typeof(double), typeof(ShapeItem));

    public Geometry Data
    {
        get => (Geometry)GetValue(DataProperty);
        set => SetValue(DataProperty, value);
    }

    public Brush Fill
    {
        get => (Brush)GetValue(FillProperty);
        set => SetValue(FillProperty, value);
    }

    public Brush Stroke
    {
        get => (Brush)GetValue(StrokeProperty);
        set => SetValue(StrokeProperty, value);
    }

    public double StrokeThickness
    {
        get => (double)GetValue(StrokeThicknessProperty);
        set => SetValue(StrokeThicknessProperty, value);
    }

    public double HitTestStrokeThickness
    {
        get => (double)GetValue(HitTestStrokeThicknessProperty);
        set => SetValue(HitTestStrokeThicknessProperty, value);
    }
}

public class ShapeItemsControl : ItemsControl
{
    protected override DependencyObject GetContainerForItemOverride()
    {
        return new ShapeItem();
    }

    protected override bool IsItemItsOwnContainerOverride(object item)
    {
        return item is ShapeItem;
    }
}

You would use it an XAML like this:

<gcs:ShapeItemsControl ItemsSource="{Binding Shapes}">
    <gcs:ShapeItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas/>
        </ItemsPanelTemplate>
    </gcs:ShapeItemsControl.ItemsPanel>
    <gcs:ShapeItemsControl.ItemContainerStyle>
        <Style TargetType="gcs:ShapeItem">
            <Setter Property="Data" Value="{Binding RelativeGeometry}"/>
            <Setter Property="Fill" Value="AliceBlue"/>
            <Setter Property="Stroke" Value="Yellow"/>
            <Setter Property="StrokeThickness" Value="3"/>
            <Setter Property="HitTestStrokeThickness" Value="15"/>
            <Setter Property="Cursor" Value="Hand"/>
        </Style>
    </gcs:ShapeItemsControl.ItemContainerStyle>
</gcs:ShapeItemsControl>

However, you may not need a ShapeItem class and a derived ItemsControl at all, when you put the Canvas in the ItemTemplate of a regular ItemsControl:

<ItemsControl ItemsSource="{Binding Shapes}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Canvas Cursor="Hand">
                <Path Data="{Binding RelativeGeometry}" Fill="Transparent"
                      Stroke="Transparent" StrokeThickness="15"/>
                <Path Data="{Binding RelativeGeometry}" Fill="AliceBlue"
                      Stroke="Yellow" StrokeThickness="3"/>
            </Canvas>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

If you also need to support selection, you should use a ListBox instead of an ItemsControl. A third Path in the ItemTemplate could visualize the selection state.

<ListBox ItemsSource="{Binding Shapes}">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas/>
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBox.Template>
        <ControlTemplate TargetType="ListBox">
            <ItemsPresenter/>
        </ControlTemplate>
    </ListBox.Template>
    <ListBox.ItemContainerStyle>
        <Style TargetType="ListBoxItem">
            <Setter Property="IsSelected" Value="{Binding IsSelected}"/>
        </Style>
    </ListBox.ItemContainerStyle>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <Canvas Cursor="Hand">
                <Path Data="{Binding RelativeGeometry}" Fill="Transparent"
                      Stroke="Transparent" StrokeThickness="15"/>
                <Path Data="{Binding RelativeGeometry}"
                      Stroke="Green" StrokeThickness="7"
                      StrokeStartLineCap="Square" StrokeEndLineCap="Square"
                      Visibility="{Binding IsSelected,
                          RelativeSource={RelativeSource AncestorType=ListBoxItem},
                          Converter={StaticResource BooleanToVisibilityConverter}}"/>
                <Path Data="{Binding RelativeGeometry}" Fill="AliceBlue"
                      Stroke="Yellow" StrokeThickness="3"/>
            </Canvas>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>
Clemens
  • 123,504
  • 12
  • 155
  • 268
  • Appreciate the answer. That must have taken some time. Would this approach also account for making the cursor change properly only when it's over the desired irregular shape? – Joe Sep 12 '18 at 12:49
  • Regarding selection, my initial design actually had my ShapesControl be a custom MultiSelector. But it proved to be redundant because my design already requires selection be expressed within a property (of the underlying view model object ("ShapeVm.IsSelected"). So I found that also attempting to have the control know about "what is selected" required coordinating with the view model. And I didn't get anything out of it. Nobody needed to bind to the control's "SelectedItems" property anyway. So I went back to just a regular ItemsControl – Joe Sep 12 '18 at 12:54
  • 1
    The cursor already changes only when its over the shape. If you don't want to have it in the interior, i.e. only on the Stroke, do not set Fill. – Clemens Sep 12 '18 at 15:43