65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF UI 元素位置的数据绑定简单技术

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (24投票s)

2010年12月23日

CPOL

13分钟阅读

viewsIcon

120878

downloadIcon

3802

解释了如何创建一个控件,其位置可以绑定到一个属性。

引言

这是一篇简短的文章,介绍了我用于将 UI 元素的位置数据绑定到另一个 UI 元素或视图/数据模型对象上的属性的技术。

WPF 本身并不支持绑定到 UI 元素的位置;也很难弄清楚如何在 WPF 之上实现这一点。

在本文中,我将首先讨论我的初步实现,然后讨论当我完全进入 MVVM 思维模式后所获得的顿悟,以及它如何 dẫn đến 了一个更简单、更优雅的解决方案。

屏幕截图

这是示例应用程序的截图。你可能认出了我 上一篇文章 中的内容。用户可以拖动彩色矩形。由于数据绑定的魔力,在拖动矩形时,它们之间的连接会自动更新。

它可能看起来不怎么起眼,但我可以向你保证,将连接绑定到矩形的技术非常棒!*

*“棒”的程度取决于你对细微编程细节有多兴奋。

假设知识

假定你已经了解 C#,并对使用 WPFXAML 有基本了解。事先了解 MVVM 也会有帮助。如果你对此是新手,那么阅读 Sacha BarberJosh Smith 的入门文章可能会有所帮助,以便你跟上进度,尽管我会尽力在文章中提供指向信息和资源的链接。

背景与目标

正如我在之前的文章中所提到的,我一直在构建一个用于可视化和编辑网络、图表和流程图的控件。我称该控件为 NetworkView。不久之后,我将在 codeproject.com 上发布 NetworkView 的完整代码和文章。本文不是关于制作流程图控件,那将稍后推出。本文介绍的是我在实现流程图控件时使用的一种特定技术。当然,这项技术是如何绑定到 UI 元素的位置。在流程图控件中,我使用此技术将连接的端点绑定到图中的节点。但是,我认为该技术更具普遍适用性,因此我将其单独在一篇文章中进行介绍。

最简单的级别上,我需要能够将一个 UI 元素的位置绑定到另一个 UI 元素的位置。当然,我真正想要的是让 WPF 布局系统 排列我的流程图节点及其连接器(也称为连接锚点),并让连接视觉元素将其端点绑定到连接器的位置。

呼,说了这么多。希望这张图片能让你更清楚

我的第一次尝试

我想简要讨论我解决这个问题的第一次尝试。如果这不有趣,请随意跳到下一节,我将在其中讨论我的 视图模型解决方案

我最初的解决方案在很大程度上依赖于显式地搜索可视化树以查找连接器。成功搜索到连接器后,将计算中心点并相对于某个父控件(或 WPF 可视化树中已知的“祖先”控件)进行变换。正是这个变换后的点用于设置连接的端点。

显式的可视化树搜索是由视图模型和支持 NetworkView 控件的各种 UI 元素组合驱动的。代表连接器的视图模型对象被插入到代表连接的视图模型对象中。然后,这些对象被数据绑定到代表连接的 UI 元素。然后,连接 UI 元素会在可视化树中搜索代表连接器的 UI 元素。它知道如何找到特定的连接器 UI 元素,因为它的 DataContext 属性已设置为连接器视图模型。用另一种方式来定义这个搜索,我会说可视化树被搜索以查找其数据上下文设置为特定连接器视图模型对象的 UI 元素。

当时我认为这个解决方案很棒。从那时起,我对 MVVM 有了更多的了解。我的直觉告诉我,显式搜索可视化树是不好的,或者至少是太麻烦了。此外,我不确定搜索会如何影响应用程序的性能。

最终,我发现了一个更好的解决方案。它使用视图模型和数据绑定来在连接器 UI 元素和连接 UI 元素之间通信“连接器热点”。在迁移到面向视图模型的解决方案后,我惊讶于我能够从应用程序中剥离多少代码。我的直觉现在告诉我,这个解决方案是正确的。

视图模型解决方案

在继续阅读之前,你应该在 Visual Studio 中打开示例项目(我使用的是 Visual Studio 2008),然后构建并运行应用程序。

你应该会看到三个彩色矩形,它们由几条黑线连接在一起。你可以通过左键拖动彩色矩形来移动它们,你会看到它们仍然由黑线连接。太神奇了,不是吗!

视图模型类

首先,我需要介绍我在示例代码中使用的视图模型类。MVVM 中视图模型和数据模型之间的界限有些模糊。我将完全回避这个问题,只保留一个视图模型。也就是说,我在本示例中称之为视图模型的都是 UI 底层的类。通常在 MVVM 应用程序中,你会希望将类分离为视图模型和数据模型,数据模型类位于视图模型类之下。为了简化,我将两者合并到了视图模型中,如果读者有兴趣,我将留给读者练习,去弄清楚视图模型和数据模型之间的分离应该是什么样的。

如果你读过我 上一篇文章,你可能认识到本文再次出现的彩色矩形。你也可能还记得上一篇文章中的数据模型。

虽然有改动和添加,但我部分复制了另一篇文章并将其重命名为视图模型。

视图模型在 MainWindow1.xaml 中初始化

<Window.DataContext>
        
    <!-- 
    Initialize the view model that supplies the UI with data.
    -->
    <local:ViewModel />
        
</Window.DataContext>

这会创建一个 ViewModel 类的实例,并将其分配给窗口的 DataContext 属性。正如你可能已经知道的,这允许 XAML 中的其他 UI 属性数据绑定到 ViewModel 类的属性。

在我们进一步深入之前,让我们看一下我们简单的视图模型的类图(感谢 StarUML

视图模型由一个矩形列表和一个连接列表组成。每个矩形由 RectangleViewModel 表示。每个连接由 ConnectionViewModel 表示,并通过 Rect1Rect2 属性引用它连接的两个矩形。

在接下来的章节中,我将解释如何使用 ItemsControlCanvas 来呈现矩形和连接。我还会解释如何使用 Thumb 来允许用户通过鼠标左键拖动矩形。这些章节只是展示了示例项目工作的基础,你可能需要阅读它们以获得完整的画面,但如果你已经是 WPF 专家,请随时跳到 文章的核心部分

矩形和连接的呈现

彩色矩形通过将 ItemsSource 绑定到 ViewModelRectangles 属性的 ItemsControl 来呈现。

<ItemsControl
    ItemsSource="{Binding Rectangles}"
    >
    ...
</ItemsControl>

同样,连接的呈现也覆盖在同一空间中

<ItemsControl
    ItemsSource="{Binding Connections}"
    >
    ...
</ItemsControl>

每个矩形和连接的可视化效果由 数据模板Resources 部分定义。我们很快就会看到这些数据模板。

定位彩色矩形

Canvas 被用作矩形 ItemsControlItemsPanel

<ItemsControl
    ItemsSource="{Binding Rectangles}"
    >
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    ...
</ItemsControl>

Canvas.LeftCanvas.Top 属性由分配给 ItemContainerStyle 的样式设置。

<ItemsControl
    ItemsSource="{Binding Rectangles}"
    >
    ... specify Canvas as the items panel ...
    <ItemsControl.ItemContainerStyle>
        <Style>
            <Setter
                Property="Canvas.Left"
                Value="{Binding X}"
                />
            <Setter
                Property="Canvas.Top"
                Value="{Binding Y}"
                />
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

样式中的 setters 将 Canvas.LeftCanvas.Top 绑定到 RectangleViewModel 类的 XY。这会在 Canvas 内定位彩色矩形。

拖动彩色矩形

一个 Thumb 被嵌入到 RectangleViewModel 的数据模板中。

<!--
A data-template that defines the visuals for a rectangle.
-->
<DataTemplate 
    DataType="{x:Type local:RectangleViewModel}"
    >
    <Grid>
        <Thumb
            ...
            DragDelta="Thumb_DragDelta"
            >  
            ...                    
           
        </Thumb>
            
        ...

    </Grid>
</DataTemplate>

Thumb 控件使得实现简单的项拖动变得微不足道。我们所要做的就是处理 Thumb_DragDelta 并更新视图模型中矩形的位置。

private void Thumb_DragDelta(object sender, 
	System.Windows.Controls.Primitives.DragDeltaEventArgs e)
{
    Thumb thumb = (Thumb)sender;
    RectangleViewModel myRectangle = (RectangleViewModel)thumb.DataContext;

    //
    // Update the position of the rectangle in the data-model.
    //
    myRectangle.X += e.HorizontalChange;
    myRectangle.Y += e.VerticalChange;
}

RectangleViewModelXY 绑定到 Canvas.LeftCanvas.Top,因此对这些属性的更改会自动设置矩形在 Canvas 内的位置,UI 也会随之更新。

既然我们在,就来看看数据模板中的其他数据绑定属性。

<DataTemplate 
    DataType="{x:Type local:RectangleViewModel}"
    >
    <Grid>
        <Thumb
            Width="{Binding Width}"
            Height="{Binding Height}"
            DragDelta="Thumb_DragDelta"
            >                    
            <Thumb.Template>
                <ControlTemplate>
                    <Rectangle
                        Fill="{Binding Color, 
			Converter={StaticResource colorToBrushConverter}}"
                        Cursor="Hand"
                        />                            
                </ControlTemplate>                        
            </Thumb.Template>
                
        </Thumb>
            
        ...

    </Grid>
</DataTemplate>

Thumb 的大小绑定到 RectangleViewModelWidthHeight。这就是设置每个矩形大小的方式。RectangleFill 属性绑定到 RectangleViewModelColor 属性,并且使用自定义的 值转换器 将指定的颜色转换为 画笔

虽然 Thumb 在这种简单情况下很有用,但在更复杂的场景中则不太有用,在实际的 NetworkView 控件中,我实际上并不使用它。对于 NetworkView,我需要处理 MouseDownMouseUpMouseMove 事件,以一种与其他功能良好集成的方式实现流程图节点拖动行为。但在此情况下,使用 Thumb 可以保持简洁。

将连接器绑定到其祖先控件

现在基本背景材料已经介绍完毕,我们可以来看一下绑定到 UI 元素位置的实际技术。

为了获取连接器的位置,我们必须决定该点将相对于哪个“祖先”控件。换句话说,我们想让“连接器热点”相对于哪个坐标系?在此示例中,我使用 Canvas(包含矩形)作为“祖先”控件。也就是说,我想在 Canvas 的坐标系中表达每个“连接器热点”。

为了实现这一点,我创建了一个自定义控件 ConnectorItem 来表示一个连接器。

ConnectorItem 派生自 ContentControl,这意味着它可以 承载任意视觉内容

public class ConnectorItem : ContentControl
{
    ...
}

如果你查看 RectangleViewModel 的数据模板,你会看到我使用了一个椭圆作为 ConnectorItem 的视觉内容。

<local:ConnectorItem
    ...
    >
    <Ellipse
        Stroke="Black"
        StrokeThickness="1"
        Fill="White"
        />
</local:ConnectorItem>

ConnectorItemAncestor 依赖属性 指定了计算“连接器热点”时使用的祖先。它是一个标准的 WPF 依赖属性。

public class ConnectorItem : ContentControl
{
    ...
        
    public static readonly DependencyProperty AncestorProperty =
        DependencyProperty.Register("Ancestor", 
		typeof(FrameworkElement), typeof(ConnectorItem),
                new FrameworkPropertyMetadata(Ancestor_PropertyChanged));
                
    ...

    public FrameworkElement Ancestor
    {
        get
        {
            return (FrameworkElement)GetValue(AncestorProperty);
        }
        set
        {
            SetValue(AncestorProperty, value);
        }
    }
        
    ...
}

设置 Ancestor 属性会自动调用“属性更改”事件,该事件由 ConnectorItem 处理。

private static void Ancestor_PropertyChanged
	(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    ConnectorItem c = (ConnectorItem)d;
    c.UpdateHotspot();
}

事件处理程序调用 UpdateHotspot 来重新计算“连接器热点”。实际上,这意味着当连接器的“祖先”更改时,“连接器热点”会被重新计算。我们很快就会看到 UpdateHotspot

Ancestor 属性在 XAML 中使用 RelativeSource 数据绑定进行设置。

<local:ConnectorItem
    Ancestor="{Binding RelativeSource=
	{RelativeSource FindAncestor, AncestorType={x:Type Canvas}}}"
    ...
    >
    ... connector visuals ...
</local:ConnectorItem>

RelativeSource 绑定是替代我用来解决这个问题 第一次尝试 中的显式可视化树搜索的。绑定会自动沿可视化树向上搜索指定类型的“祖先”,在本例中是 Canvas。然后,绑定将“祖先”分配给 Ancestor 属性。

将连接器热点绑定到视图模型

现在让我们看看 Hotspot 如何数据绑定到视图模型。

<local:ConnectorItem
    ...
    Hotspot="{Binding ConnectorHotspot, Mode=OneWayToSource}"
    ...
    >
    ...
</local:ConnectorItem>

此绑定会将 ConnectorItem.Hotspot 的值推送到 RectangleViewModel.ConnectorHotspot

我决定将 ConnectorItem 创建为一个自定义控件,以便它可以监视自己的事件并根据需要重新计算“连接器热点”。我也可以为 HotspotAncestor 使用 附加属性,这将使我不必创建自定义控件。这将是一个有效的解决方案,但我认为创建一个自定义控件稍微简单和更直观,此外它在其他方面也有帮助,我不会在未来的 NetworkView 文章中讨论它们,但现在的一个例子是能够覆盖连接器的鼠标事件处理并以自定义方式处理这些事件。

我们感兴趣的 ConnectorItem 事件是 LayoutUpdatedSizeChanged。这些事件在 ConnectorItem 构造函数中被挂钩。

public ConnectorItem()
{
    ...
    
    this.LayoutUpdated += new EventHandler(ConnectorItem_LayoutUpdated);
    this.SizeChanged += new SizeChangedEventHandler(ConnectorItem_SizeChanged);
}

每个事件处理程序都简单地调用 UpdateHotspot,例如:

private void ConnectorItem_LayoutUpdated(object sender, EventArgs e)
{
    UpdateHotspot();
}

我有点担心挂钩 UpdateLayout 可能不是通知可视化树层次结构已更改的最有效方式。如果你能想到更好的方法,请告诉我!

UpdateHotspot 通过将连接器的中心点转换到“祖先”的坐标系来计算“连接器热点”。

private void UpdateHotspot()
{
    if (this.Ancestor == null)
    {
        // 
        // Can't do anything if the ancestor hasn't been set.
        //
        return;
    }
        
    //
    // Calculate the center point (in local coordinates) of the connector.
    //
    var center = new Point(this.ActualWidth / 2, this.ActualHeight / 2);

    //
    // Transform the local center point so that it is the 
    // center of the connector relative
    // to the specified ancestor.
    //
    var centerRelativeToAncestor = 
	this.TransformToAncestor(this.Ancestor).Transform(center);

    //
    // Assign the computed point to the 'Hotspot' property.  
    // Data-binding will take care of 
    // pushing this value into the data-model.
    //
    this.Hotspot = centerRelativeToAncestor;
}

Hotspot 是标准 WPF 依赖属性的另一个例子。

public class ConnectorItem : ContentControl
{
    public static readonly DependencyProperty HotspotProperty =
        DependencyProperty.Register("Hotspot", typeof(Point), typeof(ConnectorItem));
            
    ...

    public Point Hotspot
    {
        get
        {
            return (Point)GetValue(HotspotProperty);
        }
        set
        {
            SetValue(HotspotProperty, value);
        }
    }
        
    ...
}

Hotspot 的值发生变化时,WPF 依赖属性系统会自动引发“属性更改”事件,我们无需显式引发这些事件。这些事件会导致数据绑定被重新评估,进而导致我们的视图模型被更新。

你可能已经注意到 ConnectorItem 类似于 Josh Smith 的居中内容控件,我必须承认 Josh 的代码和文章是我许多灵感来源中的一部分。

现在,回到数据绑定。之前,我们看了 Hotspot 数据绑定。

Hotspot="{Binding ConnectorHotspot, Mode=OneWayToSource}"

这个数据绑定会将 Hotspot 的值传播到视图模型中的 ConnectorHotspot。数据绑定的 Mode 被设置为 OneWayToSource。这意味着 ConnectorHotspotHotspot 更新时被更新,但反之则不然。通常当我们想到数据绑定时,我们会认为视图模型是数据的来源,UI 然后显示该数据。但在这里,这个概念被颠倒了,UI 生成数据(使用 WPF 可视化树WPF 布局系统),这些数据被推送到视图模型。

视图模型中的 ConnectorHotspot 是一个标准的 C# 属性。它由一个 private 字段支持,设置属性会导致 PropertyChanged 事件被引发。

public class RectangleViewModel : INotifyPropertyChanged
{
    ...
    
    private Point connectorHotspot;
        
    ...
    
    public Point ConnectorHotspot
    {
        get
        {
            return connectorHotspot;
        }
        set
        {
            if (connectorHotspot == value)
            {
                return;
            }

            connectorHotspot = value;
            
            OnPropertyChanged("ConnectorHotspot");
         }
    }
        
    ...
}

RectangleViewModel 和其他视图模型类派生自 INotifyPropertyChanged。这个接口是 UI 和数据绑定系统了解视图模型更改的协议的一部分。

将连接端点绑定到连接器热点

现在是时候看看连接的视觉效果如何实际数据绑定到两个端点的“连接热点”了。此时,困难的部分——祖先的绑定、连接器热点的绑定和转换——已经完成了。

Connection 数据模板的定义以及连接端点的绑定现在变得微不足道了。

<DataTemplate
    DataType="{x:Type local:Connection}"
    >
    <Line
        Stroke="Black"
        StrokeThickness="1"
        X1="{Binding Rect1.ConnectorHotspot.X}"
        Y1="{Binding Rect1.ConnectorHotspot.Y}"
        X2="{Binding Rect2.ConnectorHotspot.X}"
        Y2="{Binding Rect2.ConnectorHotspot.Y}"
        />               
</DataTemplate>

我使用了一个标准的 WPF Line 作为连接的视觉元素。线条的端点绑定到 Rect1Rect2 的“连接热点”,这两个属性指定了连接的矩形。

我现在可以总结整个计算和数据绑定的序列:ConnectorItem(响应事件)计算其中心点并将其转换到其祖先(Canvas)的坐标系;然后它更新 Hotspot 属性,数据绑定负责将该值推送到 RectangleViewModelConnectorHotspot 属性;接着,这会引发 ConnectorHotspot 的属性更改事件,数据绑定负责更新代表连接的 Line 的端点。

结论

本文介绍了我用来通过视图模型(RectangleViewModelConnectionViewModel 类)将一个 UI 元素(连接器)的位置数据绑定到另一个 UI 元素(连接)的技术。

如果你有任何可以改进这项技术的想法,请告诉我,我会整合它们并更新文章(当然会给予充分的认可)。

如果你喜欢 NetworkView 控件,请继续关注。不久之后,我将在 CodeProject 上发布它。

更新

  • 2010/12/23 - 文章首次发布
© . All rights reserved.