GraphicsView框架实战:图形项的使用
作者:Aderversa
声明:本文章和代码可以理解为完全开源的。
简单介绍
本篇文章在学习完一下三篇文章后而来:
- Qt之QGraphicsView进阶篇-CSDN博客
- Qt之QGraphicsView入门篇-CSDN博客
- Qt之QGraphicsView实战篇_qt qgraphicsview 实战 csdn-CSDN博客
一些设计参考了上面的文章,一些是自己后续做出的优化。
接下来我会详细介绍一下我参考上面文章后,自己写出的项目。
通过完成该项目,我学会了基本的图形项编写和使用方式。
需求分析
-
应用程序可以按照用户需求添加图形项。
-
用户添加图形项可以在屏幕上被移动。
-
用户可以通过在屏幕上进行操作,对图形项的具体形状进行微调,比如:对于矩形来说,用户通过屏幕的按压移动鼠标来调整矩形的长和宽。用户只能在图形项处于选中状态下,对图形项执行该操作。
-
图形项的选中和未选中状态需要有区分度,且选中状态下需要显示更详细的按键以便用户对选中的图形项进行操作。
-
应用程序代码需要有好的可扩展性,能支持多种图形项的扩展,比如:多边形,圆角矩形等等东西。
-
应用程序代码需要有好的可维护性,对于本项目来说,就是需要进行对图形项之间的解耦合。在参考的文章中,我认为有些地方的耦合是没有必要的。解决的方法也很简单:采用Qt的信号和槽机制。
信号和槽机制虽然一定程度上会损失一些GraphicsView框架的性能。
但是对于我们这些初学者而言,在很长一段时间内基本遇不到这种性能瓶颈。
-
用户可以删除处于选中状态的图形项。
以上就是本项目基础的需求分析,以我自己为用户,自己写出原型应用而得出的需求列表。而原型是我在学习开头那几篇文章的过程中搭建起来的。
读者如果本身有更多的需求,可以在学习完本篇文章之后,自行修改或添加一些自己的需求,然后实现它。本篇文章旨在引导大家学习GraphicsView这个框架的基本使用。
总体设计
根据需求和原型的代码,我搭建出了以下的UML图,方便后续理解代码的总体结构:
该UML图使用Visual Paradigm Community Edition编写完成。
顶层Class
你可以将MyPaintBoard
理解成最顶层的类,它掌管了一个QGraphicsScene
和一个QGraphicsView
,并提供了一些QPushButton
,让用户可以完成:
- 添加各种图形项,比如:
RectangleItem
,EllipseItem
,SquareItem
和CircleItem
(图形项被添加到QGraphicsScene
中了)。 - 删除图形项。
图形项的定义
如何实现各种各样的图形项,并且保证它们的可扩展性和可维护性?
图形项的实现其实不难,就是继承QGraphcisItem
然后重写出各种各样的功能。
OK,按照这个思路,你去写完RectangleItem
,SquareItem
你会发现它们有很多的地方都是相同的。如果在重写其他的CircleItem
,EllipseItem
你会发现重复的代码越来越多,这样是非常不好的,因为后续你每扩展一个新的图形项类,你就会重复之前你做过的操作。
所以,我们需要通过一些面向对象思想来解决这个问题。
这时候,我们思考一个问题:一个图形项可以由什么构成?
学过QGraphicItem
你就知道,图形项其实就是在一个边界矩阵(boundingRect()
的返回值)中使用QPainter
画出的图形。
我们知道一个矩形的坐标,接下来其实就是利用这些坐标在边界矩阵中进行绘图操作而已。
那么矩形的坐标如何而来?
- 一种想法是:一个中心点 + 一个边缘点,决定了一个矩形的坐标。你去看看
QRectF
的构造函数就可以知道,矩形的构造其实可以通过topLeft
和bottomRight
来完成。那么当我知道了矩形的中心,和矩形的一个顶点的坐标,理论上来说就可以通过数学的方式求出整个矩形的坐标。 - 可能是存在其他方法的:比如,就多边形而言,其矩形坐标的计算可能并不能由中心点 + 边缘点来完成,因此需要其他管理方法,于是就需要衍生其他的坐标组织形式。
这就是为什么从AbstractShapeItem
中泛化出CenterAndEdgePointItem
和OtherSpecialItem
(该类实际并不存在,只是为了展示可以有额外的扩展图形项类,你可以将它定义为PolygonItem
、RoundedRectangleItem
等等)。
AbstractShapeItem
对选中和未选中状态的切换进行了统一的管理,并通过继承QObject
为其子类提供了信号和槽机制。
CenterAndEdgePointItem
CenterAndEdgePointItem
基于这样的思想而搭建:一个图形项的边界矩阵可以由一个中心点
和一个边缘点
组成,且边界矩阵中绘制的图形可以利用二者通过数学计算得来。这两个点都是AtomPointItem
,它可以告知父Item自身位置的变化、焦点状态的变化等等,将这些变化以信号的方式发出,让父Item有能力对此做出反应。
AtomPointItem
的Atom
前缀是为了说明:这个Item没有子项!
CenterAndEdgePointItem
需要实现对中心点
和边缘点
的信号的处理。在这些处理当中,尤其重要的是AtomPointItem
的位置发生变换时,坐标系的变换问题。
CenterAndEdgePointItem
规定,中心点永远处于父Item坐标系的原点(0,0),如果中心点发生移动,那么实际上中心点不需要移动,而是要让父Item的位置需要在爷坐标系中发出相应移动,中心点就会跟着父Item移动。
注意:中心点检测到移动时不需要真的进行移动,因为中心点的移动并不会导致父Item的原点的位置变化。
如果我们移动中心点后,在爷坐标系中移动了中心点父Item(现在指的是
CenterAndEdgePointItem
),那么父Item的坐标系变动其实还会推着中心点移动。简单理解就是,中心点自己走了10步,然后你告诉中心点的父Item要移动10步,于是这个父Item自己移动了10步,并且还推着子Item走了10步,最终中心点走了20步,偏离了中心点10步。这是你中心点走的越多,偏离原点越远。
而对于边缘点,它可以处于父ItemCenterAndEdgePointItem
坐标系中的任何位置,我们利用它计算边界矩阵。直接看源码可以能比较快理解:
QRectF RectangleItem::boundingRect() const
{
QPointF center = m_center->m_point;
QPointF edge = m_edge->m_point;
qreal width = qAbs(center.x() - edge.x());
qreal height = qAbs(center.y() - edge.y());
// 这个QRectF的坐标是相对于RectangleItem坐标系的坐标
return QRectF(QPointF(-width, -height), QPointF(width, height));
}
OtherSpecialItem
一些不能用CenterAndEdgePointItem
进行编写的图形项,比如多边形这种东西,只监控中心点和边缘点可能根本不能满足操作的需求。
这时候,我们呢就需要继承AbstractShapeItem
,然后自己按需添加AtomPointItem
然后编写新的信号处理逻辑和坐标处理逻辑,以此来编写特殊的图形项。
特殊的图形项由于各自特殊,所以重复度不是很高,可以这样搞。
如果在这里面出现了三个点的抽象,比如:中心点和边缘点负责构造边界矩阵,还有一个点负责调整绘制图形的形状。那么就可以从
OtherSpecialItem
中衍生一个三个点的类来管理。或者继承现有的
Item
类,添加一个AtomPointItem
,然后利用这个点和原来的中心点和边缘点,重写绘制的逻辑。
详细设计
AtomPointItem
AtomPointItem
的详细UML图如下:
signals:
void pointMoved(QPointF difference);
void focusIn();
void focusOut();
以上三个函数都是作为信号存在的。
- 在
AtomPointItem
检测到边界矩阵中发生了鼠标点击事件(即mousePressEvent()
)发生之后,会发出focusIn()
信号。通过连接这个信号我们的父Item可以得知AtomPointItem
什么时候开始被用户操纵,从而做出相应的反应。 - 如果
AtomPointItem
检测到在其边界矩阵中鼠标发生了移动,AtomPointItem
不会将这个移动立马应用到AtomPointItem
本身,而是通过发出pointMoved(difference)
信号,将鼠标移动的(dx, dy)
发送出去,让连接这个信号的父Item进行处理(具体怎么处理根据不同图形项的功能需求自行定义)。 - 如果
AtomPointItem
检测到在其边界矩阵中鼠标释放了,说明用户对于AtomPointItem
的操作结束了,父Item需要给出相应的反应。
而boundingRect()
和paint()
作为一个图形项是必须实现的。
AbstractShapeItem
AbstractShapeItem
的UML类图设计如下:
focusInEvent()
:处理焦点进入事件,该方法用于确定图形项处于选中状态,需要切换绘画图形项的QPen(选中和未选中的Pen颜色不一样),用于区分图形项是否被选中。focusOutEvent()
:处理焦点丢失事件,该方式明确图形项处于未选中状态,切换绘图所用的pen。drawBoundingRect()
:提供了一种绘制虚线边界矩形框的方法。由于有些不规则图形项有需要绘制出边界矩形框让用户知道自己操作图形项的范围,所以提供统一绘画矩形框的方法。子类可以通过设置rectPen
来设置该方法绘制的矩形框的样式。
CenterAndEdgePointItem
CenterAndEdgePointItem
的UML类图定义如下:
centerMove()
,centerFocusIn()
,centerFocusOut()
分别对应m_center
发出的pointMoved()
,focusIn()
,focusOut()
信号的处理槽函数。edgeMove()
,edgeFocusIn()
,edgeFocusOut()
分别对应m_edge
发出的pointMoved()
,focusIn()
,focusOut()
信号的处理槽函数。focusChanged()
是检测CenterAndEdgePointItem
所在的Scene
发出的焦点改变信号,需要查看这个信号以确定自己和自己所管理的AtomPointItem
们是否处于焦点状态,以确定处理逻辑。m_center
和m_edge
是默认隐藏的两个Item,需要通过CenterAndEdgePointItem
获得焦点状态后才能展示出来。展示出来的逻辑构成由上面的槽函数来完成。focusInEvent()
负责获得焦点状态后将m_center
和m_edge
显示出来。
centerMove
virtual void centerMove(QPointF difference);
将m_center
检测到的鼠标移动difference = (dx, dy)
,转换成父Item在爷坐标系上的移动。
edgeMove
virtual void edgeMove(QPointF difference);
将m_edge
检测到的鼠标移动difference = (dx, dy)
,反应成m_edge
在其父Item坐标系上的移动。
focusEvent
void CenterAndEdgePointItem::focusInEvent(QFocusEvent* event);
获得焦点后,将m_center
和m_edge
从默认的隐藏状态中显示出来,让用户可以对图形项进行操作。在某些图形项中,获得焦点状态的图形项会渲染虚线边界矩形框。
centerFocusIn
void centerFocusIn();
m_center
获得焦点状态发出focusIn()
信号,然后触发该槽函数。
它将使CenterAndEdgePointItem
中其他AtomPointItem
隐藏起来。
centerFocusOut
void centerFocusOut();
m_center
失去焦点状态后发出focusOut()
信号,然后触发该槽函数。
它将使CenterAndEdgePointItem
中由于centerFocusIn()
而隐藏的AtomPointItem
重现,并将CenterAndEdgePointItem
重新置于焦点状态。
edgeFocusIn
void edgeFocusIn();
其作用和centerFocusIn()
类似。
edgeFocusOut
void edgeFocusOut();
其作用和centerFocusOut()
类似。
focusChanged
void focusChanged(QGraphicsItem* newFocusItem, QGraphicsItem* oldFocusItem, Qt::FocusReason reason);
我们需要在Focus不再处于CenterAndEdgePointItem
或者m_center
或者m_edge
之后,将m_center
和m_edge
隐藏起来,回归图形项的初始样貌。
你可以想使用focusOutEvent()
来检测焦点是否这项任务,但是这存在以下几个问题:
- 首先你要知道:由于焦点状态同一时间只能由一个Item获取,子Item获取焦点状态,并不会导致父Item获得焦点状态,因为它们是两个Item即使它们存在父子关系。
- 你可能会想到:在
focusOutEvent()
中使用AtomPointItem
从QGraphicsItem
中继承而来的hasFocus()
方法来判断某个子项是否拥有焦点状态,来确定是不是m_center
和m_edge
是否具有焦点。但这样是行不通的,因为在下一个Item获得焦点之前,GraphicsView框架似乎会先执行上一个焦点Item的focusOutEvent()
,所以即使你知道你的子Item下一个瞬间就能获取焦点,但是程序不知道。这是我Debug多次的结果。 - 最终的结果就是:你可能通过hasFocus来判断子项是否处于焦点状态,但是由于上面解释的原因,子项不可能在本
focusOutEvent()
执行完之前获得焦点状态,所以导致m_center
或者m_edge
在获得焦点之前就被hide起来了。
最终我的解决方案就是:处理QGraphicsScene::focusChanged()
这个信号。
我们可以通过检测oldFocusItem
看看是不是CenterAndEdgePointItem
本身,newFocusItem
是不是m_center
或m_edge
来决定要进行何种操作。当焦点不再CenterAndEdgePointItem
或者其子Item时,将m_center
和m_edge
进行隐藏。
这样操作有一个新的问题:何时将QGraphicsScene::focusChanged()
绑定到CenterAndEdgePointItem::focusChanged()
上呢?
通过this->scene()
我们可以获取QGraphcisScene
,但是因为当Item创建时,它不一定被添加到QGraphicsScene
当中。这样的话this->scene()
就是空指针,没有任何信号能够触发我们的槽。
我的解决方案是:第一次触发CenterAndEdgePointItem::focusInEvent()
的时候,对上述的信号和槽进行连接。
由于我们都已经出发的了focus事件,那么Item肯定存在于一个Scene中,也就肯定可以连接成功。
由于focus事件可能多次触发,可能会导致多次连接,所以需要使用一个isConnected
的布尔值来标志是否连接成功。
这里由于害怕Qt的信号和槽等消息机制可能是多线程状态下进行工作的,isConnected
的读写可能会有同步问题,这里使用了一个QMutex
来保证其状态。
RectangleItem
RectangleItem
的UML类图如下:
RectangleItem
的大多数方法和变量通过继承的方式获得,这里我们只要关注矩形的绘制,关于m_center
和m_edge
的状态管理由CenterAndPointItem
来进行。
具体坐标计算如下:
QRectF RectangleItem::boundingRect() const
{
QPointF center = m_center->m_point;
QPointF edge = m_edge->m_point;
qreal width = qAbs(center.x() - edge.x());
qreal height = qAbs(center.y() - edge.y());
return QRectF(QPointF(-width, -height), QPointF(width, height));
}
绘制方法非常简单,就是利用QPainter
将这个边界矩形绘制出来即可,使用到的QPen和QBrush都来自其祖先类:
void RectangleItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
Q_UNUSED(option);
Q_UNUSED(widget);
painter->setPen(this->pen());
painter->setBrush(this->brush());
painter->drawRect(this->boundingRect());
}
EllipseItem
EllipseItem
的UML类图如下:
其boundingRect()
与RectangleItem
的类似,而paint()
无非是将drawRect()
变为了drawEllipsse()
。
并且添加了在焦点状态下绘制矩形框的逻辑。
SquareItem
SquareItem
的UML类图如下:
该类重写了来自CenterAndEdgePointItem
的edgeMove()
方法,因为在SquareItem
,m_edge
不再可以在父Item的坐标系中随意移动了。
想要画出不“斜”的正方形,你就必须要保证m_edge
的坐标在y = x
这条线上。
每当m_edge
不在这条线上,我们就必须要把它重新移回y = x
上。
这里我采用的方法是:
以
m_edge
移动后的点(a, b)
和斜率-1
做一条直线,该直线为:y = -x + a + b
。则其与
y = x
的交点:((a + b) / 2, (a+b) / 2)
即为m_edge
的新坐标。
这样可以保证,m_edge
的移动大致反馈到y = x
上。
如果你数学很好,可以找出更为精确的算法,那么可以替换掉我的算法,使用你自己的算法。大学已经上两年的我基本上把高中的数学,甚至是高等数学忘得差不多了。
限制完移动之后,绘图就想对简单很多了:
QRectF SquareItem::boundingRect() const
{
QPointF edge = m_edge->m_point;
QPointF bottomRight(qAbs(edge.x()), qAbs(edge.y()));
QPointF topLeft(-qAbs(edge.x()), -qAbs(edge.y()));
return QRectF(topLeft, bottomRight);
}
void SquareItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
Q_UNUSED(option);
Q_UNUSED(widget);
painter->setPen(this->pen());
painter->setBrush(this->brush());
painter->drawRect(this->boundingRect());
}
CircleItem
CircleItem
的UML类图如下:
其实它与SquareItem
类似,只不过将paint()
中的drawRect()
变为了drawEllipse()
。同时添加了在焦点状态下绘制矩形框的逻辑。
MyPaintBoard
该类需要搭建的控件布局如上所示,我们为具体按键添加上相应的功能即可。
MyPaintBoard
的UML类图如下:
- 将
addCircleBtn
的clicked()
与addCircle()
连接,添加CircleItem
到scene
中。 - 将
addSquareBtn
的clicked()
与addSquare()
连接,添加SquareItem
到scene
中。 - 将
addRectangleBtn
的clicked()
与addRectangle()
连接,添加RectangleItem
到scene
中。 - 将
addEllipseBtn
的clicked()
与addEllipse()
连接,添加EllipseItem
到scene
中。
剩下的就是进行布局管理的搭建,我这里默认读者已经将这些东西掌握了。这些知识网上一抓一大把,可以自行去了解。
编码实现
源码已经发布到GitHub上,有兴趣的可以自行阅读:
Aderversa/MyPaintBoard (github.com)
Qt的版本是:6.6.3
编译器使用的是:MinGW-64bit
最终实现的效果如下:
可以自行使用QtCreator去编译代码来查看动态效果。
待解决的问题
由于本人暂时能力有限,其中的一些问题暂时得不到解决:
- 当我将边缘点
m_edge
拖出视图view之外时,不放手再次拉回来该m_edge
会消失,但是却不是hide,因为只要我还未停止拖动操作,我就能将这个Item进行移动。但是当我停止拖动之后,该m_edge
就无法再次被选中,就好像被hide了一样。然后,我选中该CenterAndEdgePointItem并进行移动之后,随意转动两下,某个时刻m_edge
又回来的了,这个回来的时刻好像是随机的,找不到什么规律。这个问题由于本人对于GraphicsView框架了解不充分,导致无法找到问题产生的原因,等待后续对GraphicsView进一步了解之后找出解决方案。 - 类的层次结构依旧存在问题,对于每个图形项,其AtomPointItem的坐标变换逻辑不一定兼容,但有些是一样的,比如:Rectangle和Ellipse的坐标移动其实可以是一样的,但Rectangle和Square的就不兼容,因为Square的
m_edge
的移动需要限制在y=x
上。所以我在想:是否可以将对于坐标的处理逻辑单独拎出来,让AbstractShapeItem类通过组合点及其坐标变换逻辑的类,来编写新Item类。比如:AbstractShapeItem通过组合Rectangle的坐标移动逻辑,就可以变成Rectangle;而组合Square的坐标移动逻辑就可以变成,Square。那这样开发的重点就变成了坐标移动逻辑的开发,以及利用坐标进行绘图的开发。将坐标移动从原有的类中分离出来,让Item可以专注于绘制逻辑的开发,好处就是坐标变换逻辑可以得到重用。如果后续有一样的坐标变换逻辑的项,比如大家都采用Rectangle的坐标变换逻辑,我却不在边界矩形中绘制矩形,而是绘制一个三角形、绘制一个“叉”等等。