Joe
Joe

Reputation: 6786

Hit-Testing in WPF for irregularly-shaped items

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; }      
 }

Upvotes: 1

Views: 751

Answers (1)

Clemens
Clemens

Reputation: 128013

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>

Upvotes: 3

Related Questions