在 C# 中控制绘制的形状
如何通过鼠标手势在屏幕上绘制和管理形状
引言
在很长一段时间里,我主要编写使用现成库的业务逻辑和简单的 UI(Web 和桌面)。前段时间,我决定弄清楚如何在屏幕上绘制对象,以及如何创建一个用户界面,让用户可以通过简单的拖动来移动这些对象,类似于拖动窗口。由于没有找到相关的文章,我决定自己摸索,然后分享我的发现和想法。
我写这篇文章主要是为了自己,但最终还是决定写出来,有两个原因:
- 它是为和我处境相同的人提供的学习工具。
- 我们可以就如何以不同的方式完成这些事情进行协作。
如果您觉得这篇文章有趣,请继续阅读我的下一篇文章。
- 文章 #2:为形状添加了物理引擎!
背景
我想能够绘制形状并将它们管理在屏幕上,这几乎就像在 Windows Explorer UI 中所做的那样。
功能目标
我的目标是创建一个能够实现以下功能的应用程序:
- 绘制任何对象。
- 用户可以选择或多选它们。
- 能够访问选择框。
- 用户可以拖动项目。
- 支持缩放功能。
- 能够拥有同一内容的多个视口。
- 使用真实世界的单位来绘制这些项目。
简单但不同;足以让我玩得开心。
设计目标
- 易于扩展
- 是否可以轻松地移植到不同的平台,例如 WPF 或 Java?
- 可维护
代码概述(设计)
总的来说,该应用程序由形状的宇宙(模型)、手势管理器(控制器)以及提供绘制区域并知道如何解释手势的用户界面(视图)组成。我猜这更像是 MVC 模式。我还依赖于 `IShape` 等接口,至少在我觉得需要扩展的领域。
视图和控制器逻辑
视图主要在 `Form1.cs`(一个很棒的名字)和用于绘制的 `Canvas.cs` 中。`Form1` 告诉画布自行绘制并解释手势,然后将手势传递给不同的管理器(控制器)来管理对象。将 `Canvas` 提取为一个单独的类,使我能够轻松地创建同一宇宙的不同视口。例如,在我的例子中,一个视口显示常规视图,另一个视口经过缩放和旋转。
解释和管理手势
我发现最有趣的部分是正确实现多选功能。我想让它非常直观和标准,因此在很大程度上模仿了 Windows Explorer。
所有手势都由手势管理器解释,然后手势管理器告诉动作管理器如何管理形状。同样,宇宙和形状管理器是解耦的。
每个动作都有自己的管理器,因为我觉得这简化了整体逻辑流程。我想在某些时候可能会变得过多,但至少你知道每个类都会很简单,并且只有一个目的。
形状模型
这里的基本对象是 `IShape`。当想要引入一个新形状时,只需要实现 `IShape`,可能还有 `ISelectable`。宇宙由 0、1 或多个形状(一个列表)组成。有人可能会问为什么 `ISelectable` 和 `IShape` 是分开的,也许我有点急于解耦,在更大的系统中可能更需要。当时驱动我这样做的一个原因是,我想要一个用于多选边界框的绘制形状,而该形状本身不可选择(但是,我通过从不将其包含在宇宙中来避免其被选择)。无论如何,即使在这一点上,它也可以作为 `IShape` 接口上的 `IsSelectable` 来实现。所以,一个设计决策,在大型系统中可能最终只会变成遗留代码。
Using the Code
希望上面的概述已经说明了大部分内容。以下是一些我认为重要的细节。
在模型的核心,你有 `IShape`、`IDrawable` 和 `ISelectable`。
public interface IShape : IDrawable
{
bool Contains(PointF p);
void Move(PointF delta);
void FillRegion(Region region);
}
`IDrawable` 和 `ISelectable` 是非常简单的接口,只有一个方法,在这里它们主要用作标签接口。如上所述,是否需要这样做是值得商榷的,最终这只是一个设计决策。例如,这可以使我们非常轻松地拥有永远无法触碰或移动的背景图像。无论如何,每当你有一个新形状时,只需实现这些方法和接口,它就可以成为形状宇宙的一部分。
`paint` 方法只是将形状绘制到传入的 `Graphics` 参数中。你可以假设任何对象的 `paint` 都是从下往上进行的,也就是说,只需绘制你的形状,如果有什么东西在后面,它会被这个形状实例隐藏。对于选定的项目,我只是为它们添加轮廓。出于性能原因,图形路径仅在模型更改时计算。在这种情况下,唯一可以更改内容的方法是 `Move`,但如果需要,也可以进行 `Resize`。
public void Paint(Graphics g)
{
g.FillPath(Brushes.Yellow, gp);
if (_isSelected)
g.DrawPath(Pens.Black, gp);
}
public void Move(PointF d)
{
_border.X += d.X;
_border.Y += d.Y;
gp.Reset();
gp.AddEllipse(_border);
}
`Contains` 和 `FillRegion` 方法是帮助框架判断鼠标或选择框是否在其上(如果它们是可选择的)。同样,我只是重用了上面计算出的图形路径来提供帮助。`Contains` 方法使用 `GraphicsPath` 中的 `IsVisible` 方法相当直接。`FillRegion` 用于查看边界框是否正在选择我们的形状。工作方式如下:框架将一个空区域传递给 `FillRegion`,并期望此代码用所需的形状填充它。然后,选择框在逻辑上与它进行 `AND` 操作。如果存在任何重叠,则结果 `Region` 将不为空,从而表明该形状应被选中。
public void FillRegion(Region r)
{
r.Union(gp);
}
在 `Canvas` 中,`Drawing2D` 中的 `Matrix` 派上了用场。这使我们能够按需更改视口的方向和缩放。唯一需要注意的地方是使用 `MatrixOrder.Append` 参数,否则可能无法按预期工作。此变换然后可用于 `paint`
dc.Transform = GetWorldToViewTransform();
以及解释鼠标选择时。
gestureControl.HandleMouseDown(
((Canvas)sender).TransformToWorldCoordinates(e.Location),
IsControlPressed());
关注点
用户选择和手势的处理主要在鼠标抬起事件中,而不是像我最初认为的那样在鼠标按下事件中。正如你所见,我现在只返回 `false`。
每个 `canvas` 都有自己的坐标系,并且可以在自身与父坐标系(或在本例中为世界坐标)之间进行转换。这使我们能够做一些有趣的事情,例如拥有对宇宙的两个完全不同的视图。在我的示例中,我展示了宇宙的更大一部分(缩小 50%)以及旋转的(为了好玩)。
我有一个形状组(碰巧它也是一个形状)。顺便说一句,宇宙就是这样实现的。然而,这里的想法应该是很容易实现形状组(如 PowerPoint 中的那样)。我们所需要做的就是将涉及的形状从宇宙中移除,将它们添加到形状组中,然后将它们重新添加到宇宙中。
命中测试是我不知道如何处理的事情,特别是以一种易于重用的方式。我最终的做法是用形状填充一个区域进行命中测试,然后与当前光标进行逻辑 `AND` 操作。如果出现任何情况,则意味着发生了命中。这很好,因为它很容易处理许多种类的形状,包括空心的形状,如甜甜圈。
最后,我尝试进行了一些从真实生活坐标和尺寸到屏幕的转换。我在这方面彻底失败了。你可以看到我尝试这样做的代码,但这不起作用,而且最终如果你有多个具有不同分辨率/DPI 的显示器(例如,当它从一个显示器移动到另一个显示器时,你如何使形状的像素大小发生变化?),它将不起作用。
尽情享用!
历史
- 2011年3月15日:首次发布