WPF 中不规则形状项目的命中测试

Hit-Testing in WPF for irregularly-shaped items

我在 ContentControl 派生的 class ("ShapeItem") 中包含一个不规则形状的项目(线条形状)。我使用自定义光标对其进行样式设置,并在 ShapeItem class.

中处理鼠标单击

不幸的是,如果鼠标在 ContentControl 的矩形边界框内的任何位置,WPF 认为鼠标是 "over" 我的项目。这对于像矩形或圆形这样的封闭形状没问题,但对于对角线来说就是个问题。考虑这张显示了 3 个这样的形状的图像,它们的边界框显示为白色:

即使我位于线周围边界框的左下角,它仍然显示光标并且鼠标点击仍然到达我的自定义项目。

我想更改此设置,以便仅当我在一定距离内时才将鼠标视为 "over" 检测到的线。就像,这个红色区域(原谅粗略的绘图)。

我的问题是,我该如何解决这个问题?我是否在我的 ShapeItem 上覆盖了一些虚拟 "HitTest" 相关函数?

我已经掌握了判断我是否来对地方的数学知识。我只是想知道最好选择哪种方法。我要覆盖哪些功能?或者我要处理什么事件,等等。我在 WPF 文档中关于命中测试的地方迷路了。是覆盖 HitTestCore 还是类似的问题?

现在开始写代码。我将项目托管在名为 "ShapesControl" 的自定义 ItemsControl 中。 它使用自定义 "ShapeItem" 容器来托管我的视图模型对象:

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

我的"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,
        };
    }
}

还有我的"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;
}

"ShapeVm" 只是我的视图模型的抽象基础 class。大致是这样的:

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

您可以使用如下所示的 ShapeItem class。它是一个 Canvas,有两个路径 children,一个用于命中测试,一个用于显示。它类似于一些典型的 Shape 属性(您可以根据需要对其进行扩展)。

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

您可以像这样 XAML 使用它:

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

但是,当您将 Canvas 放入常规 ItemsControl 的 ItemTemplate 中时,您可能根本不需要 ShapeItem class 和派生的 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>

如果您还需要支持选择,则应使用 ListBox 而不是 ItemsControl。 ItemTemplate 中的第三个 Path 可以可视化选择状态。

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