在现代的图形用户界面设计中,经常需要使用现成的库来绘制各种对象,并允许用户通过简单的拖拽操作来移动这些对象。然而,这样的功能实现细节并不总是容易找到。因此,决定自己探索实现这些功能的方法,并分享发现和思考。
写这篇文章有两个原因:一是作为学习工具,帮助那些处于同样位置的人;二是可以合作探讨不同的实现方式。如果对此感兴趣,请继续阅读下一篇文章。
想要能够绘制形状,并在屏幕上管理这些形状,类似于Windows资源管理器UI中所做的那样。
目标是创建一个应用程序,能够实现以下功能:
简单但足够有趣。
设计目标包括:
在高层次上,应用程序由形状的宇宙(模型)、手势管理器(控制器)和UI(视图)组成,它提供了绘图区域并知道如何解释手势。这更像是MVC模式。还依赖于接口,如IShape等,至少在希望能够扩展的区域。
视图主要在Form1.cs和Canvas.cs中实现绘图。Form1告诉画布绘制自己并解释手势,然后将这些手势传递给不同的管理器(控制器)来管理对象。将Canvas作为一个单独的类,使能够轻松创建相同宇宙的不同视图。例如,一个视图显示常规视图,另一个视图是放大和旋转的。
发现最有趣的部分是实现多选功能。希望它非常直观和标准,因此将其建模为类似于Windows资源管理器(至少在很大程度上)。所有手势都由手势管理器解释,然后告诉动作管理器如何管理形状。再次,宇宙和形状管理器是解耦的。
每个动作都有自己的管理器,因为觉得这简化了整体逻辑流程。也许在某个时候它会变得太多,但至少知道每个类都会很简单,并且有一个单一的目的。
这里的基本对象是IShape。每当想要引入新的形状时,只需要实现IShape,可能还需要实现ISelectable。宇宙由0、1或更多形状(一个列表)组成。人们可能会质疑为什么ISelectable和IShape是分开的,也许太急于解耦了,在更大的系统中可能更需要。在某个时候,这样做是因为想要有一个用于多选边界框的绘图形状,它本身是不可选的(然而,也通过从不将其作为宇宙的一部分来避免它被选中)。无论如何,即使在这一点上,它也可以作为IShape接口上的IsSelectable实现。所以,这可能是一个设计决策,最终可能只是大型系统中的遗留代码。
希望上面的概述讲述了大部分故事。以下是认为重要的一些细节。在模型的核心,有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传递一个空区域,并期望这段代码用所需的形状填充它。然后选择框在逻辑上被Anded在上面。如果有任何重叠,那么结果的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的显示器(例如,如何使形状在从一个显示器移动到另一个显示器时改变像素大小?),它就不会奏效。