C#9 和 .NET5 高级教程(十五)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

二十六、WPF 图形渲染服务

在这一章中,你将研究 WPF 的图形渲染能力。正如您将看到的,WPF 提供了三种不同的方式来呈现图形数据:形状、绘图和视觉效果。在你理解了每种方法的优缺点之后,你将开始使用System.Windows.Shapes中的类来学习交互式 2D 图形的世界。在此之后,您将看到绘图和几何如何让您以更轻量级的方式渲染 2D 数据。最后,您将了解可视化层如何为您提供最高级别的功能和性能。

在此过程中,您将探索几个相关的主题,例如如何创建自定义画笔和钢笔,如何将图形转换应用到渲染中,以及如何执行点击测试操作。您将看到 Visual Studio 的集成工具和一个名为 Inkscape 的附加工具如何简化您的图形编码工作。

Note

图形是 WPF 发展的一个重要方面。即使您不是在构建图形密集型应用(如视频游戏或多媒体应用),当您使用控件模板、动画和数据绑定自定义等服务时,本章中的主题也是至关重要的。

了解 WPF 的图形渲染服务

WPF 使用了一种特殊风格的图形渲染,称为保留模式图形。简而言之,这意味着既然你正在使用 XAML 或程序代码来生成图形渲染,那么 WPF 的责任就是保存这些可视项目,并确保它们以最佳方式被正确地重绘和刷新。因此,当您呈现图形数据时,它总是存在的,即使最终用户通过调整窗口大小或最小化窗口、用另一个窗口覆盖窗口等方式隐藏图像。

与之形成鲜明对比的是,之前的微软图形渲染 API(包括 Windows Forms 的 GDI+)都是即时模式图形系统。在这个模型中,由程序员来确保渲染的视觉效果在应用的生命周期中被正确地“记住”和更新。例如,在 Windows 窗体应用中,呈现矩形等形状涉及处理Paint事件(或覆盖虚拟的OnPaint()方法),获得一个Graphics对象来绘制矩形,最重要的是,添加基础结构来确保当用户调整窗口大小时图像是持久的(例如,创建成员变量来表示矩形的位置,并在整个程序中调用Invalidate())。

从即时模式到保留模式图形的转变确实是一件好事,因为程序员要创作和维护的图形代码要少得多。然而,我并不是说 WPF 图形 API 与早期的渲染工具包完全不同。例如,像 GDI+一样,WPF 支持各种画笔类型和笔对象、点击测试技术、剪辑区域、图形转换等等。所以,如果你目前有 GDI+(或基于 C/C++的 GDI)的背景,你已经知道了很多关于如何在 WPF 下执行基本渲染的知识。

WPF 图形渲染选项

与 WPF 开发的其他方面一样,除了决定通过 XAML 或过程化 C# 代码(或者两者的结合)来执行图形呈现之外,关于如何执行图形呈现,您还有许多选择。具体来说,WPF 提供了以下三种不同的方式来呈现图形数据:

  • Shapes : WPF 提供了System.Windows.Shapes名称空间,它定义了少量用于渲染 2D 几何对象(矩形、椭圆、多边形等)的类。).虽然这些类型使用简单且功能强大,但如果不加考虑地使用,它们确实会带来相当大的内存开销。

  • 绘图和几何图形:WPF API 提供了第二种呈现图形数据的方式,使用来自System.Windows.Media.Drawing抽象类的后代。使用像GeometryDrawingImageDrawing这样的类(除了各种几何对象,你可以用一种更轻量级(但功能不丰富)的方式呈现图形数据。

  • 视觉效果:在 WPF 下渲染图形数据的最快和最轻量级的方法是使用视觉层,它只能通过 C# 代码访问。使用System.Windows.Media.Visual的后代,您可以直接与 WPF 图形子系统对话。

提供不同方式来完成同一件事情(例如,呈现图形数据)的原因与内存使用以及最终的应用性能有关。因为 WPF 是一个图形密集型系统,所以一个应用在一个窗口的表面上呈现数百甚至数千个不同的图像是合理的,并且实现的选择(形状、绘图或视觉)可能会产生巨大的影响。

请理解,当您构建一个 WPF 应用时,很有可能会用到这三个选项。根据经验,如果你需要适量的可由用户操作的交互式图形数据(接收鼠标输入,显示工具提示等)。),您将需要使用System.Windows.Shapes名称空间中的成员。

相比之下,当您需要使用 XAML 或 C# 对复杂的、通常非交互式的、基于矢量的图形数据建模时,绘图和几何图形更合适。虽然绘图和几何图形仍然可以响应鼠标事件、点击测试和拖放操作,但通常需要编写更多的代码来实现这一点。

最后但同样重要的是,如果您需要尽可能最快的方式来呈现大量的图形数据,那么可视化层是一个不错的选择。例如,假设您正在使用 WPF 构建一个科学应用,它可以绘制出成千上万的数据点。使用视觉图层,您可以尽可能以最佳方式渲染地块点。正如你将在本章后面看到的,可视化层只能通过 C# 代码访问,并且不是 XAML 友好的。

无论您采用哪种方法(形状、绘图和几何图形,或视觉),您都将使用常见的图形原语,如画笔(填充内部)、钢笔(绘制外部)和转换对象(转换数据)。为了开始这个旅程,您将开始使用System.Windows.Shapes的类。

Note

WPF 还附带了一个成熟的 API,可用于渲染和操作 3D 图形,这在本文中没有涉及。

使用形状呈现图形数据

System.Windows.Shapes名称空间的成员提供了最直接、最具交互性、但最占用内存的方式来呈现二维图像。这个名称空间(在PresentationFramework.dll汇编中定义)非常小,只包含六个扩展抽象Shape基类的密封类:EllipseRectangleLinePolygonPolylinePath

抽象的Shape类继承自FrameworkElement,后者继承自UIElement。这些类定义成员来处理大小调整、工具提示、鼠标光标等等。给定这个继承链,当您使用Shape派生类来呈现图形数据时,这些对象的功能(就用户交互性而言)就像 WPF 控件一样!

例如,确定用户是否点击了您的渲染图像并不比处理MouseDown事件更复杂。举个简单的例子,如果你在你最初的WindowGrid中创作了这个Rectangle对象的 XAML:

<Rectangle x:Name="myRect" Height="30" Width="30" Fill="Green" MouseDown="myRect_MouseDown"/>

您可以为MouseDown事件实现一个 C# 事件处理程序,它会在单击时改变矩形的背景颜色,如下所示:

private void myRect_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Change color of Rectangle when clicked.
  myRect.Fill = Brushes.Pink;
}

与您可能使用过的其他图形工具包不同,您不需要而不是编写大量的基础设施代码,这些代码手动将鼠标坐标映射到几何图形,手动计算点击测试,渲染到屏幕外缓冲区,等等。System.Windows.Shapes的成员只是简单地响应你注册的事件,就像一个典型的 WPF 控件(如Button等)。).

所有这些开箱即用的功能的缺点是形状确实会占用大量内存。如果您正在构建一个在屏幕上绘制成千上万个点的科学应用,使用形状将是一个糟糕的选择(本质上,它将与渲染成千上万个Button对象一样占用大量内存!).然而,当你需要生成一个交互式的 2D 矢量图像时,形状是一个很好的选择。

除了从UIElementFrameworkElement父类继承的功能之外,Shape为每个子类定义了许多成员;表 26-1 显示了一些更有用的。

表 26-1。

Shape基类的关键属性

|

性能

|

生命的意义

|
| — | — |
| DefiningGeometry | 返回一个代表当前形状总尺寸的Geometry对象。该对象只包含用于渲染数据的绘图点,没有UIElementFrameworkElement功能的痕迹。 |
| Fill | 允许您指定画笔对象来填充形状的内部。 |
| GeometryTransform | 允许您在图形呈现在屏幕上之前对其应用变换*。继承的RenderTransform属性(来自UIElement)在呈现在屏幕上后应用变换。* |
| Stretch | 描述如何在分配给形状的空间内填充形状,如形状在布局管理器中的位置。这是使用相应的System.Windows.Media.Stretch枚举来控制的。 |
| Stroke | 定义一个画笔对象,或者在某些情况下,定义一个钢笔对象(实际上是一个伪装的画笔),用于绘制形状的边框。 |
| StrokeDashArrayStrokeEndLineCapStrokeStartLineCapStrokeThickness | 这些(和其他)与笔画相关的属性控制在绘制形状的边框时如何配置线条。在大多数情况下,这些属性将配置用于绘制边框或线条的画笔。 |

Note

如果你忘记设置FillStroke属性,WPF 会给你“不可见”的笔刷,因此,这个形状在屏幕上是不可见的!

将矩形、椭圆和线条添加到画布

您将使用 XAML 和 C# 构建一个可以呈现形状的 WPF 应用,并且在此过程中,学习一点关于点击测试的过程。创建一个名为 RenderingWithShapes 的新 WPF 应用,并将标题MainWindow.xaml改为“有趣的形状!”然后更新<Window>的初始 XAML,用包含一个(现在为空)<ToolBar>和一个<Canvas><DockPanel>替换Grid。注意,通过Name属性,每个包含的项目都有一个配件名称。

<DockPanel LastChildFill="True">
  <ToolBar DockPanel.Dock="Top" Name="mainToolBar" Height="50">
  </ToolBar>
  <Canvas Background="LightBlue" Name="canvasDrawingArea"/>
</DockPanel>

现在,用一组<RadioButton>对象填充<ToolBar>,每个对象包含一个特定的Shape派生类作为内容。请注意,每个<RadioButton>都被分配给同一个GroupName(以确保互斥性),并且还被赋予了一个合适的名称。

<ToolBar DockPanel.Dock="Top" Name="mainToolBar" Height="50">
  <RadioButton Name="circleOption" GroupName="shapeSelection" Click="CircleOption_Click">
    <Ellipse Fill="Green" Height="35" Width="35" />
  </RadioButton>
  <RadioButton Name="rectOption" GroupName="shapeSelection" Click="RectOption_Click">
    <Rectangle Fill="Red" Height="35" Width="35" RadiusY="10" RadiusX="10" />
  </RadioButton>
  <RadioButton Name="lineOption" GroupName="shapeSelection" Click="LineOption_Click">
    <Line Height="35" Width="35" StrokeThickness="10" Stroke="Blue"
          X1="10" Y1="10" Y2="25" X2="25"
          StrokeStartLineCap="Triangle" StrokeEndLineCap="Round" />
  </RadioButton>
</ToolBar>

如您所见,在 XAML 声明RectangleEllipseLine对象非常简单,几乎不需要注释。回想一下,Fill属性用于指定画笔来绘制形状的内部。当需要纯色画笔时,只需指定一个已知值的硬编码字符串,底层类型转换器就会生成正确的对象。Rectangle类型的一个有趣的特性是它定义了RadiusXRadiusY属性来允许你渲染弯曲的角落。

Line使用X1X2Y1Y2属性表示其起点和终点(假设高度宽度在描述一条线时没有什么意义)。在这里,您设置了几个附加属性来控制如何呈现Line的起点和终点,以及如何配置笔画设置。图 26-1 显示了通过 Visual Studio WPF 设计器看到的渲染工具栏。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-1。

使用形状作为一组RadioButtons的内容

现在,使用 Visual Studio 的属性窗口,为Canvas处理MouseLeftButtonDown事件,为每个RadioButton处理Click事件。在您的 C# 文件中,您的目标是当用户在Canvas中单击时呈现选定的形状(圆形、正方形或直线)。首先,在您的Window-派生类中定义下面的嵌套enum(以及相应的成员变量):

public partial class MainWindow : Window
{
  private enum SelectedShape
  { Circle, Rectangle, Line }
  private SelectedShape _currentShape;
}

在每个Click事件处理程序中,将currentShape成员变量设置为正确的SelectedShape值,如下所示:

private void CircleOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Circle;
}

private void RectOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Rectangle;
}

private void LineOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Line;
}

使用CanvasMouseLeftButtonDown事件处理程序,您将使用鼠标光标的 X,Y 位置作为起点,渲染出正确的形状(预定义大小)。下面是完整的实现,分析如下:

private void CanvasDrawingArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  Shape shapeToRender = null;
  // Configure the correct shape to draw.
  switch (_currentShape)
  {
    case SelectedShape.Circle:
      shapeToRender = new Ellipse() { Fill = Brushes.Green, Height = 35, Width = 35 };
      break;
    case SelectedShape.Rectangle:
      shapeToRender = new Rectangle()
        { Fill = Brushes.Red, Height = 35, Width = 35, RadiusX = 10, RadiusY = 10 };
      break;
    case SelectedShape.Line:
      shapeToRender = new Line()
      {
        Stroke = Brushes.Blue,
        StrokeThickness = 10,
        X1 = 0, X2 = 50, Y1 = 0, Y2 = 50,
        StrokeStartLineCap= PenLineCap.Triangle,
        StrokeEndLineCap = PenLineCap.Round
      };
      break;
    default:
      return;
  }
  // Set top/left position to draw in the canvas.
  Canvas.SetLeft(shapeToRender, e.GetPosition(canvasDrawingArea).X);
  Canvas.SetTop(shapeToRender, e.GetPosition(canvasDrawingArea).Y);
  // Draw shape!
  canvasDrawingArea.Children.Add(shapeToRender);
}

Note

您可能会注意到,在这个方法中创建的EllipseRectangleLine对象与相应的 XAML 定义具有相同的属性设置!正如你所希望的,你可以简化这些代码,但是这需要理解 WPF 对象资源,这将在第二十七章中讨论。

如您所见,您正在测试currentShape成员变量以创建正确的Shape派生对象。在这之后,使用传入的MouseButtonEventArgs设置Canvas中左上角的值。最后但同样重要的是,您将新的Shape派生类型添加到由Canvas维护的UIElement对象集合中。如果您现在运行您的程序,您应该能够单击画布中的任何位置,并看到在鼠标左键单击的位置呈现的所选形状。

从画布中移除矩形、椭圆和线条

有了维护对象集合的Canvas,您可能想知道如何动态地移除一个项目,也许是为了响应用户右击一个形状。您当然可以使用名为VisualTreeHelperSystem.Windows.Media名称空间中的类来实现这一点。第二十七章将详细解释“视觉树”和“逻辑树”的作用。在此之前,您可以处理您的Canvas对象上的MouseRightButtonDown事件,并实现相应的事件处理程序,如下所示:

private void CanvasDrawingArea_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
  // First, get the X,Y location of where the user clicked.
  Point pt = e.GetPosition((Canvas)sender);
  // Use the HitTest() method of VisualTreeHelper to see if the user clicked
  // on an item in the canvas.
  HitTestResult result = VisualTreeHelper.HitTest(canvasDrawingArea, pt);
  // If the result is not null, they DID click on a shape!
  if (result != null)
  {
      // Get the underlying shape clicked on, and remove it from
      // the canvas.
      canvasDrawingArea.Children.Remove(result.VisualHit as Shape);
  }
}

该方法首先获取用户在Canvas中点击的准确的 X,Y 位置,并通过静态VisualTreeHelper.HitTest()方法执行点击测试操作。如果用户没有点击Canvas中的UIElement,返回值HitTestResult对象将被设置为空。如果HitTestResult而不是 null,你可以通过VisualHit属性获得被点击的底层UIElement,你将它转换成一个Shape派生的对象(记住,Canvas可以保存任何UIElement,而不仅仅是形状!).同样,在下一章中你会得到更多关于“视觉树”的细节。

Note

默认情况下,VisualTreeHelper.HitTest()返回被点击的最上面的UIElement,不提供该项下面的其他对象的信息(例如,按 Z 顺序重叠的对象)。

通过这一修改,您应该能够用鼠标左键在画布上添加一个形状,用鼠标右键从画布上删除一个项目!

目前为止,一切顺利。至此,您已经使用 XAML 使用Shape派生的对象在RadioButton上呈现内容,并使用 C# 填充了一个Canvas。当您检查画笔和图形转换的作用时,您将向这个示例添加更多的功能。与此相关,本章中的另一个例子将说明在UIElement对象上的拖放技术。在那之前,让我们检查一下System.Windows.Shapes的剩余成员。

使用多段线和多边形

当前的例子只使用了三个Shape派生类。其余的子类(PolylinePolygonPath)在没有工具支持的情况下(例如 Microsoft Blend,为 WPF 开发人员设计的 Visual Studio 的配套工具,或其他可以创建矢量图形的工具),要正确渲染极其繁琐,因为它们需要大量的绘图点来表示它们的输出。以下是其余Shapes类型的概述。

Polyline类型允许您定义一组(x,y)坐标(通过Points属性)来绘制一系列不需要连接端点的线段。Polygon型也差不多;但是,它被编程为总是关闭起点和终点,并用指定的画笔填充内部。假设您已经在 Kaxaml 编辑器中编写了下面的<StackPanel>:

<!-- Polylines do not automatically connect the ends. -->
<Polyline Stroke ="Red" StrokeThickness ="20" StrokeLineJoin ="Round" Points ="10,10 40,40 10,90 300,50"/>
<!-- A Polygon always closes the end points. -->
<Polygon Fill ="AliceBlue" StrokeThickness ="5" Stroke ="Green" Points ="40,10 70,80 10,50" />

图 26-2 显示了 Kaxaml 的渲染输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-2。

多边形和折线

使用路径

单独使用RectangleEllipsePolygonPolylineLine类型来绘制详细的 2D 矢量图像会非常复杂,因为这些图元不允许您轻松地捕捉图形数据,如曲线、重叠数据的联合等。最后一个Shape派生类Path,提供了定义复杂的 2D 图形数据的能力,这些数据被表示为一组独立的几何图形。在您定义了这样的几何图形集合之后,您可以将它们分配给Path类的Data属性,其中的信息将用于呈现复杂的 2D 图像。

Data属性采用一个System.Windows.Media.Geometry派生类,包含表 26-2 中描述的关键成员。

表 26-2。

选择System.Windows.Media.Geometry类型的成员

|

成员

|

生命的意义

|
| — | — |
| Bounds | 建立包含几何图形的当前边框。 |
| FillContains() | 确定给定的Point(或其他Geometry对象)是否在特定的Geometry派生类的范围内。这对于点击测试计算很有用。 |
| GetArea() | 返回一个Geometry派生类型占据的整个区域。 |
| GetRenderBounds() | 返回一个Rect,它包含可能用于呈现Geometry派生类的最小矩形。 |
| Transform | 给几何体分配一个Transform对象,以改变渲染。 |

扩展Geometry的类(见表 26-3 )看起来非常像它们的Shape派生的对应类。例如,EllipseGeometry的成员与Ellipse相似。最大的区别是Geometry的派生类不知道如何直接呈现它们自己,因为它们不是UIElement的。相反,Geometry的派生类只代表一个绘图点数据的集合,实际上是说“如果一个Path使用我的数据,这就是我如何呈现自己。”

表 26-3。

Geometry-派生类

|

几何课

|

生命的意义

|
| — | — |
| LineGeometry | 代表一条直线 |
| RectangleGeometry | 表示一个矩形 |
| EllipseGeometry | 表示一个椭圆 |
| GeometryGroup | 允许您对几个Geometry对象进行分组 |
| CombinedGeometry | 允许您将两个不同的Geometry对象合并成一个形状 |
| PathGeometry | 表示由直线和曲线组成的图形 |

Note

不是 WPF 唯一可以使用几何图形集合的类。例如,DoubleAnimationUsingPathDrawingGroupGeometryDrawing,甚至UIElement都可以使用几何图形进行渲染,分别使用PathGeometryClipGeometryGeometryClip属性。

下面是一个使用了一些Geometry派生类型的Path。请注意,您正在将PathData属性设置为一个GeometryGroup对象,该对象包含其他Geometry派生的对象,如EllipseGeometryRectangleGeometryLineGeometry。图 26-3 显示了输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-3。

包含各种Geometry对象的路径

<!-- A Path contains a set of geometry objects, set with the Data property. -->
<Path Fill = "Orange" Stroke = "Blue" StrokeThickness = "3">
  <Path.Data>
    <GeometryGroup>
      <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
    <RectangleGeometry Rect = "25,55 100 30" />
    <LineGeometry StartPoint="0,0" EndPoint="70,30" />
    <LineGeometry StartPoint="70,30" EndPoint="0,30" />
  </GeometryGroup>
  </Path.Data>
</Path>

图 26-3 中的图像可以使用之前显示的LineEllipseRectangle类来渲染。然而,这会将各种UIElement对象放到内存中。当您使用几何图形对要绘制的绘图点进行建模,然后将几何图形集合放入一个可以呈现数据的容器(在本例中为Path)中时,您可以减少内存开销。

现在回想一下,PathSystem.Windows.Shapes的任何其他成员具有相同的继承链,因此可以发送与其他UIElement对象相同的事件通知。因此,如果您要在 Visual Studio 项目中定义这个相同的<Path>元素,您可以通过处理鼠标事件来确定用户是否单击了扫描行中的任何位置(记住,Kaxaml 不允许您处理您所创作的标记的事件)。

“微型语言”的路径建模

在表 26-3 中列出的所有类中,PathGeometry在 XAML 或代码方面是最复杂的。这与PathGeometry的每个都是由包含各种段和图形的对象组成的(如ArcSegmentBezierSegmentLineSegmentPolyBezierSegmentPolyLineSegmentPolyQuadraticBezierSegment等)。).下面是一个Path对象的示例,其Data属性已被设置为由各种图形和线段组成的<PathGeometry>:

<Path Stroke="Black" StrokeThickness="1" >
  <Path.Data>
    <PathGeometry>
      <PathGeometry.Figures>
        <PathFigure StartPoint="10,50">
          <PathFigure.Segments>
           <BezierSegment
             Point1="100,0"
             Point2="200,200"
             Point3="300,100"/>
           <LineSegment Point="400,100" />
           <ArcSegment
             Size="50,50" RotationAngle="45"
             IsLargeArc="True" SweepDirection="Clockwise"
             Point="200,100"/>
           </PathFigure.Segments>
        </PathFigure>
      </PathGeometry.Figures>
    </PathGeometry>
  </Path.Data>
</Path>

现在,说实话,很少有程序员需要通过直接描述GeometryPathSegment派生类来手工构建复杂的 2D 映像。在本章的后面,你将学习如何将矢量图形转换成可以在 XAML 中使用的路径语句。

即使有这些工具的帮助,定义一个复杂的Path对象所需的 XAML 量也将是可怕的,因为数据由各种GeometryPathSegment派生类的完整描述组成。为了产生更简洁紧凑的标记,Path类被设计成能够理解一种专门的“迷你语言”

例如,与其将PathData属性设置为GeometryPathSegment派生类型的集合,不如将Data属性设置为包含许多已知符号和定义要呈现的形状的各种值的单个字符串文字。下面是一个简单的例子,结果输出如图 26-4 所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-4。

Path 微型语言允许您简洁地描述一个Geometry/PathSegment对象模型

<Path Stroke="Black" StrokeThickness="3" Data="M 10,75 C 70,15 250,270 300,175 H 240" />

M命令(简称移动)取一个 X,Y 位置,代表绘图的起点。C命令采用一系列绘图点来绘制一条 c 曲线(确切地说是一条三次贝塞尔曲线),而H绘制一条水平线

现在,老实说,您需要手动构建或解析包含 Path 微型语言指令的字符串文字的机会微乎其微。然而,至少,当你看到 XAML 开发的专用工具时,你不会再感到惊讶了。

WPF 画笔和钢笔

每个 WPF 图形渲染选项(形状,绘图和几何图形,视觉效果)都大量使用了笔刷,它允许你控制 2D 表面的内部是如何填充的。WPF 提供了六种不同的笔刷类型,它们都扩展了System.Windows.Media.Brush。虽然Brush是抽象的,但是表 26-4 中描述的后代可以用来填充一个区域,几乎可以用任何可以想到的选项。

表 26-4。

WPFBrush-衍生类型

|

刷型

|

生命的意义

|
| — | — |
| DrawingBrush | 用从Drawing派生的对象(GeometryDrawingImageDrawingVideoDrawing)绘制区域 |
| ImageBrush | 用图像绘制一个区域(由一个ImageSource对象表示) |
| LinearGradientBrush | 用线性渐变绘制区域 |
| RadialGradientBrush | 用径向渐变绘制区域 |
| SolidColorBrush | 使用Color属性设置,绘制单一颜色 |
| VisualBrush | 用从Visual派生的对象(DrawingVisualViewport3DVisualContainerVisual)绘制一个区域 |

DrawingBrushVisualBrush类允许你基于现有的DrawingVisual派生类来构建画笔。当你使用 WPF 的另外两个图形选项(绘图或视觉)时,会用到这些笔刷类,我们将在本章的后面进行讨论。

顾名思义,ImageBrush通过设置ImageSource属性,让您构建一个显示来自外部文件或嵌入式应用资源的图像数据的画笔。剩下的笔刷类型(LinearGradientBrushRadialGradientBrush)很容易使用,尽管输入所需的 XAML 可能有点冗长。幸运的是,Visual Studio 支持集成的画笔编辑器,这使得生成风格化的画笔变得简单。

使用 Visual Studio 配置画笔

让我们更新你的 WPF 绘图程序,RenderingWithShapes,使用一些更有趣的笔刷。到目前为止,您用来在工具栏上呈现数据的三个形状都使用简单的纯色,因此您可以使用简单的字符串来获取它们的值。为了增加一点趣味,你现在将使用集成的笔刷编辑器。确保初始窗口的 XAML 编辑器是 IDE 中打开的窗口,并选择Ellipse元素。现在,在属性窗口中,定位笔刷类别,然后点击顶部列出的Fill属性(参见图 26-5 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-5。

任何需要画笔的属性都可以用集成的画笔编辑器来配置

在画笔编辑器的顶部,你会看到一组属性,它们都是所选项目的“画笔兼容的”(例如,FillStrokeOpacityMask)。在这下面,你会看到一系列的标签,允许你配置不同类型的画笔,包括当前的纯色画笔。您可以使用颜色选择器工具以及 ARGB (alpha、红色、绿色和蓝色,其中“alpha”控制透明度)编辑器来控制当前画笔的颜色。使用这些滑块和相关的颜色选择区域,您可以创建任何种类的纯色。使用这些工具来改变你的EllipseFill颜色,并查看生成的 XAML。您会注意到颜色是以十六进制值存储的,如下所示:

<Ellipse Fill="#FF47CE47" Height="35" Width="35" />

更有趣的是,这个编辑器允许您配置渐变画笔,用于定义一系列颜色和过渡点。回想一下,这个笔刷编辑器为您提供了一组选项卡,其中的第一个选项卡允许您为无渲染输出设置一个空笔刷。其他四个允许你设置一个纯色笔刷(你刚刚检查的),渐变笔刷,拼贴笔刷,或者图像笔刷。

点击渐变画笔按钮,编辑器会显示一些新的选项(见图 26-6 )。左下方的三个按钮允许您选择线性渐变、径向渐变或反转渐变停止点。最底部的条将显示每个渐变停止点的当前颜色,每个渐变停止点都由条上的“拇指”标记。当您在渐变条周围拖移这些滑块时,您可以控制渐变偏移。此外,当您单击给定的缩略图时,您可以通过颜色选择器更改特定渐变停止点的颜色。最后,如果您直接点按渐变条,您可以添加渐变停止点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-6。

Visual Studio 画笔编辑器允许您构建基本的渐变画笔

花几分钟时间使用这个编辑器来创建一个包含三个渐变停止点的径向渐变画笔,设置为你选择的颜色。图 26-6 显示了你刚刚构建的笔刷,使用了三种不同的绿色。

完成后,IDE 将使用自定义画笔更新您的 XAML,使用属性元素语法设置为画笔兼容属性(本例中为EllipseFill属性),如下所示:

<Ellipse Height="35" Width="35">
  <Ellipse.Fill>
    <RadialGradientBrush>
      <GradientStop Color="#FF17F800"/>
      <GradientStop Color="#FF24F610" Offset="1"/>
      <GradientStop Color="#FF1A6A12" Offset="0.546"/>
    </RadialGradientBrush>
   </Ellipse.Fill>
</Ellipse>

在代码中配置画笔

现在你已经为你的Ellipse的 XAML 定义构建了一个自定义笔刷,相应的 C# 代码已经过时了,因为它仍然会渲染一个实心的绿色圆圈。为了同步备份,更新正确的case语句来使用你刚刚创建的笔刷。以下是必要的更新,看起来比你想象的要复杂,因为你正在通过System.Windows.Media.ColorConverter类将十六进制值转换成一个合适的Color对象(修改后的输出见图 26-7 ):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-7。

用更多的活力画圆

case SelectedShape.Circle:
  shapeToRender = new Ellipse() { Height = 35, Width = 35 };
  // Make a RadialGradientBrush in code!
  RadialGradientBrush brush = new RadialGradientBrush();
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF77F177"), 0));
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF11E611"), 1));
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF5A8E5A"), 0.545));
  shapeToRender.Fill = brush;
  break;

顺便说一下,您可以通过使用Colors枚举指定一个简单的颜色作为第一个构造函数参数来构建GradientStop对象,这将返回一个已配置的Color对象。

GradientStop g = new GradientStop(Colors.Aquamarine, 1);

或者,如果您需要更精细的控制,您可以传入一个已配置的Color对象,如下所示:

Color myColor = new Color() { R = 200, G = 100, B = 20, A = 40 };
GradientStop g = new GradientStop(myColor, 34);

当然,Colors枚举和Color类并不局限于渐变画笔。您可以在任何需要用代码表示颜色值的时候使用它们。

配置笔

与画笔相比,是用于绘制几何图形边界的对象,或者在LinePolyLine类的情况下,是线条几何图形本身。具体来说,Pen类允许你绘制一个指定的厚度,用一个double值表示。此外,Pen可以配置与Shape类相同的属性,比如开始和停止笔帽、点划线图案等等。例如,您可以将以下标记添加到形状中,以定义钢笔属性:

<Pen Thickness="10" LineJoin="Round" EndLineCap="Triangle" StartLineCap="Round" />

在许多情况下,您不需要直接创建一个Pen对象,因为这将在您为属性赋值时间接完成,例如将StrokeThickness赋值给一个Shape派生的类型(以及其他的UIElements)。然而,当使用Drawing派生的类型时,构建一个定制的Pen对象是很方便的(在本章后面会有描述)。Visual Studio 没有笔编辑器本身,但是它允许您使用属性窗口配置选定项的所有以笔画为中心的属性。

应用图形转换

为了总结使用形状的讨论,让我们讨论一下转换的话题。WPF 附带了许多扩展抽象基类的类。表 26-5 记录了许多关键的现成的Transform派生类。

表 26-5。

System.Windows.Media.Transform类型的主要后代

|

类型

|

生命的意义

|
| — | — |
| MatrixTransform | 创建任意矩阵变换,用于操作 2D 平面中的对象或坐标系 |
| RotateTransform | 围绕 2D (x,y)坐标系中的指定点顺时针旋转对象 |
| ScaleTransform | 在 2D (x,y)坐标系中缩放对象 |
| SkewTransform | 在 2D (x,y)坐标系中倾斜对象 |
| TranslateTransform | 在 2D (x,y)坐标系中平移(移动)对象 |
| TransformGroup | 表示由其他Transform对象组成的复合Transform |

变换可以应用于任何UIElement(例如,Shape的后代以及诸如Button控件、TextBox控件等控件)。使用这些转换类,您可以以给定的角度呈现图形数据,在表面上倾斜图像,并以各种方式扩展、收缩或翻转目标项目。

Note

虽然变换对象可以在任何地方使用,但您会发现它们在处理 WPF 动画和自定义控件模板时最有用。正如您将在本章后面看到的,您可以使用 WPF 动画为自定义控件的最终用户提供视觉提示。

可以将变换(或一整套变换)分配给目标对象(例如,ButtonPath等)。)使用两个共同的属性,LayoutTransformRenderTransform

LayoutTransform属性是有帮助的,因为转换发生在元素被呈现到布局管理器之前的*,因此转换不会影响 Z 排序操作(换句话说,转换后的图像数据不会重叠)。*

另一方面,RenderTransform属性发生在项目进入它们的容器之后,因此很有可能元素可以根据它们在容器中的排列方式以相互重叠的方式进行转换。

转换的初步观察

一会儿,您将为您的RenderingWithShapes项目添加一些转换逻辑。然而,要查看实际的转换对象,打开 Kaxaml,在根PageWindow中定义一个简单的StackPanel,并将Orientation属性设置为Horizontal。现在,添加下面的Rectangle,它将使用RotateTransform对象以 45 度角绘制:

<!-- A Rectangle with a rotate transformation. -->
<Rectangle Height ="100" Width ="40" Fill ="Red">
  <Rectangle.LayoutTransform>
    <RotateTransform Angle ="45"/>
  </Rectangle.LayoutTransform>
</Rectangle>

这里有一个<Button>在表面上倾斜了 20 度,使用的是一个<SkewTransform>:

<!-- A Button with a skew transformation. -->
<Button Content ="Click Me!" Width="95" Height="40">
  <Button.LayoutTransform>
   <SkewTransform AngleX ="20" AngleY ="20"/>
  </Button.LayoutTransform>
</Button>

为了更好地测量,这里有一个用ScaleTransform缩放了 20 度的Ellipse(注意设置为初始HeightWidth的值),以及一个应用了一组变换对象的TextBox:

<!-- An Ellipse that has been scaled by 20%. -->
<Ellipse Fill ="Blue" Width="5" Height="5">
  <Ellipse.LayoutTransform>
    <ScaleTransform ScaleX ="20" ScaleY ="20"/>
  </Ellipse.LayoutTransform>
</Ellipse>
<!-- A TextBox that has been rotated and skewed. -->
<TextBox Text ="Me Too!" Width="50" Height="40">
  <TextBox.LayoutTransform>
    <TransformGroup>
      <RotateTransform Angle ="45"/>
      <SkewTransform AngleX ="5" AngleY ="20"/>
    </TransformGroup>
  </TextBox.LayoutTransform>
</TextBox>

请注意,当应用转换时,您不需要执行任何手动计算来正确地响应点击测试、输入焦点等等。WPF 图形引擎代表你处理这样的任务。例如,在图 26-8 中,你可以看到TextBox仍然对键盘输入有反应。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-8。

图形转换对象的结果

转换您的画布数据

现在,让我们将一些转换逻辑合并到你的 RenderingWithShapes 示例中。除了将变换对象应用于单个项目(例如,RectangleTextBox等)。),您也可以将转换对象应用于布局管理器,以转换所有内部数据。例如,你可以以一个角度渲染主窗口的整个DockPanel

<DockPanel LastChildFill="True">
  <DockPanel.LayoutTransform>
    <RotateTransform Angle="45"/>
  </DockPanel.LayoutTransform>
...
</DockPanel>

对于这个例子来说这有点极端,所以让我们添加一个最终的(不太激进的)特性,允许用户翻转整个Canvas和所有包含的图形。首先将最后一个ToggleButton添加到您的ToolBar中,定义如下:

<ToggleButton Name="flipCanvas" Click="FlipCanvas_Click" Content="Flip Canvas!"/>

Click事件处理程序中,创建一个RotateTransform对象,如果这个新的ToggleButton被点击,通过LayoutTransform属性将它连接到Canvas对象。如果没有点击ToggleButton,通过将相同的属性设置为null来移除转换。

private void FlipCanvas_Click(object sender, RoutedEventArgs e)
{
  if (flipCanvas.IsChecked == true)
  {
    RotateTransform rotate = new RotateTransform(-180);
    canvasDrawingArea.LayoutTransform = rotate;
  }
  else
  {
    canvasDrawingArea.LayoutTransform = null;
  }
}

运行您的应用,在整个画布区域添加一堆图形,确保它们并排。如果你点击你的新按钮,你会发现形状数据超出了画布的边界!这是因为你没有定义一个裁剪区域(见图 26-9 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-9。

哎呀!在转换之后,您的数据正在流出画布!

解决这个问题很简单。不需要手工编写复杂的裁剪逻辑代码,只需将CanvasClipToBounds属性设置为true,这样可以防止子元素被呈现在父元素的边界之外。如果你再次运行你的程序,你会发现数据不会从画布边界溢出。

<Canvas ClipToBounds = "True" ... >

要做的最后一个微小的修改是,当您通过按下切换按钮翻转画布,然后单击画布来绘制新形状时,您单击的点是而不是应用图形数据的点。相反,数据呈现在鼠标光标上方。

要解决这个问题,在渲染发生之前,将同一个变换对象应用到正在绘制的形状(通过RenderTransform)。代码的关键在于:

private void CanvasDrawingArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  //omitted for brevity
  if (flipCanvas.IsChecked == true)
  {
    RotateTransform rotate = new RotateTransform(-180);
    shapeToRender.RenderTransform = rotate;
  }
  // Set top/left to draw in the canvas.
  Canvas.SetLeft(shapeToRender,
    e.GetPosition(canvasDrawingArea).X);
  Canvas.SetTop(shapeToRender,
    e.GetPosition(canvasDrawingArea).Y);

  // Draw shape!
  canvasDrawingArea.Children.Add(shapeToRender);
}

这就完成了对System.Windows.Shapes、笔刷和变换的检查。在查看使用绘图和几何图形呈现图形的作用之前,让我们看看如何使用 Visual Studio 来简化处理基本图形的方式。

使用 Visual Studio 转换编辑器

在前面的示例中,您通过手动输入标记和创作一些 C# 代码来应用各种转换。虽然这肯定是有用的,但是您会很高兴地知道最新版本的 Visual Studio 附带了一个集成的转换编辑器。回想一下,任何 UI 元素都可以成为转换服务的接受者,包括包含各种 UI 元素的布局系统。为了演示 Visual Studio 的转换编辑器的用法,创建一个名为 FunWithTransforms 的新 WPF 应用。

构建初始布局

首先,使用集成的网格编辑器将最初的Grid分成两列(具体大小无关紧要)。现在,在你的工具箱中找到StackPanel控件,并添加它以占据Grid第一列的整个空间;然后给StackPanel添加三个Button控件,像这样:

<Grid>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="*"/>
    <ColumnDefinition Width="*"/>
  </Grid.ColumnDefinitions>
  <StackPanel Grid.Row="0" Grid.Column="0">
    <Button Name="btnSkew" Content="Skew" Click="Skew"/>
    <Button Name="btnRotate" Content="Rotate" Click="Rotate"/>
    <Button Name="btnFlip" Content="Flip" Click="Flip"/>
  </StackPanel>
</Grid>

将按钮的处理程序添加到代码页,如下所示:

private void Skew(object sender, RoutedEventArgs e)
{
}
private void Rotate(object sender, RoutedEventArgs e)
{
}
private void Flip(object sender, RoutedEventArgs e)
{
}

为了完成 UI,创建一个您选择的图形(使用本章讨论的任何技术),定义在Grid的第二列。示例中使用的标记如下所示:

<Canvas x:Name="myCanvas" Grid.Column="1" Grid.Row="0">
  <Ellipse HorizontalAlignment="Left" VerticalAlignment="Top"
       Height="186"  Width="92" Stroke="Black"
       Canvas.Left="20" Canvas.Top="31">
    <Ellipse.Fill>
      <RadialGradientBrush>
        <GradientStop Color="#FF951ED8" Offset="0.215"/>
        <GradientStop Color="#FF2FECB0" Offset="1"/>
      </RadialGradientBrush>
    </Ellipse.Fill>
  </Ellipse>
  <Ellipse HorizontalAlignment="Left" VerticalAlignment="Top"
       Height="101" Width="110" Stroke="Black"
       Canvas.Left="122" Canvas.Top="126">
    <Ellipse.Fill>
      <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
        <GradientStop Color="#FFB91DDC" Offset="0.355"/>
        <GradientStop Color="#FFB0381D" Offset="1"/>
      </LinearGradientBrush>
    </Ellipse.Fill>
  </Ellipse>
</Canvas>

图 26-10 显示了示例的最终布局。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-10。

您的转换示例的布局

在设计时应用转换

如前所述,Visual Studio 提供了一个集成的转换编辑器,它可以在属性面板中找到。找到该区域,并确保展开转换部分以查看编辑器的 RenderTransform 和 LayoutTransform 部分(参见图 26-11 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-11。

转换编辑器

与画笔部分类似,变换部分提供了许多选项卡来配置对当前所选项目的各种类型的图形变换。表 26-6 描述了每个转换选项,按照从左到右评估每个选项卡的顺序列出。

表 26-6

混合变换选项

|

转换选项

|

生命的意义

|
| — | — |
| 翻译 | 允许您在 X,Y 位置上偏移项目的位置。 |
| 辐状的 | 允许您将项目旋转 360 度。 |
| 规模 | 允许您在 X 和 Y 方向上放大或缩小项目。 |
| 斜交 | 允许您将包含选定项目的边界框在 X 和 Y 方向上倾斜一个因子。 |
| 中心点 | 当您旋转或翻转对象时,该项目相对于一个固定点移动,该点称为对象的中心点。默认情况下,对象的中心点位于对象的中心;但是,这种变换允许您更改对象的中心点,以围绕不同的点旋转或翻转对象。 |
| 翻转 | 基于 X 或 Y 中心点翻转选定项目。 |

我建议您使用您的自定义形状作为目标来测试这些转换中的每一个(只需按 Ctrl+Z 来撤消前面的操作)。像 Transform Properties 面板的许多其他方面一样,每个转换部分都有一组独特的配置选项,当您修改时,这些选项应该变得很容易理解。例如,倾斜变换编辑器允许您设置 X 和 Y 倾斜值,翻转变换编辑器允许您在 X 轴或 Y 轴上翻转,等等。

用代码转换画布

每个Click事件处理程序的实现或多或少是相同的。您将配置一个转换对象,并将其分配给myCanvas对象。然后,当您运行应用时,您可以单击一个按钮来查看应用转换的结果。以下是每个事件处理程序的完整代码(注意,您正在设置LayoutTransform属性,因此形状数据保持相对于父容器的位置):

private void Flip(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new ScaleTransform(-1, 1);
}

private void Rotate(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new RotateTransform(180);
}

private void Skew(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new SkewTransform(40, -20);
}

使用绘图和几何图形呈现图形数据

虽然Shape类型允许你生成任何类型的交互式二维表面,但是由于它们丰富的继承链,它们需要相当多的内存开销。虽然Path类可以使用包含的几何图形(而不是其他形状的大量集合)来帮助消除一些开销,但 WPF 提供了一个复杂的绘图和几何图形编程接口,可以呈现更轻量级的 2D 矢量图像。

这个 API 的入口点是抽象的System.Windows.Media.Drawing类(在PresentationCore.dll中),它本身只不过是定义一个边界矩形来保存渲染。鉴于UIElementFrameworkElement都不在继承链中,Drawing类明显比Shape更轻量级。

WPF 提供了各种扩展Drawing的类,每个类都代表了一种绘制内容的特定方式,如表 26-7 中所述。

表 26-7

WPFDrawing-衍生类型

|

类型

|

生命的意义

|
| — | — |
| DrawingGroup | 用于将一组独立的Drawing派生对象组合成一个单一的合成渲染。 |
| GeometryDrawing | 用于以非常轻量级的方式渲染 2D 图形。 |
| GlyphRunDrawing | 用于使用 WPF 图形呈现服务呈现文本数据。 |
| ImageDrawing | 用于将图像文件或几何体集渲染到边框中。 |
| VideoDrawing | 用于播放音频文件或视频文件。只有使用过程代码才能充分利用这种类型。如果你想通过 XAML 播放视频,MediaPlayer型是更好的选择。 |

因为它们更轻量级,Drawing派生的类型不具有处理输入事件的内在支持,因为它们不是UIElementFrameworkElement(尽管有可能以编程方式执行点击测试逻辑)。

Drawing派生的类型和从Shape派生的类型之间的另一个关键区别是,从Drawing派生的类型没有能力呈现它们自己,因为它们不是从UIElement派生的!相反,派生类型必须放在宿主对象中(特别是,DrawingImageDrawingBrushDrawingVisual)才能显示它们的内容。

DrawingImage允许你在 WPF Image控件中放置图形和几何图形,该控件通常用于显示来自外部文件的数据。DrawingBrush允许您基于绘图及其几何图形构建画笔,以设置需要画笔的属性。最后,DrawingVisual只用于图形渲染的“视觉”层,完全通过 C# 代码驱动。

虽然使用绘图比使用简单形状要复杂一些,但是这种图形合成与图形呈现的分离使得Drawing派生类型比Shape派生类型更加轻量级,同时仍然保留了关键服务。

使用几何图形构建画笔

在本章的前面,您用一组几何图形填充了一个Path,就像这样:

<Path Fill = "Orange" Stroke = "Blue" StrokeThickness = "3">
  <Path.Data>
    <GeometryGroup>
      <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
    <RectangleGeometry Rect = "25,55 100 30" />
    <LineGeometry StartPoint="0,0" EndPoint="70,30" />
    <LineGeometry StartPoint="70,30" EndPoint="0,30" />
  </GeometryGroup>
  </Path.Data>
</Path>

通过这样做,你从Path获得了交互性,但是考虑到你的几何形状,仍然是相当轻量级的。但是,如果您想要呈现相同的输出,并且不需要任何(现成的)交互性,您可以将相同的<GeometryGroup>放在DrawingBrush中,如下所示:

<DrawingBrush>
  <DrawingBrush.Drawing>
    <GeometryDrawing>
      <GeometryDrawing.Geometry>
        <GeometryGroup>
            <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
            <RectangleGeometry Rect = "25,55 100 30" />
            <LineGeometry StartPoint="0,0" EndPoint="70,30" />
            <LineGeometry StartPoint="70,30" EndPoint="0,30" />
          </GeometryGroup>
        </GeometryDrawing.Geometry>
        <!-- A custom pen to draw the borders. -->
        <GeometryDrawing.Pen>
           <Pen Brush="Blue" Thickness="3"/>
        </GeometryDrawing.Pen>
        <!-- A custom brush to fill the interior. -->
        <GeometryDrawing.Brush>
          <SolidColorBrush Color="Orange"/>
        </GeometryDrawing.Brush>
      </GeometryDrawing>
    </DrawingBrush.Drawing>
</DrawingBrush>

当您将一组几何图形放入DrawingBrush时,您还需要建立用于绘制边界的Pen对象,因为您不再从Shape基类继承Stroke属性。在这里,您创建了一个<Pen>,其设置与上一个Path示例中的StrokeStrokeThickness值相同。

此外,由于您不再从Shape继承一个Fill属性,您还需要使用属性元素语法来定义一个用于<DrawingGeometry>的笔刷对象,这里是一个纯色的橙色笔刷,就像前面的Path设置一样。

用画笔画画

现在您有了一个DrawingBrush,您可以用它来设置任何需要 brush 对象的属性的值。例如,如果您在 Kaxaml 中创作这个标记,您可以使用属性元素语法在一个Page的整个表面上绘制您的图形,如下所示:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Page.Background>
    <DrawingBrush>
    <!-- Same DrawingBrush as seen above. -->
    </DrawingBrush>
  </Page.Background>
</Page>

或者你可以使用这个<DrawingBrush>来设置一个不同的画笔兼容属性,比如一个ButtonBackground属性。

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Button Height="100" Width="100">
  <Button.Background>
    <DrawingBrush>
    <!-- Same DrawingBrush as seen above. -->
    </DrawingBrush>
  </Button.Background>
  </Button>
</Page>

无论你用自定义的<DrawingBrush>设置哪个笔刷兼容的属性,底线是你渲染的 2D 矢量图像比用形状渲染的 2D 图像开销要少得多。

在绘图图像中包含绘图类型

DrawingImage类型允许你将你的绘图几何图形插入到 WPF <Image>控件中。请考虑以下几点:

<Image>
  <Image.Source>
    <DrawingImage>
      <DrawingImage.Drawing>
        <!--Same GeometryDrawing from above -->
      </DrawingImage.Drawing>
    </DrawingImage>
  </Image.Source>
</Image>

在这种情况下,你的<GeometryDrawing>被放入了一个<DrawingImage>,而不是一个<DrawingBrush>。使用这个<DrawingImage>,可以设置Image控件的Source属性。

使用矢量图像

您可能同意,对于图形艺术家来说,使用 Visual Studio 提供的工具和技术来创建复杂的基于矢量的图像是一件非常具有挑战性的事情。图形艺术家有自己的一套工具,可以制作出令人惊叹的矢量图形。无论是 Visual Studio 还是其配套的 Expression Blend for Visual Studio 都不具备这种设计能力。在将矢量图像导入 WPF 应用之前,必须将其转换成Path表达式。此时,您可以使用 Visual Studio 针对生成的对象模型进行编程。

Note

您可以在下载文件的Chapter 26文件夹中找到正在使用的图像(LaserSign.svg)以及导出的路径(LaserSign.xaml)数据。图片最初来自维基百科,位于 https://en.wikipedia.org/wiki/Hazard_symbol

将样本矢量图形文件转换为 XAML

在将复杂的图形数据(如矢量图形)导入 WPF 应用之前,您需要将图形转换为路径数据。作为如何做到这一点的一个例子,从一个样本.svg图像文件开始,例如前面注释中提到的激光标记。然后下载并安装一个名为 Inkscape 的开源工具(位于 www.inkscape.org )。使用 Inkscape,从下载一章中打开LaserSign.svg文件。可能会提示您升级格式。如图 26-12 所示填写选项。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-12。

在 Inkscape 中将 SVG 文件升级到最新格式

接下来的步骤一开始看起来会有点奇怪,但是一旦你克服了这种奇怪,这是一个将矢量图像转换成正确的 XAML 的简单方法。当您得到想要的图像时,选择文件➤打印菜单选项。接下来,选择 Microsoft XPS Document Writer 作为打印机目标,然后单击打印。在下一个屏幕上,输入一个文件名并选择保存文件的位置;然后单击保存。现在你有了一个完整的*.xps(或*.oxps)文件。

Note

根据系统配置中的变量数量,生成的文件会有.xps.oxps扩展名。无论哪种方式,过程都是一样的。

*.xps*.oxps格式实际上是.zip文件。将文件的扩展名重命名为.zip,就可以在文件资源管理器(或者 7-Zip,或者你喜欢的存档工具)中打开文件了。你会看到它包含了如图 26-13 所示的层级。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-13。

打印的 XPS 文件的文件夹层次结构

您需要的文件在Pages目录(Documents/1/Pages)中,并被命名为1.fpage。用文本编辑器打开文件,复制除了<FixedPage>开始和结束标签之外的所有内容。然后可以将路径数据复制到 Kaxaml 中,并放在主WindowCanvas中。你的图像将显示在 XAML 窗口。

Note

最新版本的 Inkscape 可以选择将文件保存为微软 XAML。不幸的是,在撰写本文时,它与 WPF 不兼容。

将图形数据导入 WPF 项目

此时,创建一个名为 InteractiveLaserSign 的新 WPF 应用。将Window调整为 600 的Height和 650 的Width,并将Grid替换为Canvas

<Window x:Class="InteractiveLaserSign.MainWindow"
        xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

        xmlns:local="clr-namespace:InteractiveLaserSign"
        mc:Ignorable="d"
        Title="MainWindow" Height="600" Width="650">
    <Canvas>
    </Canvas>
</Window>

1.fpage文件中复制整个 XAML(不包括外部的FixedPage标签)并粘贴到Canvas控件中。在设计模式下查看Window,您将看到在您的应用中复制的标志。

如果您查看文档轮廓,您会看到图像的每个部分都表示为一个 XAML Path元素。如果您调整Window的大小,无论窗口有多大,图像质量都保持不变。这是因为由Path元素表示的图像是使用绘图引擎和数学来呈现的,而不是翻转像素。

与标志互动

回想一下路由的事件隧道和气泡,因此在Canvas中单击的任何Path都可以由画布上的 click 事件处理程序来处理。将Canvas标记更新如下:

<Canvas MouseLeftButtonDown="Canvas_MouseLeftButtonDown">

使用以下代码添加事件处理程序:

private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  if (e.OriginalSource is Path p)
  {
    p.Fill = new SolidColorBrush(Colors.Red);
  }
}

现在,运行您的应用。单击线条查看效果。

您现在了解了为复杂图形生成Path数据的过程,以及如何在代码中与图形数据交互。您可能会同意,专业图形艺术家生成复杂图形数据并将数据导出为 XAML 的能力非常强大。一旦生成了图形数据,开发人员就可以导入标记并针对对象模型进行编程。

使用可视层呈现图形数据

用 WPF 渲染图形数据的最后一个选项被称为视觉层。如前所述,您只能通过代码访问该层(它对 XAML 不友好)。虽然绝大多数 WPF 应用使用形状、绘图和几何图形都能正常工作,但可视化图层确实提供了渲染大量图形数据的最快方法。当您需要在大面积上渲染单个图像时,这个低级图形层也很有用。例如,如果您需要用普通的静态图像填充窗口的背景,视觉图层是最快的方法。如果你需要根据用户输入或类似的东西在窗口背景之间快速切换,它也会很有用。

我不会花太多时间深入研究 WPF 编程这方面的细节,但是让我们构建一个小的示例程序来说明基本原理。

Visual 基类和派生的子类

抽象的System.Windows.Media.Visual类类型提供了一个最小的服务集(渲染、点击测试、转换)来渲染图形,但是它不支持额外的非可视服务,这会导致代码膨胀(输入事件、布局服务、样式和数据绑定)。Visual类是一个抽象基类。您需要使用一个派生类型来执行实际的呈现操作。WPF 提供了一些子类,包括DrawingVisualViewport3DVisualContainerVisual

在本例中,您将只关注DrawingVisual,这是一个轻量级绘图类,用于呈现形状、图像或文本。

使用 DrawingVisual 类初探

要使用DrawingVisual将数据渲染到表面上,您需要采取以下基本步骤:

  1. DrawingVisual类中获取一个DrawingContext对象。

  2. 使用DrawingContext渲染图形数据。

这两个步骤代表了将一些数据渲染到表面所需的最少步骤。但是,如果您希望呈现的图形数据能够响应点击测试计算(这对增加用户交互性很重要),您还需要执行以下附加步骤:

  1. 更新正在渲染的容器所维护的逻辑树和可视化树。

  2. 覆盖来自FrameworkElement类的两个虚拟方法,允许容器获得您创建的可视数据。

稍后您将检查这最后两个步骤。首先,为了说明如何使用DrawingVisual类来呈现 2D 数据,创建一个名为 RenderingWithVisuals 的新 WPF 应用。你的第一个目标是使用一个DrawingVisual动态地分配数据给一个 WPF Image控件。首先更新窗口的 XAML 来处理Loaded事件,如下所示:

<Window x:Class="RenderingWithVisuals.MainWindow"
      <!--omitted for brevity -->
      Title="Fun With Visual Layer" Height="450" Width="800"
      Loaded="MainWindow_Loaded">

接下来,用一个StackPanel替换Grid,并在StackPanel中添加一个Image,就像这样:

<StackPanel Background="AliceBlue" Name="myStackPanel">
  <Image Name="myImage" Height="80"/>
</StackPanel>

您的<Image>控件还没有Source值,因为这将在运行时发生。Loaded事件将使用一个DrawingBrush对象完成构建内存中图形数据的工作。确保以下名称空间位于MainWindow.cs的顶部:

using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;

下面是Loaded事件处理程序的实现:

private void MainWindow_Loaded(
  object sender, RoutedEventArgs e)
{
  const int TextFontSize = 30;
  // Make a System.Windows.Media.FormattedText object.
  FormattedText text = new FormattedText(
    "Hello Visual Layer!",
    new System.Globalization.CultureInfo("en-us"),
    FlowDirection.LeftToRight,
    new Typeface(this.FontFamily, FontStyles.Italic,
      FontWeights.DemiBold, FontStretches.UltraExpanded),
    TextFontSize,
    Brushes.Green,
    null,
    VisualTreeHelper.GetDpi(this).PixelsPerDip);
  // Create a DrawingVisual, and obtain the DrawingContext.
  DrawingVisual drawingVisual = new DrawingVisual();
  using(DrawingContext drawingContext =
    drawingVisual.RenderOpen())
  {
    // Now, call any of the methods of DrawingContext to render data.
    drawingContext.DrawRoundedRectangle(
      Brushes.Yellow, new Pen(Brushes.Black, 5),
      new Rect(5, 5, 450, 100), 20, 20);
    drawingContext.DrawText(text, new Point(20, 20));
  }
  // Dynamically make a bitmap, using the data in the DrawingVisual.
  RenderTargetBitmap bmp = new RenderTargetBitmap(
    500, 100, 100, 90, PixelFormats.Pbgra32);
  bmp.Render(drawingVisual);
  // Set the source of the Image control!
  myImage.Source = bmp;
}

这段代码引入了许多新的 WPF 类,我将在这里对它们进行简单的评论。该方法首先创建一个新的FormattedText对象,表示您正在构建的内存图像的文本部分。如您所见,构造函数允许您指定许多属性,如字体大小、字体系列、前景色和文本本身。

接下来,您通过在DrawingVisual实例上调用RenderOpen()来获得必要的DrawingContext对象。这里,您将一个彩色的圆角矩形呈现到DrawingVisual中,后面是您的格式化文本。在这两种情况下,您都是使用硬编码的值将图形数据放入DrawingVisual中,这对于生产来说不一定是个好主意,但是对于这个简单的测试来说却很好。

最后几个语句将DrawingVisual映射到一个RenderTargetBitmap对象,该对象是System.Windows.Media.Imaging名称空间的成员。这个类将接受一个可视对象,并将其转换成内存中的位图图像。至此,您设置了Image控件的Source属性,果然,您将看到图 26-14 中的输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-14。

使用可视层呈现内存中的位图

Note

System.Windows.Media.Imaging名称空间包含许多额外的编码类,允许您以各种格式将内存中的RenderTargetBitmap对象保存到物理文件中。查看JpegBitmapEncoder类(和朋友)了解更多信息。

向自定义布局管理器呈现可视数据

虽然使用DrawingVisual在 WPF 控件的背景上绘图很有趣,但构建一个自定义布局管理器(GridStackPanelCanvas等)可能更常见。)在内部使用可视层来呈现其内容。在你创建了这样一个定制的布局管理器之后,你可以把它插入到一个普通的Window(或者Page或者UserControl)中,并且拥有一个使用高度优化的渲染代理的 UI 的一部分,而主机Window的非关键方面使用图形和绘图来处理剩余的图形数据。

如果您不需要专用布局管理器提供的额外功能,您可以选择简单地扩展FrameworkElement,它有必要的基础设施来包含可视项目。为了说明如何做到这一点,在您的项目中插入一个名为CustomVisualFrameworkElement的新类。从FrameworkElement扩展这个类并导入 System、System.WindowsSystem.Windows.InputSystem.Windows.MediaSystem.Windows.Media.Imaging名称空间。

这个类将维护一个类型为VisualCollection的成员变量,它包含两个固定的DrawingVisual对象(当然,您可以通过鼠标操作向这个集合添加新成员,但是这个示例将保持简单)。使用以下新功能更新您的类:

public class CustomVisualFrameworkElement : FrameworkElement
{
  // A collection of all the visuals we are building.
  VisualCollection theVisuals;
  public CustomVisualFrameworkElement()
  {
    // Fill the VisualCollection with a few DrawingVisual objects.
    // The ctor arg represents the owner of the visuals.
    theVisuals = new VisualCollection(this)
      {AddRect(),AddCircle()};
  }
  private Visual AddCircle()
  {
    DrawingVisual drawingVisual = new DrawingVisual();
    // Retrieve the DrawingContext in order to create new drawing content.
    using DrawingContext drawingContext =
      drawingVisual.RenderOpen()
    // Create a circle and draw it in the DrawingContext.
    drawingContext.DrawEllipse(Brushes.DarkBlue, null,
      new Point(70, 90), 40, 50);
    return drawingVisual;
  }
  private Visual AddRect()
  {
    DrawingVisual drawingVisual = new DrawingVisual();
    using DrawingContext drawingContext =
      drawingVisual.RenderOpen()
    Rect rect =
      new Rect(new Point(160, 100), new Size(320, 80));
    drawingContext.DrawRectangle(Brushes.Tomato, null, rect);
    return drawingVisual;
  }
}

现在,在您可以在您的Window中使用这个自定义的FrameworkElement之前,您必须覆盖前面提到的两个关键的虚拟方法,这两个方法都是在渲染过程中由 WPF 在内部调用的。GetVisualChild()方法从子元素集合中返回指定索引处的子元素。只读VisualChildrenCount属性返回该可视集合中可视子元素的数量。这两种方法都很容易实现,因为您可以将真正的工作委托给VisualCollection成员变量。

protected override int VisualChildrenCount
  => theVisuals.Count;

protected override Visual GetVisualChild(int index)
{
  // Value must be greater than zero, so do a sanity check.
  if (index < 0 || index >= theVisuals.Count)
  {
     throw new ArgumentOutOfRangeException();
  }
  return theVisuals[index];
}

现在,您已经有了足够的功能来测试您的定制类。更新Window的 XAML 描述,将一个CustomVisualFrameworkElement对象添加到现有的StackPanel中。这样做将要求您添加一个自定义 XML 命名空间,该命名空间映射到您的。NET 核心命名空间。

<Window x:Class="RenderingWithVisuals.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="clr-namespace:RenderingWithVisuals"
  Title="Fun with the Visual Layer" Height="350" Width="525"
  Loaded="Window_Loaded" WindowStartupLocation="CenterScreen">
    <StackPanel Background="AliceBlue" Name="myStackPanel">
          <Image Name="myImage" Height="80"/>
          <local:CustomVisualFrameworkElement/>
    </StackPanel>
</Window>

当你运行程序时,你会看到如图 26-15 所示的结果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 26-15。

使用可视层将数据渲染到自定义的FrameworkElement

响应点击测试操作

因为DrawingVisual没有UIElementFrameworkElement的任何基础设施,您将需要以编程方式添加计算点击测试操作的能力。幸运的是,这在视觉层很容易做到,因为有了逻辑视觉树的概念。事实证明,当你创作一个 XAML 的 blob 时,你实际上是在构建一个元素的逻辑树。然而,在每一个逻辑树的背后都有一个更丰富的描述,称为视觉树,它包含更低级的渲染指令。

第二十七章将会更详细地探究这些树,但是现在,你要明白,直到你用这些数据结构注册了你的自定义视图,你才能够执行点击测试操作。幸运的是,VisualCollection容器为您做了这件事(这解释了为什么您需要传入对自定义FrameworkElement的引用作为构造函数参数)。

首先,使用标准 C# 语法更新CustomVisualFrameworkElement类以处理类构造函数中的MouseDown事件,如下所示:

this.MouseDown += CustomVisualFrameworkElement_MouseDown;

这个处理程序的实现将调用VisualTreeHelper.HitTest()方法来查看鼠标是否在一个渲染的视觉对象的边界内。要做到这一点,您需要指定一个执行计算的HitTestResultCallback委托作为HitTest()的参数。如果单击某个视觉对象,将在该视觉对象的倾斜呈现和原始呈现之间切换。将以下方法添加到您的CustomVisualFrameworkElement类中:

void CustomVisualFrameworkElement_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Figure out where the user clicked.
  Point pt = e.GetPosition((UIElement)sender);
  // Call helper function via delegate to see if we clicked on a visual.
  VisualTreeHelper.HitTest(this, null,
  new HitTestResultCallback(myCallback), new PointHitTestParameters(pt));
}

public HitTestResultBehavior myCallback(HitTestResult result)
{
    // Toggle between a skewed rendering and normal rendering,
    // if a visual was clicked.
    if (result.VisualHit.GetType() == typeof(DrawingVisual))
    {
      if (((DrawingVisual)result.VisualHit).Transform == null)
      {
         ((DrawingVisual)result.VisualHit).Transform = new SkewTransform(7, 7);
      }
      else
      {
         ((DrawingVisual)result.VisualHit).Transform = null;
    }
  }
  // Tell HitTest() to stop drilling into the visual tree.
  return HitTestResultBehavior.Stop;
}

现在,再次运行你的程序。现在,您应该能够单击任一渲染视图,并看到正在进行的转换!虽然这只是使用 WPF 视觉图层的一个简单示例,但请记住,您使用的笔刷、变换、钢笔和布局管理器与使用 XAML 时相同。因此,您已经对使用这个Visual派生类有了相当多的了解。

这就结束了您对 Windows Presentation Foundation 的图形呈现服务的研究。虽然您了解了许多有趣的主题,但实际情况是,您只是触及了 WPF 图形功能的皮毛。我将把它留给你,让你更深入地挖掘形状、绘画、画笔、变换和视觉效果的主题(当然,你会在 WPF 剩余的章节中看到这些主题的更多细节)。

摘要

因为 Windows Presentation Foundation 是一个图形密集型 GUI API,所以我们有多种方法来呈现图形输出也就不足为奇了。本章首先研究了 WPF 应用可以做到的三种方式(形状、绘图和视觉),并讨论了各种呈现原语,如画笔、钢笔和变换。

请记住,当你需要建立交互式 2D 渲染,形状使过程非常简单。但是,静态、非交互式渲染可以通过使用绘图和几何图形以更优化的方式进行渲染,而可视化层(仅在代码中可访问)为您提供最大的控制和性能。

二十七、WPF 资源、动画、样式和模板

本章向您介绍了三个重要的(且相互关联的)主题,它们将加深您对 WPF(WPF) API 的理解。首要任务是学习逻辑资源的作用。正如您将看到的,逻辑资源(也称为对象资源)系统是一种命名和引用 WPF 应用中常用对象的方式。虽然逻辑资源通常是在 XAML 中编写的,但是它们也可以在过程代码中定义。

接下来,您将学习如何定义、执行和控制动画序列。不管你怎么想,WPF 动画并不局限于视频游戏或多媒体应用。在 WPF API 下,动画可以非常微妙,比如让一个按钮在获得焦点时发光,或者扩展DataGrid中选定行的大小。理解动画是构建自定义控件模板的一个关键方面(你将在本章后面看到)。

然后,您将探索 WPF 风格和模板的作用。就像使用 CSS 或 ASP.NET 主题引擎的网页一样,WPF 应用可以为一组控件定义一个共同的外观。您可以在标记中定义这些样式,并将它们存储为对象资源供以后使用,还可以在运行时动态应用它们。最后一个例子将教你如何构建自定义控件模板。

了解 WPF 资源系统

您的第一个任务是研究嵌入和访问应用资源的主题。WPF 支持两种类型的资源。第一个是二进制资源,这一类别通常包括大多数程序员认为是传统意义上的资源的项目(嵌入的图像文件或声音剪辑、应用使用的图标等)。).

第二种风格称为对象资源逻辑资源,代表一个命名的。NET 对象,可以打包并在整个应用中重用。而任何。NET 对象可以打包成对象资源,逻辑资源在处理任何种类的图形数据时特别有用,因为您可以定义常用的图形元素(画笔、钢笔、动画等)。)并在需要时参考它们。

使用二进制资源

在进入对象资源的主题之前,让我们快速检查一下如何将二进制资源打包到您的应用中,例如图标或图像文件(例如,公司徽标或动画图像)。如果您想继续,创建一个名为BinaryResourcesApp的新 WPF 应用。更新初始窗口的标记,以处理Window Loaded事件并使用DockPanel作为布局根,如下所示:

<Window x:Class="BinaryResourcesApp.MainWindow"
  <!-- Omitted for brevity -->
    Title="Fun with Binary Resources" Height="500" Width="649" Loaded="MainWindow_OnLoaded">
  <DockPanel LastChildFill="True">
  </DockPanel>
</Window>

现在,假设您的应用需要根据用户输入在窗口的一部分显示三个图像文件中的一个。WPF Image控件不仅可用于显示典型的图像文件(*.bmp*.gif*.ico*.jpg*.png*.wdp*.tiff),还可用于显示DrawingImage中的数据(如您在第二十六章中所见)。您可以为您的窗口构建一个支持DockPanel的 UI,该 UI 包含一个带有下一个和上一个按钮的简单工具栏。在这个工具栏下面,您可以放置一个Image控件,该控件目前没有设置为Source属性的值,如下所示:

  <DockPanel LastChildFill="True">
    <ToolBar Height="60" Name="picturePickerToolbar" DockPanel.Dock="Top">
      <Button x:Name="btnPreviousImage" Height="40" Width="100" BorderBrush="Black"
              Margin="5" Content="Previous" Click="btnPreviousImage_Click"/>
      <Button x:Name="btnNextImage" Height="40" Width="100" BorderBrush="Black"
              Margin="5" Content="Next" Click="btnNextImage_Click"/>
    </ToolBar>
    <!-- We will fill this Image in code. -->
    <Border BorderThickness="2" BorderBrush="Green">
      <Image x:Name="imageHolder" Stretch="Fill" />
    </Border>
  </DockPanel>

接下来,添加以下空事件处理程序:

private void MainWindow_OnLoaded(
  object sender, RoutedEventArgs e)
{
}
private void btnPreviousImage_Click(
  object sender, RoutedEventArgs e)
{
}
private void btnNextImage_Click(
  object sender, RoutedEventArgs e)
{
}

当窗口加载时,图像将被添加到一个集合中,下一个和上一个按钮将在其中循环。现在,应用框架已经就绪,让我们检查实现它的不同选项。

在项目中包含松散的资源文件

一种选择是将您的图像文件作为一组松散的文件放在应用安装路径的子目录中。首先向您的项目添加一个新文件夹(名为Images)。右键单击并选择“添加➤现有项目”,向该文件夹添加一些图像。确保将添加现有项目对话框中的文件过滤器更改为*.*,以便显示图像文件。您可以添加自己的图像文件,或者使用可下载代码中的三个名为Deer.jpgDogs.jpgWelcome.jpg的图像文件。

配置松散资源

要在项目构建时将\Images文件夹中的内容复制到\bin\Debug文件夹中,首先在解决方案资源管理器中选择所有图像。现在,在这些图像仍处于选中状态的情况下,右键单击并选择 Properties 以打开 Properties 窗口。将Build Action属性设置为Content,将Copy to Output Directory属性设置为Copy always(见图 27-1 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-1。

配置要复制到输出目录的图像数据

Note

您还可以选择Copy if Newer,如果您正在构建包含大量内容的大型项目,这将节省您的时间。对于这个例子,Copy always起作用。

如果您构建了您的项目,现在您可以单击解决方案资源管理器的 Show All Files 按钮,并查看您的\bin\Debug目录下复制的Image文件夹(您可能需要单击 Refresh 按钮)。

以编程方式加载图像

WPF 提供了一个名为BitmapImage的类,它是System.Windows.Media.Imaging名称空间的一部分。这个类允许你从一个图像文件中加载数据,这个图像文件的位置由一个System.Uri对象表示。添加一个List<BitmapImage>来保存图像,以及一个int来存储当前显示图像的索引。

// A List of BitmapImage files.
List<BitmapImage> _images=new List<BitmapImage>();
// Current position in the list.
private int _currImage=0;

在窗口的Loaded事件中,填充图像列表,然后将Image控制源设置为列表中的第一幅图像。

private void MainWindow_OnLoaded(
  object sender, RoutedEventArgs e)
{
  try
  {
    string path=Environment.CurrentDirectory;
    // Load these images from disk when the window loads.
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Deer.jpg")));
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Dogs.jpg")));
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Welcome.jpg")));
    // Show first image in the List.
    imageHolder.Source=_images[_currImage];
  }
  catch (Exception ex)
  {
    MessageBox.Show(ex.Message);
  }
}

接下来,实现 previous 和 Next 处理程序来遍历图像。如果用户到达列表的末尾,让他们从头开始,反之亦然。

private void btnPreviousImage_Click(
  object sender, RoutedEventArgs e)
{
  if (--_currImage < 0)
  {
    _currImage=_images.Count - 1;
  }
  imageHolder.Source=_images[_currImage];
}
private void btnNextImage_Click(
  object sender, RoutedEventArgs e)
{
  if (++_currImage >=_images.Count)
  {
    _currImage=0;
  }
  imageHolder.Source=_images[_currImage];
}

此时,你可以运行你的程序,浏览每张图片。

嵌入应用资源

如果您希望将图像文件配置为直接编译到。NET 核心程序集作为二进制资源,在解决方案资源管理器中选择图像文件(在\Images文件夹中,而不是在\bin\Debug\Images文件夹中)。将Build Action属性更改为Resource,将Copy to Output Directory属性设置为Do not copy(见图 27-2 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-2。

将图像配置为嵌入式资源

现在,使用 Visual Studio 的 Build 菜单,选择 Clean Solution 选项清除当前的\bin\Debug\Images内容,然后重新构建您的项目。刷新解决方案资源管理器,观察您的\bin\Debug\Images目录中是否缺少数据。使用当前的构建选项,您的图形数据不再被复制到输出文件夹中,而是嵌入到程序集本身中。这确保了资源的存在,但也增加了编译后程序集的大小。

您需要修改代码,通过从编译后的程序集中提取这些图像来将它们加载到列表中。

// Extract from the assembly and then load images
_images.Add(new BitmapImage(new Uri(img/Deer.jpg", UriKind.Relative)));
_images.Add(new BitmapImage(new Uri(img/Dogs.jpg", UriKind.Relative)));
_images.Add(new BitmapImage(new Uri(img/Welcome.jpg", UriKind.Relative)));

在这种情况下,您不再需要确定安装路径,可以简单地按名称列出资源,这考虑了原始子目录的名称。还要注意,当你创建你的Uri对象时,你指定了一个RelativeUriKind值。此时,您的可执行文件是一个独立的实体,可以从机器上的任何位置运行,因为所有编译的数据都在二进制文件中。

使用对象(逻辑)资源

在构建 WPF 应用时,通常会定义一个 XAML 的简介,在一个窗口中的多个位置使用,或者跨多个窗口或项目使用。例如,假设你已经创建了完美的线性渐变画笔,它由十行标记组成。现在,您想要使用该笔刷作为项目中每个Button控件的背景色(项目由 8 个窗口组成),总共有 16 个Button控件。

最糟糕的事情是将 XAML 复制并粘贴到每个控件中。很明显,这将是一场维护的噩梦,因为你需要在任何时候对画笔的外观和感觉进行大量的修改。

谢天谢地,对象资源允许你定义一个 XAML 的 blob,给它一个名字,并把它存储在一个 fitting 字典中以备后用。像二进制资源一样,对象资源通常被编译到需要它们的程序集中。但是,您不需要修改Build Action属性就可以做到这一点。如果你把你的 XAML 放到正确的位置,编译器会自动完成剩下的工作。

使用对象资源是 WPF 开发的一大部分。正如你将看到的,对象资源可能比自定义画笔复杂得多。您可以定义基于 XAML 的动画、3D 呈现、自定义控件样式、数据模板、控件模板等,并将每个模板打包为可重用的资源。

资源属性的作用

如前所述,对象资源必须放在 fitting dictionary 对象中,以便在整个应用中使用。目前,FrameworkElement的每个后代都支持一个Resources属性。该属性封装了一个包含已定义对象资源的ResourceDictionary对象。ResourceDictionary可以保存任何类型的项目,因为它在System.Object类型上操作,并且可以通过 XAML 或程序代码进行操作。

在 WPF,所有的控件、WindowPage(构建导航应用时使用)和UserControl都扩展了FrameworkElement,所以几乎所有的小部件都提供了对ResourceDictionary的访问。此外,Application类虽然没有扩展FrameworkElement,但出于同样的目的,它支持一个同名的Resources属性。

定义窗口范围的资源

要开始探索对象资源的角色,创建一个名为 ObjectResourcesApp 的新 WPF 应用,并将最初的Grid更改为水平对齐的StackPanel布局管理器。在这个StackPanel中,像这样定义两个Button控件(你真的不需要太多来说明对象资源的作用,这样就行了):

<StackPanel Orientation="Horizontal">
  <Button Margin="25" Height="200" Width="200" Content="OK" FontSize="20"/>
  <Button Margin="25" Height="200" Width="200" Content="Cancel" FontSize="20"/>
</StackPanel>

现在,选择 OK 按钮,使用集成笔刷编辑器将Background颜色属性设置为自定义笔刷类型(在第二十六章中讨论)。完成后,注意画笔是如何嵌入在<Button></Button>标签的范围内的,如下所示:

<Button Margin="25" Height="200" Width="200" Content="OK" FontSize="20">
  <Button.Background>
    <RadialGradientBrush>
      <GradientStop Color="#FFC44EC4" Offset="0" />
      <GradientStop Color="#FF829CEB" Offset="1" />
      <GradientStop Color="#FF793879" Offset="0.669" />
    </RadialGradientBrush>
  </Button.Background>
</Button>

为了让 Cancel 按钮也使用这个画笔,您应该将<RadialGradientBrush>的范围提升到父元素的资源字典。例如,如果你把它移动到<StackPanel>,两个按钮可以使用相同的笔刷,因为它们是布局管理器的子元素。更好的是,你可以将画笔打包到Window本身的资源字典中,这样窗口的内容就可以使用它。

当您需要定义一个资源时,您可以使用 property-element 语法来设置所有者的Resources属性。您还为资源项赋予了一个x:Key值,当窗口的其他部分想要引用对象资源时,将会使用这个值。要知道x:Keyx:Name是不一样的!x:Name属性允许您访问代码文件中作为成员变量的对象,而x:Key属性允许您引用资源字典中的一个项目。

Visual Studio 允许您使用资源各自的属性窗口将资源提升到更高的范围。要做到这一点,首先要确定包含要打包为资源的复杂对象的属性(在本例中是Background属性)。属性的右边是一个小方块,单击它将打开一个弹出菜单。从中选择转换为新资源选项(参见图 27-3 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-3。

将复杂对象移动到资源容器中

要求您命名您的资源(myBrush)并指定放置它的位置。对于本例,保留当前文件的默认选择(见图 27-4 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-4。

命名对象资源

当你完成后,你会看到画笔已经被移动到了Window.Resources标签内。

<Window.Resources>
  <RadialGradientBrush x:Key="myBrush">
    <GradientStop Color="#FFC44EC4" Offset="0" />
    <GradientStop Color="#FF829CEB" Offset="1" />
    <GradientStop Color="#FF793879" Offset="0.669" />
  </RadialGradientBrush>
</Window.Resources>

并且Button控件的Background已经被更新以使用新的资源。

<Button Margin="25" Height="200" Width="200" Content="OK"
        FontSize="20" Background="{DynamicResource myBrush}"/>

创建资源向导创建新资源作为DynamicResource。稍后你会在文中了解到DynamicResource s,但是现在,把它改成StaticResource,就像这样:

<Button Margin="25" Height="200" Width="200" Content="OK"
    FontSize="20" Background="{StaticResource myBrush}"/>

要看到好处,将取消ButtonBackground属性更新为同一个StaticResource,就可以看到重用在起作用。

<Button Margin="25" Height="200" Width="200" Content="Cancel"
    FontSize="20" Background="{StaticResource myBrush}"/>

{StaticResource}标记扩展

{StaticResource}标记扩展只应用资源一次(初始化时),并在应用的生命周期内保持与原始对象的“连接”。一些属性(例如渐变停止)将会更新,但是如果您创建一个新的Brush,控件将不会更新。要看到这一点,给每个Button控件添加一个NameClick事件处理程序,如下所示:

<Button Name="Ok" Margin="25" Height="200" Width="200" Content="OK"
    FontSize="20" Background="{StaticResource myBrush}" Click="Ok_OnClick"/>
<Button Name="Cancel" Margin="25" Height="200" Width="200" Content="Cancel"
    FontSize="20" Background="{StaticResource myBrush}" Click="Cancel_OnClick"/>

接下来,将以下代码添加到Ok_OnClick()事件处理程序中:

private void Ok_OnClick(object sender, RoutedEventArgs e)
{
  // Get the brush and make a change.
  var b=(RadialGradientBrush)Resources["myBrush"];
  b.GradientStops[1]=new GradientStop(Colors.Black, 0.0);
}

Note

在这里,您使用Resources索引器通过名称来定位资源。但是,请注意,如果找不到资源,这将引发运行时异常。您也可以使用TryFindResource()方法,它不会抛出运行时错误;如果找不到指定的资源,它将简单地返回null

当您运行程序并单击 OK Button时,您会看到渐变发生了适当的变化。现在将以下代码添加到Cancel_OnClick()事件处理程序中:

private void Cancel_OnClick(object sender, RoutedEventArgs e)
{
  // Put a totally new brush into the myBrush slot.
  Resources["myBrush"]=new SolidColorBrush(Colors.Red);
}

再次运行程序,点击取消Button,什么都没发生!

{DynamicResource}标记扩展

属性也可以使用DynamicResource标记扩展。要查看差异,请将取消Button的标记更改为以下内容:

<Button Name="Cancel" Margin="25" Height="200" Width="200" Content="Cancel"
                FontSize="20" Background="{DynamicResource myBrush}" Click="Cancel_OnClick"/>

这一次,当你点击取消Button时,取消Button的背景会改变,但是确定Button的背景保持不变。这是因为{DynamicResource}标记扩展可以检测底层键控对象是否已经被新对象替换。正如您可能猜到的,这需要一些额外的运行时基础设施,所以您通常应该坚持使用{StaticResource},除非您知道您有一个对象资源将在运行时与另一个对象交换,并且您希望使用该资源的所有项目都得到通知。

应用级资源

当窗口的资源字典中有对象资源时,窗口中的所有项都可以自由使用它,但应用中的其他窗口不能。跨应用共享资源的解决方案是在应用级别定义对象资源,而不是在窗口级别。在 Visual Studio 中没有办法实现自动化,所以只需将当前的 brush 对象从<Windows.Resources>范围中剪切出来,并将其放在App.xaml文件的<Application.Resources>范围中。

现在,应用中的任何附加窗口或控件都可以自由地使用这个画笔。如果要为控件设置Background属性,可以选择应用级资源,如图 27-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-5。

应用应用级资源

Note

将资源置于应用级别并将其分配给控件的属性将会冻结资源,从而防止在运行时更改值。可以克隆资源,并且可以更新克隆。

定义合并的资源词典

应用级的资源通常是足够好的,但是它们无助于跨项目的重用。在这种情况下,您想要定义一个被称为合并资源字典的东西。把它想象成 WPF 资源的类库;它只不过是一个包含资源集合的XAML文件。单个项目可以根据需要拥有多个这样的文件(一个用于画笔,一个用于动画,等等)。),每一个都可以使用通过项目菜单激活的添加新项目对话框插入(见图 27-6 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-6。

插入新的合并资源字典

在新的MyBrushes.xaml文件中,剪切Application.Resources范围中的当前资源,并将它们移动到您的字典中,如下所示:

<ResourceDictionary xmlns:=http://schemas.microsoft.com/winfx/2006/xaml/presentation
  xmlns:local="clr-namespace:ObjectResourcesApp"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <RadialGradientBrush x:Key="myBrush">
    <GradientStop Color="#FFC44EC4" Offset="0" />
    <GradientStop Color="#FF829CEB" Offset="1" />
    <GradientStop Color="#FF793879" Offset="0.669" />
  </RadialGradientBrush>
</ResourceDictionary>

即使此资源字典是项目的一部分,所有资源字典都必须合并(通常在应用级别)到现有的资源字典中才能使用。为此,在App.xaml文件中使用以下格式(注意,可以通过在<ResourceDictionary.MergedDictionaries>范围内添加多个<ResourceDictionary>元素来合并多个资源字典):

  <Application.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="MyBrushes.xaml"/>
      </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
  </Application.Resources>

这种方法的问题是,每个资源文件都必须添加到每个需要资源的项目中。共享资源的一个更好的方法是定义一个. NET 核心类库在项目之间共享,这是您接下来要做的。

定义仅资源程序集

生成纯资源程序集的最简单方法是从 WPF 用户控件库(。NET Core)项目。通过 Visual 的“添加➤新项目”菜单选项将这样一个项目(名为 MyBrushesLibrary)添加到当前解决方案中,并从 ObjectResourcesApp 项目中添加对它的项目引用。

现在,从项目中删除UserControl1.xaml文件。接下来,将MyBrushes.xaml文件拖放到您的MyBrushesLibrary项目中,并将其从ObjectResourcesApp项目中删除。最后,打开MyBrushesLibrary项目中的MyBrushes.xaml,将文件中的x:local名称空间改为clr-namespace:MyBrushesLibrary。您的MyBrushes.xaml文件应该如下所示:

<ResourceDictionary xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:MyBrushesLibrary">
    <RadialGradientBrush x:Key="myBrush">
        <GradientStop Color="#FFC44EC4" Offset="0" />
        <GradientStop Color="#FF829CEB" Offset="1" />
        <GradientStop Color="#FF793879" Offset="0.669" />
    </RadialGradientBrush>
</ResourceDictionary>

编译您的用户控件库项目。现在,将这些二进制资源合并到ObjectResourcesApp项目的应用级资源字典中。然而,这样做需要一些相当时髦的语法,如下所示:

<Application.Resources>
  <ResourceDictionary>
    <ResourceDictionary.MergedDictionaries>
      <!-- The syntax is /NameOfAssembly;Component/NameOfXamlFileInAssembly.xaml -->
      <ResourceDictionary Source="/MyBrushesLibrary;Component/MyBrushes.xaml"/>
    </ResourceDictionary.MergedDictionaries>
  </ResourceDictionary>
</Application.Resources>

首先,请注意这个字符串是区分空间的。如果分号或正斜杠两边有多余的空格,就会产生错误。字符串的第一部分是外部库的友好名称(没有文件扩展名)。在分号后,键入单词Component,后跟编译后的二进制资源的名称,这将与原始的 XAML 资源字典相同。

这就结束了对 WPF 资源管理系统的检查。您将在大多数(如果不是全部)应用中很好地利用这些技术。接下来,我们来考察一下 Windows Presentation Foundation 的集成动画 API。

了解 WPF 的动画服务

除了你在第二十六章中研究的图形渲染服务,WPF 还提供了一个编程接口来支持动画服务。术语动画可能会让人想起旋转的公司徽标、一系列旋转的图像资源(以提供运动的错觉)、屏幕上跳动的文本或特定类型的程序,如视频游戏或多媒体应用。

虽然 WPF 的动画 API 肯定可以用于这种目的,但如果你想给应用增添一些特色,随时都可以使用动画。例如,您可以为屏幕上的按钮创建一个动画,当鼠标光标悬停在其边界内时,该动画会稍微放大(当鼠标光标移动到边界之外时,动画会缩小)。或者,您可以制作窗口动画,使其以特定的视觉外观关闭,例如慢慢淡入透明。更以业务应用为中心的用途是淡入应用屏幕上的错误消息,以改善用户体验。事实上,WPF 的动画支持可以用于任何类型的应用(商业应用、多媒体程序、视频游戏等)。)每当您想要提供更吸引人的用户体验时。

正如 WPF 的许多其他方面一样,制作动画的概念并不新鲜。新的是,与您过去可能使用的其他 API(包括 Windows 窗体)不同,开发人员不需要手动创作必要的基础结构。在 WPF 下,不需要创建用于推进动画序列的后台线程或计时器,不需要定义自定义类型来表示动画,不需要擦除和重绘图像,也不需要进行繁琐的数学计算。像 WPF 的其他方面一样,你可以完全使用 XAML、完全使用 C# 代码或者两者结合来制作动画。

Note

Visual Studio 不支持使用 GUI 动画工具创作动画。如果您使用 Visual Studio 创作动画,您可以通过直接键入 XAML 来完成。然而,Blend for Visual Studio(Visual Studio 2019 附带的配套产品)确实有一个内置的动画编辑器,可以大大简化你的生活。

动画类的角色类型

为了理解 WPF 的动画支持,您必须从检查PresentationCore.dllSystem.Windows.Media.Animation名称空间中的动画类开始。在这里,您会发现 100 多个不同的类类型是使用Animation标记命名的。

这些类别可以分为三大类。第一,任何遵循命名约定数据类型 Animation ( ByteAnimationColorAnimationDoubleAnimationInt32Animation等的类。)允许您使用线性插值动画。这使您能够随着时间的推移平稳地将值从起始值更改为最终值。

接下来,遵循命名约定的类有数据类型 AnimationUsingKeyFrames ( StringAnimationUsingKeyFramesDoubleAnimationUsingKeyFramesPointAnimationUsingKeyFrames等。)表示“关键帧动画”,它允许您在一段时间内循环通过一组定义的值。例如,您可以通过在一系列单个字符之间循环,使用关键帧来更改按钮的标题。

最后,遵循数据类型 AnimationUsingPath命名约定的类(DoubleAnimationUsingPathPointAnimationUsingPath等等)是基于路径的动画,允许你动画化对象沿着你定义的路径移动。举例来说,如果您正在构建一个 GPS 应用,您可以使用基于路径的动画来沿着最快的旅行路线将项目移动到用户的目的地。

现在,很明显,这些类是而不是用来以某种方式直接向特定数据类型的变量提供动画序列(毕竟,你怎么能使用Int32Animation来制作值“9”的动画呢?).

例如,考虑一下Label类型的HeightWidth属性,这两个属性都是包装了double的依赖属性。如果你想定义一个在一段时间内增加标签高度的动画,你可以将一个DoubleAnimation对象连接到Height属性,并允许 WPF 处理实际动画本身的细节。作为另一个例子,如果你想在五秒钟内将画笔类型的颜色从绿色转换为黄色,你可以使用ColorAnimation类型来完成。

为了清楚起见,这些Animation类可以连接到匹配底层类型的给定对象的任何依赖属性。正如第二十五章所解释的,依赖属性是许多 WPF 服务所需要的一种特殊形式的属性,包括动画、数据绑定和样式。

按照惯例,依赖属性被定义为类的静态只读字段,并通过在普通属性名后面加上单词Property来命名。例如,在代码中使用Button.HeightProperty可以访问ButtonHeight属性的依赖属性。

“收件人”、“发件人”和“依据”属性

所有的Animation类都定义了以下几个关键属性,这些属性控制用于执行动画的开始和结束值:

  • To:该属性表示动画的结束值。

  • From:该属性表示动画的起始值。

  • By:该属性表示动画改变其起始值的总量。

尽管所有的Animation类都支持ToFromBy属性,但它们并不通过基类的虚拟成员接收这些属性。原因是这些属性所包装的底层类型差异很大(整数、颜色、Thickness对象等)。),并且使用单个基类来表示所有的可能性会导致复杂的编码结构。

另一方面,你可能也想知道为什么。NET 泛型不用于定义具有单一类型参数(例如,Animate<T>)的单一泛型动画类。同样,假设有这么多的底层数据类型(颜色、向量、intstring等)。)习惯了动态的依赖属性,它不会像你期望的那样是一个干净的解决方案(更不用说 XAML 对泛型类型的支持是有限的)。

时间轴基类的角色

尽管没有使用单个基类来定义虚拟的ToFromBy属性,但是Animation类确实共享一个公共基类:System.Windows.Media.Animation.Timeline。这种类型提供了几个控制动画步调的附加属性,如表 27-1 所述。

表 27-1。

Timeline基类的关键成员

|

性能

|

生命的意义

|
| — | — |
| AccelerationRatioDecelerationRatioSpeedRatio | 这些属性可用于控制动画序列的整体速度。 |
| AutoReverse | 该属性获取或设置一个值,该值指示时间轴在完成正向迭代后是否反向播放(默认值为false)。 |
| BeginTime | 此属性获取或设置此时间线的开始时间。默认值为 0,表示立即开始播放动画。 |
| Duration | 此属性允许您设置播放时间线的持续时间。 |
| FillBehaviorRepeatBehavior | 这些属性用于控制时间轴完成后应该发生的事情(重复动画,什么都不做,等等)。). |

用 C# 代码创作动画

具体来说,您将构建一个包含一个ButtonWindow,每当鼠标进入它的表面区域时,它就会有一个奇怪的旋转行为(基于左上角)。首先创建一个名为SpinningButtonAnimationApp的新 WPF 应用。将初始标记更新为以下内容(注意,您正在处理按钮的MouseEnter事件):

<Button x:Name="btnSpinner" Height="50" Width="100" Content="I Spin!"
      MouseEnter="btnSpinner_MouseEnter" Click="btnSpinner_OnClick"/>

在代码隐藏文件中,导入System.Windows.Media.Animation命名空间,并在窗口的 C# 代码文件中添加以下代码:

private bool _isSpinning=false;

private void btnSpinner_MouseEnter(
  object sender, MouseEventArgs e)
{
  if (!_isSpinning)
  {
    _isSpinning=true;
    // Make a double animation object, and register
    // with the Completed event.
    var dblAnim=new DoubleAnimation();
    dblAnim.Completed +=(o, s)=> { _isSpinning=false; };
    // Set the start value and end value.
    dblAnim.From=0;
    dblAnim.To=360;
    // Now, create a RotateTransform object, and set
    // it to the RenderTransform property of our
    // button.
    var rt=new RotateTransform();
    btnSpinner.RenderTransform=rt;
    // Now, animation the RotateTransform object.
    rt.BeginAnimation(RotateTransform.AngleProperty, dblAnim);
  }
}
private void btnSpinner_OnClick(
  object sender, RoutedEventArgs e)
{

}

该方法的第一个主要任务是配置一个DoubleAnimation对象,它将从值 0 开始,到值 360 结束。请注意,您也正在处理该对象上的Completed事件,以切换一个类级别的bool变量,该变量用于确保如果一个动画当前正在执行,您不会“重置”它以重新开始。

接下来,您创建一个连接到您的Button控件(btnSpinner)的RenderTransform属性的RotateTransform对象。最后,您通知RenderTransform对象使用您的DoubleAnimation对象开始制作其Angle属性的动画。当您在代码中创作动画时,通常通过调用BeginAnimation()来完成,然后传入您想要制作动画的底层依赖属性(记住,按照惯例,这是类上的静态字段),后面跟一个相关的动画对象。

让我们在程序中添加另一个动画,这个动画会导致按钮在被点击时淡入不可见状态。首先,在Click事件处理程序中添加以下代码:

private void btnSpinner_OnClick(
  object sender, RoutedEventArgs e)
{
  var dblAnim=new DoubleAnimation
  {
    From=1.0,
    To=0.0
  };
  btnSpinner.BeginAnimation(Button.OpacityProperty, dblAnim);
}

这里,您正在更改Opacity属性值,以使按钮淡出视图。然而,目前这很难做到,因为按钮旋转得非常快!那么,你如何控制动画的节奏呢?很高兴你问了。

控制动画的速度

默认情况下,动画在分配给FromTo属性的值之间转换大约需要一秒钟。因此,你的按钮有一秒钟的时间旋转 360 度,而按钮将在一秒钟内消失(当被点击时)。

如果您想要为动画的过渡定义一个自定义的时间量,您可以通过动画对象的Duration属性来实现,该属性可以设置为一个Duration对象的实例。通常,时间跨度是通过将一个TimeSpan对象传递给Duration的构造函数来建立的。考虑下面的更新,它将为按钮提供整整四秒的旋转时间:

private void btnSpinner_MouseEnter(
  object sender, MouseEventArgs e)
{
  if (!_isSpinning)
  {
    _isSpinning=true;

    // Make a double animation object, and register
    // with the Completed event.
    var dblAnim=new DoubleAnimation();
    dblAnim.Completed +=(o, s)=> { _isSpinning=false; };

    // Button has four seconds to finish the spin!
    dblAnim.Duration=new Duration(TimeSpan.FromSeconds(4));

...
  }
}

通过这种调整,你应该有机会在按钮旋转时点击它,此时它会逐渐消失。

Note

一个Animation类的BeginTime属性也接受一个TimeSpan对象。回想一下,可以设置该属性来建立开始动画序列之前的等待时间。

反转和循环播放动画

还可以通过将AutoReverse属性设置为true来告诉Animation对象在动画序列完成时反向播放动画。例如,如果您想让按钮在消失后重新出现,您可以编写以下代码:

private void btnSpinner_OnClick(object sender, RoutedEventArgs e)
{
  DoubleAnimation dblAnim=new DoubleAnimation
  {
    From=1.0,
    To=0.0
  };
  // Reverse when done.
  dblAnim.AutoReverse=true;
  btnSpinner.BeginAnimation(Button.OpacityProperty, dblAnim);
}

如果你想让一个动画重复一定次数(或者一旦激活就永不停止),你可以使用所有Animation类共有的RepeatBehavior属性。如果向构造函数传递一个简单的数值,可以指定硬编码的重复次数。另一方面,如果你将一个TimeSpan对象传递给构造函数,你可以确定动画应该重复的时间。最后,如果你想让一个动画无限循环*,你可以简单的指定RepeatBehavior.Forever。考虑以下方法,您可以更改本例中使用的任一DoubleAnimation对象的重复行为:*

// Loop forever.
dblAnim.RepeatBehavior=RepeatBehavior.Forever;

// Loop three times.
dblAnim.RepeatBehavior=new RepeatBehavior(3);

// Loop for 30 seconds.
dblAnim.RepeatBehavior=new RepeatBehavior(TimeSpan.FromSeconds(30));

这就结束了关于如何使用 C# 代码和 WPF 动画 API 来制作对象动画的研究。接下来,您将学习如何使用 XAML 做同样的事情。

在 XAML 创作动画

在标记中创作动画就像在代码中创作一样,至少对于简单直接的动画序列是这样。当您需要捕获更复杂的动画时,这可能涉及到一次更改许多属性的值,标记的数量可能会大大增加。即使您使用工具来生成基于 XAML 的动画,了解动画在 XAML 的基本表现方式也很重要,因为这将使您更容易修改和调整工具生成的内容。

Note

您会在可下载源代码的XamlAnimations文件夹中找到许多 XAML 文件。在接下来的几页中,将这些标记文件复制到您的自定义 XAML 编辑器或 Kaxaml 编辑器中,以查看结果。

在很大程度上,创作一部动画就像你已经看到的一样。您仍然需要配置一个Animation对象,并将它与一个对象的属性相关联。然而,一个很大的不同是,WPF 不是函数调用友好的。因此,您不用调用BeginAnimation(),而是使用故事板作为间接层。

让我们看一个用 XAML 定义的动画的完整例子,然后是一个详细的分解。下面的 XAML 定义将显示一个包含单个标签的窗口。一旦Label对象加载到内存中,它就开始一个动画序列,其中字体大小在 4 秒内从 12 磅增加到 100 磅。只要Window对象加载到内存中,动画就会重复播放。您可以在GrowLabelFont.xaml文件中找到这个标记,所以将它复制到 Kaxaml 中(确保按 F5 显示窗口)并观察行为。

<Window
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Height="200" Width="600" WindowStartupLocation="CenterScreen" Title="Growing Label Font!">
  <StackPanel>
    <Label Content="Interesting...">
      <Label.Triggers>
        <EventTrigger RoutedEvent="Label.Loaded">
          <EventTrigger.Actions>
            <BeginStoryboard>
              <Storyboard TargetProperty="FontSize">
                <DoubleAnimation From="12" To="100" Duration="0:0:4"
                  RepeatBehavior="Forever"/>
              </Storyboard>
            </BeginStoryboard>
          </EventTrigger.Actions>
        </EventTrigger>
      </Label.Triggers>
    </Label>
  </StackPanel>
</Window>

现在,让我们一点一点地分解这个例子。

故事板的作用

从最里面的元素开始,您首先会遇到<DoubleAnimation>元素,它利用了您在过程代码中设置的相同属性(FromToDurationRepeatBehavior)。

<DoubleAnimation From="12" To="100" Duration="0:0:4"
                 RepeatBehavior="Forever"/>

如上所述,Animation元素被放置在一个<Storyboard>元素中,该元素用于通过TargetProperty属性将动画对象映射到父类型上的给定属性,在本例中是FontSize。一个<Storyboard>总是被包装在一个名为<BeginStoryboard>的父元素中。

<BeginStoryboard>
  <Storyboard TargetProperty="FontSize">
    <DoubleAnimation From="12" To="100" Duration="0:0:4"
                     RepeatBehavior="Forever"/>
  </Storyboard>
</BeginStoryboard>

事件触发器的作用

在定义了<BeginStoryboard>元素之后,您需要指定某种动作来使动画开始执行。WPF 有几种不同的方式来响应标记中的运行时条件,其中一种被称为触发器。从高层次来看,您可以将触发器视为一种响应 XAML 事件条件的方式,而不需要过程代码。

通常,当您在 C# 中响应事件时,您创作的自定义代码将在事件发生时执行。然而,触发器只是一种被通知某些事件条件已经发生的方式(“我被加载到内存中!”或者“鼠标在我身上!”或者“我有焦点了!”).

一旦你被通知一个事件条件已经发生,你就可以开始故事板。在本例中,您正在响应加载到内存中的Label。因为您感兴趣的是LabelLoaded事件,所以<EventTrigger>被放在Label的触发集合中。

<Label Content="Interesting...">
  <Label.Triggers>
    <EventTrigger RoutedEvent="Label.Loaded">
      <EventTrigger.Actions>
        <BeginStoryboard>
          <Storyboard TargetProperty="FontSize">
            <DoubleAnimation From="12" To="100" Duration="0:0:4"
                             RepeatBehavior="Forever"/>
          </Storyboard>
        </BeginStoryboard>
      </EventTrigger.Actions>
    </EventTrigger>
  </Label.Triggers>
</Label>

让我们看另一个在 XAML 定义动画的例子,这次使用一个关键帧动画。

使用离散关键帧的动画

与只能在起点和终点之间移动的线性插值动画对象不同,关键帧副本允许您为应该在特定时间发生的动画创建特定值的集合。

为了说明离散关键帧类型的用法,假设您想要构建一个Button控件,它可以使其内容动画化,这样在三秒钟的时间内,值“OK!”一次显示一个字符。您将在AnimateString.xaml文件中找到以下标记。将这个标记复制到您的MyXamlPad.exe程序(或 Kaxaml)中,并查看结果:

<Window xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   Height="100" Width="300"
   WindowStartupLocation="CenterScreen" Title="Animate String Data!">
   <StackPanel>
     <Button Name="myButton" Height="40"
             FontSize="16pt" FontFamily="Verdana" Width="100">
      <Button.Triggers>
        <EventTrigger RoutedEvent="Button.Loaded">
          <BeginStoryboard>
            <Storyboard>
              <StringAnimationUsingKeyFrames RepeatBehavior="Forever"
                Storyboard.TargetProperty="Content"
                Duration="0:0:3">
                <DiscreteStringKeyFrame Value="" KeyTime="0:0:0" />
                <DiscreteStringKeyFrame Value="O" KeyTime="0:0:1" />
                <DiscreteStringKeyFrame Value="OK" KeyTime="0:0:1.5" />
                <DiscreteStringKeyFrame Value="OK!" KeyTime="0:0:2" />
              </StringAnimationUsingKeyFrames>
            </Storyboard>
          </BeginStoryboard>
        </EventTrigger>
      </Button.Triggers>
    </Button>
  </StackPanel>
</Window>

首先,请注意,您已经为按钮定义了一个事件触发器,以确保当按钮加载到内存中时故事板会执行。StringAnimationUsingKeyFrames类通过Storyboard.TargetProperty值监督按钮内容的改变。

<StringAnimationUsingKeyFrames>元素的范围内,您定义了四个DiscreteStringKeyFrame元素,它们在两秒钟内改变按钮的Content属性(注意StringAnimationUsingKeyFrames建立的持续时间总共是三秒钟,因此您将看到在最后的!和循环O之间有一个轻微的停顿)。

既然您对如何用 C# 代码和 XAML 构建动画有了更好的感觉,让我们看看 WPF 风格的作用,它大量使用图形、对象资源和动画。

了解 WPF 风格的作用

当您构建 WPF 应用的用户界面时,一系列控件需要共享的外观并不罕见。例如,您可能希望所有按钮类型的字符串内容具有相同的高度、宽度、背景颜色和字体大小。虽然您可以通过将每个按钮的单个属性设置为相同的值来解决这个问题,但这种方法很难实现后续的更改,因为每次更改都需要在多个对象上重置相同的属性集。

幸运的是,WPF 提供了一种简单的方法来约束使用风格的相关控件的外观和感觉。简单地说,WPF 样式是一个维护属性-值对集合的对象。从编程的角度来说,使用System.Windows.Style类来表示一个单独的样式。这个类有一个名为Setters的属性,它公开了一个Setter对象的强类型集合。是Setter对象允许您定义属性-值对。

除了Setters集合之外,Style类还定义了一些其他重要的成员,这些成员允许您合并触发器,限制可以应用样式的位置,甚至基于现有的样式创建新的样式(可以将其视为“样式继承”)。注意下面这个Style类的成员:

  • Triggers:公开一个触发器对象集合,允许您在一个样式中捕获各种事件条件

  • BasedOn:允许您在现有样式的基础上构建新样式

  • TargetType:允许您限制样式的应用位置

定义和应用样式

几乎在每种情况下,一个Style对象都会被打包成一个对象资源。像任何对象资源一样,您可以在窗口或应用级别打包它,以及在一个专用的资源字典中打包(这很好,因为它使Style对象在整个应用中很容易被访问)。现在回想一下,目标是定义一个用一组属性-值对填充(至少)集合的Style对象。

让我们构建一个样式,它可以捕获应用中控件的基本字体特征。首先创建一个名为WpfStyles的新 WPF 应用。打开您的App.xaml文件并定义以下命名样式:

<Application.Resources>
  <Style x:Key="BasicControlStyle">
    <Setter Property="Control.FontSize" Value="14"/>
    <Setter Property="Control.Height" Value="40"/>
    <Setter Property="Control.Cursor" Value="Hand"/>
  </Style>
</Application.Resources>

注意,您的BasicControlStyle向内部集合添加了三个Setter对象。现在,让我们将这种风格应用到主窗口中的几个控件上。因为这个样式是一个对象资源,想要使用它的控件仍然需要使用{StaticResource}{DynamicResource}标记扩展来定位样式。当他们找到样式时,他们会将资源项设置为同名的Style属性。用以下标记替换默认的Grid控件:

<StackPanel>
  <Label x:Name="lblInfo" Content="This style is boring..."
         Style="{StaticResource BasicControlStyle}" Width="150"/>
  <Button x:Name="btnTestButton" Content="Yes, but we are reusing settings!"
         Style="{StaticResource BasicControlStyle}" Width="250"/>
</StackPanel>

如果您在 Visual Studio 设计器中查看Window(或者运行应用),您会发现两个控件都支持相同的光标、高度和字体大小。

覆盖样式设置

虽然你的两个控件都选择了样式,但是如果一个控件想要应用一个样式,然后改变一些已定义的设置,那也没问题。例如,Button现在将使用Help光标(而不是样式中定义的Hand光标)。

<Button x:Name="btnTestButton" Content="Yes, but we are reusing settings!"
        Cursor="Help" Style="{StaticResource BasicControlStyle}" Width="250" />

在使用样式的控件的单个属性设置之前处理样式;因此,控件可以根据具体情况“覆盖”设置。

目标类型对样式的影响

目前,你的风格是以这样一种方式定义的,任何控件都可以采用它(并且必须通过设置控件的Style属性显式地这样做),假设每个属性都由Control类限定。对于一个定义了许多设置的程序来说,这需要大量的重复代码。稍微清理一下这种风格的一种方法是使用TargetType属性。当您将该属性添加到Style的开始元素时,您可以准确地标记一次它可以应用的位置(在本例中,在App.XAML)。

<Style x:Key="BasicControlStyle" TargetType="Control">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Height" Value="40"/>
  <Setter Property="Cursor" Value="Hand"/>
</Style>

Note

当您生成使用基类类型的样式时,您不必担心是否将值赋给了派生类型不支持的依赖属性。如果派生类型不支持给定的依赖项属性,则忽略该属性。

这在一定程度上是有帮助的,但是您仍然有一种可以应用于任何控件的样式。当您想要定义一个只能应用于特定类型控件的样式时,TargetType属性会更有用。将以下新样式添加到应用的资源字典中:

<Style x:Key="BigGreenButton" TargetType="Button">
  <Setter Property="FontSize" Value="20"/>
  <Setter Property="Height" Value="100"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Background" Value="DarkGreen"/>
  <Setter Property="Foreground" Value="Yellow"/>
</Style>

这种风格只适用于Button控件(或Button的子类)。如果将它应用于不兼容的元素,将会出现标记和编译器错误。添加一个使用这个新样式的新Button,如下所示:

<Button x:Name="btnAnotherButton" Content="OK!" Margin="0,10,0,0"
    Style="{StaticResource BigGreenButton}" Width="250" Cursor="Help"/>

您将看到如图 27-7 所示的输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-7。

不同样式的控件

TargetType的另一个作用是,如果x:Key属性不存在,样式将被应用到样式定义范围内该类型的所有元素。

下面是另一个应用级样式,它将自动应用于当前应用中的所有TextBox控件:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
</Style>

您现在可以定义任意数量的TextBox控件,它们将自动获得定义的外观。如果给定的TextBox不想要这个默认的外观,它可以通过将Style属性设置为{x:Null}来退出。例如,txtTest将获得默认的未命名样式,而txtTest2则以自己的方式做事。

<TextBox x:Name="txtTest"/>
<TextBox x:Name="txtTest2" Style="{x:Null}" BorderBrush="Black"
       BorderThickness="5" Height="60" Width="100" Text="Ha!"/>

子类化现有样式

您还可以通过BasedOn属性使用现有的样式构建新的样式。您正在扩展的样式必须在字典中被赋予一个合适的x:Key,因为派生的样式将使用{StaticResource}{DynamicResource}标记扩展通过名称引用它。下面是一个基于BigGreenButton的新样式,它将按钮元素旋转了 20 度:

<!-- This style is based on BigGreenButton. -->
<Style x:Key="TiltButton" TargetType="Button" BasedOn="{StaticResource BigGreenButton}">
  <Setter Property="Foreground" Value="White"/>
  <Setter Property="RenderTransform">
    <Setter.Value>
      <RotateTransform Angle="20"/>
    </Setter.Value>
  </Setter>
</Style>

要使用这种新样式,请将按钮的标记更新为:

<Button x:Name="btnAnotherButton" Content="OK!" Margin="0,10,0,0"
    Style="{StaticResource TiltButton}" Width="250" Cursor="Help"/>

这将改变图 27-8 所示图像的外观。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-8。

使用派生样式

使用触发器定义样式

通过将Trigger对象打包到Style对象的Triggers集合中,WPF 样式也可以包含触发器。在一个样式中使用触发器允许您定义某些<Setter>元素,使得它们只有在给定的触发条件为true时才会被应用。例如,当鼠标停留在按钮上时,您可能想要增加字体的大小。或者,您可能希望确保具有当前焦点的文本框以给定的颜色突出显示。触发器对于这类情况很有用,因为它们允许您在属性更改时采取特定的操作,而无需在代码隐藏文件中编写显式 C# 代码。

下面是对TextBox样式的更新,确保当TextBox拥有输入焦点时,它将获得黄色背景:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
  <!-- The following setter will be applied only when the text box is in focus. -->
  <Style.Triggers>
    <Trigger Property="IsFocused" Value="True">
      <Setter Property="Background" Value="Yellow"/>
    </Trigger>
  </Style.Triggers>
</Style>

如果你测试这种风格,你会发现当你在不同的TextBox对象之间切换时,当前选中的TextBox有一个亮黄色的背景(假设它没有通过将{x:Null}分配给Style属性而退出)。

属性触发器也非常智能,当触发器的条件为非真时,属性会自动接收默认的赋值。因此,一旦TextBox失去焦点,它也会自动变成默认颜色,无需您做任何工作。相反,事件触发器(在你看 WPF 动画时检查过)不会自动回复到先前的状态。

使用多个触发器定义样式

触发器也可以这样设计,当多个条件为真时,定义的<Setter>元素将被应用。假设您想将一个TextBox的背景设置为Yellow,只要它有活动的焦点并且鼠标在它的边界内悬停。为此,您可以利用<MultiTrigger>元素来定义每个条件,如下所示:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
  <!-- The following setter will be applied only when the text box is
  in focus AND the mouse is over the text box. -->
  <Style.Triggers>
    <MultiTrigger>
      <MultiTrigger.Conditions>
            <Condition Property="IsFocused" Value="True"/>
            <Condition Property="IsMouseOver" Value="True"/>
        </MultiTrigger.Conditions>
      <Setter Property="Background" Value="Yellow"/>
    </MultiTrigger>
  </Style.Triggers>
</Style>

动画样式

样式还可以包含启动动画序列的触发器。下面是最后一个样式,当应用于Button控件时,当鼠标在按钮的表面区域内时,它将导致控件的大小增大和缩小:

<!-- The growing button style! -->
<Style x:Key="GrowingButtonStyle" TargetType="Button">
  <Setter Property="Height" Value="40"/>
  <Setter Property="Width" Value="100"/>
  <Style.Triggers>
    <Trigger Property="IsMouseOver" Value="True">
      <Trigger.EnterActions>
        <BeginStoryboard>
          <Storyboard TargetProperty="Height">
            <DoubleAnimation From="40" To="200"
                             Duration="0:0:2" AutoReverse="True"/>
          </Storyboard>
        </BeginStoryboard>
      </Trigger.EnterActions>
    </Trigger>
  </Style.Triggers>
</Style>

在这里,Triggers集合正在寻找IsMouseOver属性来返回true。当这种情况发生时,您定义一个<Trigger.EnterActions>元素来执行一个简单的故事板,强制按钮在两秒钟内增长到200Height值(然后返回到40Height)。如果您想要执行其他的属性更改,您也可以定义一个<Trigger.ExitActions>范围来定义当IsMouseOver更改为false时要采取的任何自定义动作。

以编程方式分配样式

回想一下,样式也可以在运行时应用。如果您想让最终用户选择他们的用户界面的外观和感觉,或者如果您需要基于安全设置(例如,DisableAllButton样式)或您所拥有的东西来加强外观和感觉,这可能是有帮助的。

在这个项目中,您定义了几种样式,其中许多可以应用于Button控件。所以,让我们重组主窗口的 UI,让用户通过在ListBox中选择名字来选择这些风格。根据用户的选择,您将应用适当的样式。下面是<Window>元素的新的(也是最终的)标记:

<DockPanel >
  <StackPanel Orientation="Horizontal" DockPanel.Dock="Top" Margin="0,0,0,50">
    <Label Content="Please Pick a Style for this Button" Height="50"/>
    <ListBox x:Name="lstStyles" Height="80" Width="150" Background="LightBlue"
             SelectionChanged="comboStyles_Changed" />
  </StackPanel>
  <Button x:Name="btnStyle" Height="40" Width="100" Content="OK!"/>
</DockPanel>

ListBox控件(名为lstStyles)将在窗口的构造函数中动态填充,如下所示:

public MainWindow()
{
  InitializeComponent();
  // Fill the list box with all the Button styles.
  lstStyles.Items.Add("GrowingButtonStyle");
  lstStyles.Items.Add("TiltButton");
  lstStyles.Items.Add("BigGreenButton");
  lstStyles.Items.Add("BasicControlStyle");}
}

最后一个任务是处理相关代码文件中的SelectionChanged事件。注意在下面的代码中,如何使用继承的TryFindResource()方法按名称提取当前资源:

private void comboStyles_Changed(object sender, SelectionChangedEventArgs e)
{
  // Get the selected style name from the list box.
  var currStyle=(Style)TryFindResource(lstStyles.SelectedValue);
  if (currStyle==null) return;
  // Set the style of the button type.
  this.btnStyle.Style=currStyle;
}

当您运行这个应用时,您可以从这四种按钮样式中选择一种。图 27-9 显示了您完成的申请。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-9。

不同样式的控件

逻辑树、可视化树和默认模板

现在您已经了解了样式和资源,在开始学习如何构建自定义控件之前,还有几个准备主题需要研究。具体来说,您需要了解逻辑树、可视化树和默认模板之间的区别。当你在 Visual Studio 或类似kaxaml.exe的工具中输入 XAML 时,你的标记就是 XAML 文档的逻辑视图。同样,如果您编写了向布局控件添加新项的 C# 代码,您就是在向逻辑树中插入新项。本质上,一个逻辑视图代表了你的内容将如何在主Window(或者另一个根元素,比如Page或者NavigationWindow)的各种布局管理器中定位。

然而,在每个逻辑树的背后是一个更详细的表示,称为视觉树,WPF 内部使用它来正确地将元素渲染到屏幕上。在任何视觉树中,都有用于呈现每个对象的模板和样式的完整细节,包括任何必要的绘图、形状、视觉效果和动画。

理解逻辑树和可视化树之间的区别非常有用,因为当您生成自定义控件模板时,实际上是替换控件的全部或部分默认可视化树并插入您自己的树。因此,如果您想要将Button控件呈现为星形,您可以定义一个新的星形模板,并将其插入到Button的可视化树中。从逻辑上讲,Button仍然是类型Button,它支持预期的属性、方法和事件。但在视觉上,它呈现出全新的面貌。鉴于其他工具包会要求您构建一个新的类来制作星形按钮,仅这一事实就使 WPF 成为一个极其有用的 API。有了 WPF,你只需要定义新的标记。

Note

WPF 控件通常被描述为无外观。这是指这样一个事实,即 WPF 控件的外观和感觉完全独立于它的行为。

以编程方式检查逻辑树

虽然在运行时分析一个窗口的逻辑树并不是一个非常常见的 WPF 编程活动,但是值得一提的是,System.Windows名称空间定义了一个名为LogicalTreeHelper的类,它允许您在运行时检查逻辑树的结构。为了说明逻辑树、可视化树和控件模板之间的联系,创建一个名为 TreesAndTemplatesApp 的新 WPF 应用。

用以下标记替换Grid,该标记包含两个Button控件和一个启用滚动条的大只读TextBox。确保使用 IDE 处理每个按钮的Click事件。下面的 XAML 会做得很好:

<DockPanel LastChildFill="True">
  <Border Height="50" DockPanel.Dock="Top" BorderBrush="Blue">
    <StackPanel Orientation="Horizontal">
      <Button x:Name="btnShowLogicalTree" Content="Logical Tree of Window"
            Margin="4" BorderBrush="Blue" Height="40" Click="btnShowLogicalTree_Click"/>
      <Button x:Name="btnShowVisualTree" Content="Visual Tree of Window"
            BorderBrush="Blue" Height="40" Click="btnShowVisualTree_Click"/>
    </StackPanel>
  </Border>
  <TextBox x:Name="txtDisplayArea" Margin="10" Background="AliceBlue" IsReadOnly="True"
         BorderBrush="Red" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" />
</DockPanel>

在 C# 代码文件中,定义一个名为 _ dataToShowstring成员变量。现在,在btnShowLogicalTree对象的Click处理程序中,调用一个 helper 函数,该函数递归地调用自身,用Window的逻辑树填充字符串变量。为此,您将调用LogicalTreeHelper的静态GetChildren()方法。下面是代码:

private string _dataToShow=string.Empty;

private void btnShowLogicalTree_Click(object sender, RoutedEventArgs e)
{
  _dataToShow="";
  BuildLogicalTree(0, this);
  txtDisplayArea.Text=_dataToShow;
}

void BuildLogicalTree(int depth, object obj)
{
  // Add the type name to the dataToShow member variable.
  _dataToShow +=new string(' ', depth) + obj.GetType().Name + "\n";
  // If an item is not a DependencyObject, skip it.
  if (!(obj is DependencyObject))
    return;
  // Make a recursive call for each logical child.
  foreach (var child in LogicalTreeHelper.GetChildren((DependencyObject)obj))
  {
      BuildLogicalTree(depth + 5, child);
  }
}
private void btnShowVisualTree_Click(
  object sender, RoutedEventArgs e)
{
}

如果你运行你的应用并点击第一个按钮,你会在文本区域看到一个树形图,它几乎是原始 XAML 的精确复制品(见图 27-10 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-10。

在运行时查看逻辑树

以编程方式检查可视化树

使用System.Windows.MediaVisualTreeHelper类也可以在运行时检查Window的可视化树。下面是第二个Button控件(btnShowVisualTree)的Click实现,它执行类似的递归逻辑来构建视觉树的文本表示:

using System.Windows.Media;

private void btnShowVisualTree_Click(object sender, RoutedEventArgs e)
{
  _dataToShow="";
  BuildVisualTree(0, this);
  txtDisplayArea.Text=_dataToShow;
}
void BuildVisualTree(int depth, DependencyObject obj)
{
  // Add the type name to the dataToShow member variable.
  _dataToShow +=new string(' ', depth) + obj.GetType().Name + "\n";
  // Make a recursive call for each visual child.
  for (int i=0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
  {
    BuildVisualTree(depth + 1, VisualTreeHelper.GetChild(obj, i));
  }
}

如图 27-11 所示,视觉树公开了几个低级渲染代理,如ContentPresenterAdornerDecoratorTextBoxLineDrawingVisual等。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-11。

在运行时查看可视化树

以编程方式检查控件的默认模板

回想一下,WPF 使用视觉树来理解如何呈现一个Window和所有包含的元素。每个 WPF 控件都在其默认模板中存储自己的呈现命令集。从编程的角度来说,任何模板都可以表示为ControlTemplate类的一个实例。同样,您可以通过使用名副其实的Template属性来获得控件的默认模板,如下所示:

// Get the default template of the Button.
Button myBtn=new Button();
ControlTemplate template=myBtn.Template;

同样,您可以在代码中创建一个新的ControlTemplate对象,并将其插入控件的Template属性,如下所示:

// Plug in a new template for the button to use.
Button myBtn=new Button();
ControlTemplate customTemplate=new ControlTemplate();

// Assume this method adds all the code for a star template.
MakeStarTemplate(customTemplate);
myBtn.Template=customTemplate;

虽然您可以用代码构建一个新的模板,但在 XAML 这样做要常见得多。但是,在您开始构建自己的模板之前,让我们先完成当前的示例,并添加在运行时查看 WPF 控件的默认模板的功能。这是查看模板整体构成的一种有用方式。用停靠在主控件DockPanel左侧的新控件StackPanel更新窗口的标记,定义如下(放置在<TextBox>元素之前):

<Border DockPanel.Dock="Left" Margin="10" BorderBrush="DarkGreen" BorderThickness="4" Width="358">
  <StackPanel>
    <Label Content="Enter Full Name of WPF Control" Width="340" FontWeight="DemiBold" />
    <TextBox x:Name="txtFullName" Width="340" BorderBrush="Green"
             Background="BlanchedAlmond" Height="22" Text="System.Windows.Controls.Button" />
    <Button x:Name="btnTemplate" Content="See Template" BorderBrush="Green"
            Height="40" Width="100" Margin="5" Click="btnTemplate_Click" HorizontalAlignment="Left" />
    <Border BorderBrush="DarkGreen" BorderThickness="2" Height="260"
            Width="301" Margin="10" Background="LightGreen" >
      <StackPanel x:Name="stackTemplatePanel" />
    </Border>
  </StackPanel>
</Border>

btnTemplate_Click()事件添加一个空事件处理函数,如下所示:

private void btnTemplate_Click(
  object sender, RoutedEventArgs e)
{
}

左上角的文本区允许你输入位于PresentationFramework.dll组件中的 WPF 控件的全限定名。一旦库被加载,您将动态地创建对象的一个实例,并将其显示在左下角的大方框中。最后,控件的默认模板将显示在右边的文本区域。首先,向类型为Control的 C# 类添加一个新的成员变量,如下所示:

private Control _ctrlToExamine=null;

下面是剩余的代码,它要求您导入System.ReflectionSystem.XmlSystem.Windows.Markup名称空间:

private void btnTemplate_Click(
  object sender, RoutedEventArgs e)
{
  _dataToShow="";
  ShowTemplate();
  txtDisplayArea.Text=_dataToShow;
}

private void ShowTemplate()
{
  // Remove the control that is currently in the preview area.
  if (_ctrlToExamine !=null)
    stackTemplatePanel.Children.Remove(_ctrlToExamine);
  try
  {
    // Load PresentationFramework, and create an instance of the
    // specified control. Give it a size for display purposes, then add to the
    // empty StackPanel.
    Assembly asm=Assembly.Load("PresentationFramework, Version=4.0.0.0," +
      "Culture=neutral, PublicKeyToken=31bf3856ad364e35");
    _ctrlToExamine=(Control)asm.CreateInstance(txtFullName.Text);
    _ctrlToExamine.Height=200;
    _ctrlToExamine.Width=200;
    _ctrlToExamine.Margin=new Thickness(5);
    stackTemplatePanel.Children.Add(_ctrlToExamine);
    // Define some XML settings to preserve indentation.
    var xmlSettings=new XmlWriterSettings{Indent=true};
    // Create a StringBuilder to hold the XAML.
    var strBuilder=new StringBuilder();
    // Create an XmlWriter based on our settings.
    var xWriter=XmlWriter.Create(strBuilder, xmlSettings);
    // Now save the XAML into the XmlWriter object based on the ControlTemplate.
    XamlWriter.Save(_ctrlToExamine.Template, xWriter);
    // Display XAML in the text box.
    _dataToShow=strBuilder.ToString();
  }
  catch (Exception ex)
  {
    _dataToShow=ex.Message;
  }
}

大部分工作只是修补编译后的 BAML 资源,将其映射成 XAML 字符串。图 27-12 显示了你的最终应用,显示了System.Windows.Controls.DatePicker控件的默认模板。图像显示的是Calendar,点击控件右侧的按钮即可进入。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-12。

在运行时调查一个ControlTemplate

太好了。您应该对逻辑树、可视化树和控件默认模板如何协同工作有了更好的了解。现在,您可以用本章的剩余部分来学习如何构建自定义模板和用户控件。

使用触发器框架构建控件模板

当您为控件生成自定义模板时,除了 C# 代码之外,您什么都不用做。使用这种方法,您可以将数据添加到一个ControlTemplate对象,然后将它分配给一个控件的Template属性。然而,大多数时候,您将使用 XAML 定义一个ControlTemplate的外观,并添加一些代码(或者可能是相当多的代码)来驱动运行时行为。

在本章的剩余部分,您将研究如何使用 Visual Studio 构建自定义模板。在此过程中,您将了解 WPF 触发器框架和可视化状态管理器(VSM),并了解如何使用动画为最终用户提供可视化提示。单独使用 Visual Studio 来构建复杂的模板可能需要大量的输入和一些繁重的工作。可以肯定的是,生产级模板将受益于 Blend for Visual Studio,这是随 Visual Studio 一起安装的(现在)免费配套应用。然而,鉴于这一版本的文本不包括 Blend 的覆盖范围,是时候卷起袖子敲打一些标记了。

首先,创建一个名为 ButtonTemplate 的新 WPF 应用。对于这个项目,您对创建和使用模板的机制更感兴趣,所以用下面的标记替换Grid:

  <StackPanel Orientation="Horizontal">
    <Button x:Name="myButton" Width="100" Height="100" Click="myButton_Click"/>
  </StackPanel>

Click事件处理程序中,简单地显示一个消息框(通过MessageBox.Show())来显示一条确认控件点击的消息。记住,当你构建定制模板时,控件的行为是不变的,但是外观可能会变化。

目前,这个Button是使用默认模板呈现的,如前面的例子所示,它是给定 WPF 程序集中的一个 BAML 资源。当您想要定义自己的模板时,实际上是用您自己的创建来替换这个默认的可视化树。首先,更新<Button>元素的定义,使用 property-element 语法指定一个新模板。该模板将使控件具有圆形外观。

<Button x:Name="myButton" Width="100" Height="100" Click="myButton_Click">
  <Button.Template>
    <ControlTemplate>
      <Grid x:Name="controlLayout">
        <Ellipse x:Name="buttonSurface" Fill="LightBlue"/>
        <Label x:Name="buttonCaption"
        VerticalAlignment="Center"
        HorizontalAlignment="Center"
        FontWeight="Bold" FontSize="20" Content="OK!"/>
      </Grid>
    </ControlTemplate>
  </Button.Template>
</Button>

这里,您已经定义了一个模板,它由一个名为Grid的控件组成,该控件包含一个名为Ellipse的控件和一个名为Label的控件。因为您的Grid没有已定义的行或列,所以每个子控件都堆叠在前一个控件的顶部,使内容居中。如果您现在运行您的应用,您会注意到当鼠标光标在Ellipse的边界内时,Click事件将只触发*!这是 WPF 模板架构的一个很大的特点:你不需要重新计算命中测试、边界检查或者任何其他底层细节。因此,如果你的模板使用了一个Polygon对象来呈现一些奇怪的几何图形,你可以放心,鼠标点击测试的细节是相对于控件的形状,而不是更大的边框。*

模板作为资源

目前,您的模板被嵌入到一个特定的Button控件中,这限制了重用。理想情况下,您应该将模板放在资源字典中,以便可以在项目之间重用圆形按钮模板,或者至少将它移动到应用资源容器中,以便在该项目中重用。让我们通过从Button中剪切模板定义并将其粘贴到App.xaml文件的Application.Resources标签中,将本地Button资源移动到应用级别。添加一个Key和一个TargetType,如下所示:

<Application.Resources>
  <ControlTemplate x:Key="RoundButtonTemplate" TargetType="{x:Type Button}">
    <Grid x:Name="controlLayout">
      <Ellipse x:Name="buttonSurface" Fill="LightBlue"/>
      <Label x:Name="buttonCaption" VerticalAlignment="Center" HorizontalAlignment="Center"
             FontWeight="Bold" FontSize="20" Content="OK!"/>
    </Grid>
  </ControlTemplate>
</Application.Resources>

Button标记更新为以下内容:

<Button x:Name="myButton" Width="100" Height="100"
  Click="myButton_Click"
  Template="{StaticResource RoundButtonTemplate}">
</Button>

现在,因为整个应用都可以使用这个资源,所以只需应用模板就可以定义任意数量的圆形按钮。创建两个额外的Button控件,使用这个模板进行测试(不需要为这些新项目处理Click事件)。

<StackPanel>
  <Button x:Name="myButton" Width="100" Height="100"
    Click="myButton_Click"
    Template="{StaticResource RoundButtonTemplate}"></Button>
  <Button x:Name="myButton2" Width="100" Height="100"
    Template="{StaticResource RoundButtonTemplate}"></Button>
  <Button x:Name="myButton3" Width="100" Height="100"
    Template="{StaticResource RoundButtonTemplate}"></Button>
</StackPanel>

使用触发器整合视觉提示

定义自定义模板时,默认模板的视觉提示也会被删除。例如,默认的 button 模板包含一些标记,这些标记通知控件在某些 UI 事件发生时如何显示,例如当它获得焦点时、用鼠标单击时、启用(或禁用)时等等。用户非常习惯于这种视觉提示,因为它给了控件某种程度的触觉反应。然而,您的RoundButtonTemplate没有定义任何这样的标记,所以无论鼠标活动如何,控件的外观都是相同的。理想情况下,你的控件在被点击时看起来应该有所不同(可能通过颜色变化或阴影),让用户知道视觉状态已经改变。

正如您已经了解到的,这可以通过使用触发器来完成。对于简单的操作,触发器工作得非常好。还有其他方法可以做到这一点,超出了本书的范围,但在 https://docs.microsoft.com/en-us/dotnet/desktop-wpf/themes/how-to-create-apply-template 可以获得更多信息。

举例来说,用下面的标记更新您的RoundButtonTemplate,它添加了两个触发器。第一个将在鼠标停留在表面上时将控件的颜色更改为蓝色,前景色更改为黄色。第二种方法是在通过鼠标按下控件时缩小Grid(以及所有子元素)的大小。

<ControlTemplate x:Key="RoundButtonTemplate" TargetType="Button" >
  <Grid x:Name="controlLayout">
    <Ellipse x:Name="buttonSurface" Fill="LightBlue" />
    <Label x:Name="buttonCaption" Content="OK!"
      FontSize="20" FontWeight="Bold"
      HorizontalAlignment="Center"
      VerticalAlignment="Center" />
  </Grid>
    <ControlTemplate.Triggers>
      <Trigger Property="IsMouseOver" Value="True">
        <Setter TargetName="buttonSurface" Property="Fill"
          Value="Blue"/>
        <Setter TargetName="buttonCaption"
          Property="Foreground" Value="Yellow"/>
      </Trigger>
      <Trigger Property="IsPressed" Value="True">
        <Setter TargetName="controlLayout"
           Property="RenderTransformOrigin" Value="0.5,0.5"/>
        <Setter TargetName="controlLayout"
          Property="RenderTransform">
          <Setter.Value>
            <ScaleTransform ScaleX="0.8" ScaleY="0.8"/>
          </Setter.Value>
        </Setter>
      </Trigger>
  </ControlTemplate.Triggers>
</ControlTemplate>

{TemplateBinding}标记扩展的角色

控件模板的问题是每个按钮看起来和说的都一样。将标记更新为以下内容没有任何效果:

<Button x:Name="myButton" Width="100" Height="100"
  Background="Red" Content="Howdy!" Click="myButton_Click"
  Template="{StaticResource RoundButtonTemplate}" />
<Button x:Name="myButton2" Width="100" Height="100"
  Background="LightGreen" Content="Cancel!" Template="{StaticResource RoundButtonTemplate}" />
<Button x:Name="myButton3" Width="100" Height="100"
  Background="Yellow" Content="Format" Template="{StaticResource RoundButtonTemplate}" />

这是因为控件的默认属性(如BackGroundContent)在模板中被覆盖。要启用它们,必须将它们映射到模板中的相关属性。您可以在构建模板时使用{TemplateBinding}标记扩展来解决这些问题。这允许您使用模板捕获由控件定义的属性设置,并使用它们来设置模板本身中的值。

下面是RoundButtonTemplate的修改版本,它现在使用这个标记扩展将ButtonBackground属性映射到EllipseFill属性;它还确保了ButtonContent确实被传递给了LabelContent属性:

<Ellipse x:Name="buttonSurface" Fill="{TemplateBinding Background}"/>
<Label x:Name="buttonCaption" Content="{TemplateBinding Content}"
  FontSize="20" FontWeight="Bold" HorizontalAlignment="Center"
  VerticalAlignment="Center" />

通过此次更新,您现在可以创建各种颜色和文本值的按钮。图 27-13 显示了 XAML 更新后的结果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 27-13。

模板绑定允许值传递给内部控件。

内容演示者的角色

当你设计你的模板时,你使用了一个Label来显示控件的文本值。像Button一样,Label支持一个Content属性。因此,考虑到您对{TemplateBinding}的使用,您可以定义一个包含复杂内容的Button,而不仅仅是一个简单的字符串。

然而,如果您需要将复杂的内容传递给一个没有没有Content属性的模板成员,该怎么办呢?当您想要在模板中定义一个通用的内容显示区域时,您可以使用ContentPresenter类,而不是特定类型的控件(LabelTextBlock)。对于这个例子,没有必要这样做;然而,这里有一些简单的标记说明了如何构建一个使用ContentPresenter的定制模板,以显示使用该模板的控件的Content属性的值:

<!-- This button template will display whatever is set to the Content of the hosting button. -->
<ControlTemplate x:Key="NewRoundButtonTemplate" TargetType="Button">
  <Grid>
    <Ellipse Fill="{TemplateBinding Background}"/>
    <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
  </Grid>
</ControlTemplate>

将模板并入样式

目前,你的模板仅仅定义了Button控件的基本外观。但是,建立控件的基本属性(内容、字体大小、字体粗细等)的过程。)是Button本身的责任。

<!-- Currently the Button must set basic property values, not the template. -->
<Button x:Name="myButton" Foreground="Black" FontSize="20"
  FontWeight="Bold"
  Template="{StaticResource RoundButtonTemplate}"
  Click="myButton_Click"/>

如果您愿意,您可以在模板中建立这些值*。通过这样做,您可以有效地创建默认的外观。你可能已经意识到了,这是 WPF·斯泰尔斯的工作。当您构建一个样式时(考虑到基本的属性设置),您可以在样式中定义一个模板!这是您在App.xaml的应用资源中更新的应用资源,它已被重设密钥为RoundButtonStyle😗

<!-- A style containing a template. -->
<Style x:Key="RoundButtonStyle" TargetType="Button">
  <Setter Property="Foreground" Value="Black"/>
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="FontWeight" Value="Bold"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="100"/>
  <!-- Here is the template! -->
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="Button">
          <!-- Control template from above example -->
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

有了这次更新,您现在可以通过设置Style属性来创建按钮控件,如下所示:

<Button x:Name="myButton" Background="Red" Content="Howdy!"
        Click="myButton_Click" Style="{StaticResource RoundButtonStyle}"/>

虽然按钮的呈现和行为是相同的,但是在样式中嵌套模板的好处是可以为公共属性提供一组固定的值。这就概括了如何使用 Visual Studio 和触发器框架为控件构建自定义模板。虽然关于 Windows Presentation Foundation API 还有很多内容没有在这里讨论,但是您应该已经为进一步的学习打下了坚实的基础。

摘要

本章第一部分考察了 WPF 的资源管理体系。您从如何使用二进制资源开始,然后研究了对象资源的角色。正如您所了解的,对象资源被命名为 XAML 的 blobs,可以存储在不同的位置以重用内容。

接下来,你学习了 WPF 的动画框架。在这里,你有机会用 C# 代码和 XAML 制作一些动画。您了解到,如果您在标记中定义动画,您将使用<Storyboard>元素和触发器来控制执行。然后您看到了 WPF 风格的机制,它大量使用图形、对象资源和动画。

您检查了逻辑树视觉树之间的关系。逻辑树基本上是您为描述 WPF 根元素而创作的标记的一一对应关系。在这个逻辑树的后面是一个更深的可视化树,它包含了详细的渲染指令。

然后检查了默认模板的作用。请记住,当您构建自定义模板时,您实际上是将控件的可视化树全部(或部分)取出,并用您自己的自定义实现替换它。*

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值