written by SJTU-XHW

Reference: C++ GUI Programming with Qt 4 (2nd Edition)

注意:本文章将讲解 Qt 5 入门知识,需要一定的 C++ 基础

本人知识浅薄,文章难免有问题或缺漏,欢迎批评指正!

观前提示:本系列的所有完整代码在整理好后,都会存放在仓库中,有需要可以自取 ~


前面 4 章节的内容都是从具体项目来学习 Qt 的方法,也已经让我们初步认识了 Qt 的使用方法,下面的内容将按类分块来介绍,文章的篇幅也会短一些。

Chapter 5. Qt 常用事件

Qt 中常用的 Event 主要有:鼠标事件键盘事件内部事件。其中内部事件绝大多数都交给 信号-槽 来处理,少部分的内部事件在特定情况需要重写,例如 QPaintEvent(下一章说)、 QCloseEvent 关闭事件(实现关闭前确认)。前两个事件是输入事件,都继承于 QInputEvent

5.1 内部事件

前面介绍过,对于内部事件(特指关闭事件),QCloseEvent 的使用方法也很简单,重写 void QWidget::closeEvent(QCloseEvent* event) 即可,如果调用了 event->accept(),表示控件接受关闭事件,正式关闭;而如果调用了 event->ignore(),则控件忽略关闭事件。

5.2 鼠标事件

注意:鼠标滚轮另外归于 QWheelEvent 类。在最后会一笔带过。

5.2.1 简单介绍

Qt 的鼠标事件交由 QMouseEvent 处理,分为鼠标移动、左键 / 右键的单击、双击、释放。具体为何能够实现,归因于 QWidget 的信号-槽,它们会在鼠标在该控件区域发生特点行为时发出信号,进而传递 QMouseEvent 对象。

有一个重要的点需要阐释:鼠标移动事件只会在按下鼠标按键的情况下才会发生!除非通过显式调用 QWidget::setMouseTracking()函数来开启鼠标轨迹,这种情况下只要鼠标指针在移动,就会产生一系列的鼠标移动事件。

5.2.2 QMouseEvent 的传递方式

一个鼠标事件包含一些指定的接受标志 flag 用于指出该事件是否会被接收和处理 ,如果鼠标指针所在的父控件不接收该事件则可以调用函数 QMouseEvent::ignore() 予以忽略;

多个重叠的控件好比一个倒立的树,鼠标事件会沿着鼠标指针所在的父控件向上传递,直到某个控件调用 QMouseEvent::accept() 函数进行事件处理,否则该事件将被过滤销毁掉;

此外,QWidget 中的属性 Qt::WA_NoMousePropagation 能够改变这种传递行为。如果设置为 true,那么鼠标信号不会再向父控件传递。

5.2.3 QMouseEvent 的使用

需要引入 QMouseEvent 类的头文件。通常通过重写下面的函数来实现:

1
2
3
4
virtual void QWidget::mousePressEvent(QMouseEvent* event);
virtual void QWidget::mouseReleaseEvent(QMouseEvent* event);
virtual void QWidget::mouseDoubleClickEvent(QMouseEvent* event);
virtual void QWidget::mouseMoveEvent(QMouseEvent* event);

具体 QMouseEvent 有如下获取位置实例方法(成员函数):

1
2
3
4
5
6
7
QPoint QMouseEvent::pos() const;
int QMouseEvent::x() const;
int QMouseEvent::y() const;

QPoint QMouseEvent::globalPos() const;
int QMouseEvent::globalX() const;
int QMouseEvent::globalY() const;

注:QPoint 类的方法也有 QPoint::x()QPoint::y(),代表点对象在当前画布的位置。

如果鼠标事件还涉及处理移动窗口的坐标计算(pos 在窗口外),那么为了防止抖动可以使用 QMouseEvent::globalPos() 获取屏幕全局坐标。

还需要注意的问题,就是鼠标指针位置类 QCursor 的方法 pos() 和 这里鼠标事件 globalPos() 不一样,需要使用 QWidget::mapToGlobal(pos()) 在窗口坐标和全局坐标间转换;


鼠标事件除了记录位置,还记录了鼠标击键的方法,可以调用下面这些函数判断:

注意,Qt::MouseButton 是枚举类型,其取值都以十六进制存储,可以进行按位或的方法来多选。常见的值有:Qt::NoButtonQt::AllButtonsQt::LeftButtonQt::RightButtonQt::MiddleButton

1
2
3
4
5
// 如果是鼠标移动事件,那么此函数始终返回 Qt::NoButton
Qt::MouseButton QMouseEvent::button() const;

// 和上面一个可以相互替代,不过这个函数更准,返回同时将触发的按键值**按位或**起来
Qt::MouseButton QMouseEvent::buttons() const;

正因为 buttons() 的结果是按位或起来,所以判断自然不是 ==,而是按位与判断有没有特定的按键。

注:在 QWidget::mouseDoubleClick() 中,因为双击是同一按键,所以也能用 QMouseEvent::button() 来判断按键的。

5.2.4 鼠标滚轮

简单介绍 QWheelEvent 类,用的比较多的是:

1
int QWheelEvent::delta() const;

如果返回值大于 0,说明滚轮向下(远离使用者);反之向上(接近使用者)。

5.3 键盘事件

键盘事件交由 QKeyEvent 来处理。传递方式和鼠标事件几乎相同。在使用方面,键盘事件比鼠标简单,不存在移动、右击、双击的事件,就处理按下和释放两个事件,可以重写的函数有两个:

1
2
virtual void QWidget::keyPressEvent(QKeyEvent* event);
virtual void QWidget::keyReleaseEvent(QKeyEvent* event);

具体 QKeyEvent 有如下的实例方法:

1
2
3
4
Qt::Key QKeyEvent::key() const;
Qt::KeyboardModifiers QKeyEvent::modifiers() const;
bool QKeyEvent::isAutoRepeat() const;
int QKeyEvent::count() const;

这里解释一下 isAutoRepeat()。众所周知,长按键盘的某个键,就相当于快速重复触发这个键(平时在编辑文本中应该感受到了)。而在 Qt 的键盘事件中,在控件中按一次键就各触发一次 QKeyEvent 按下和释放事件,长按也会快速触发 QKeyEvent,但有种方法能够辨别长按的行为——isAutoRepeat(),只有长按键盘的事件第一次会返回 false,快速触发的接下来所有事件都返回 true,直至物理上松开按键。利用这个函数,可以设计出 “不处理长按事件” 的逻辑(需要在按下和释放的处理函数中都写一下)。

其次,Qt::Key 也是枚举类型,其取值都以十六进制存储,值的格式为 Qt::Key_<keyName>

Qt::Key_Escape(Esc)、Qt::Key_TabQt::Key_BackspaceQt::Key_EnterQt::Key_InsertQt::Key_DeleteQt::Key_Pause(对于键盘的 Pause/Break,和多媒体的 “暂停” 丝毫没有关系)、Qt::Key_PrintQt::Key_HomeQt::Key_EndQt::Key_[Left/Right/Up/Down]Qt::Key_PageUpQt::Key_PageDownQt::Key_ShiftQt::Key_Control(MacOS 中的 command 键)、Qt::Key_AltQt::Key_Meta(MacOS 中的 Ctrl,Windows 中的徽标键)、Qt::Key_CapsLockQt::Key_NumLockQt::Key_ScrollLockQt::Key_[F1~35]Qt::Key_SpaceQt::Key_[0-9]Qt::Key_[A-Z]……

多个键同时按下时,还可以使用 count() 返回本次 QKeyEvent 事件按键数量。

如果多个键按下时,伴随的是修饰键(例如 shiftctrlalt 等),可以用 modifiers() 查到。

修饰键有单独的枚举类型 Qt::KeyboradModifiers,值有:

Qt::NoModifierQt::ShiftModifierQt::ControlModifierQt::AltModifierQt::MetaModifier……

举个例子,想要查到 ctrl+M 事件

1
2
3
4
5
6
7
8
void TestWidget::keyPressEvent(QKeyEvent* event) {
if (event->modifiers() == Qt::ControlModifiers) {
if (event->key() == Qt::Key_M) {
// ...
}
}
else QWidget::keyPressEvent(event);
}

最后强调一个事,Qt 的键盘事件一次仅能捕捉一个按键 !!!例如看似我“同时”按下 A 和 S 这两个键,实际上会先触发 Qt::Key_A/Qt::Key_S 中的一个,再触发另一个

这对于想要判断非修饰键的组合键的同学来说可能有些麻烦——因为这需要设置一个容器,或者十六进制码进行存储,按下键存进去,松开键删除掉,然后统一判断这个存储的信息。

Chapter 6. Qt 图形绘制

Qt 所制作的几乎所有 GUI 上的按钮、编辑框等组件都是通过绘图得到的。Qt 的二维绘图基本功能是使用 QPainter 在绘图设备(包括 QWidgetQPixmap 等)上绘图,通过绘制一些基本的点、线、圆等基本形状组成需要的图形,得到的图形不可交互。

除了 QPainter,Qt 还提供 Graphics View 架构,使用 QGraphicsViewQGraphicsSceneQGraphicsItem 类来制作更多样复杂的、可交互的图形。

6.1 QPainter 基本绘图

6.1.1 绘图区

Qt 基本绘图系统基于 QPainter(绘图操作使用)、QPaintDevice(可以使用 QPainter 的抽象二维界面)、QPaintEngine(为 QPainter 提供各种设备上绘制的接口)类。

一般情况下不需要关注底层 QPaintEngine,除非想自定义一个可以绘制的设备类型。

常见的绘图设备有 QWidgetQPixmapQImage,为 QPainter 提供了 “canvas”。

下面先以 QWdiget 这个绘图设备为例。

想在 QWidget 这一绘图设备上绘图很简单,重写以下函数:

1
virtual void QWidget::paintEvent(QPaintEvent* event);

这也是上一章提到的 “内部事件” 之一。利用 QPaintEvent 事件,在函数中创建 QPaint 对象,就可以在控件画布上绘图了。

当然,重写也是有讲究的,需要使用专用的宏 Q_DECL_OVERRIDE 来声明这个函数:

1
void TestWidget::paintEvent(QPaintEvent* event) Q_DECL_OVERRIDE;

再介绍一下绘图坐标系中的视口(viewport)坐标QWidget 的左上角就是(0,0),向右是 x 轴正方向,向下是 y 轴正方向;绘图区宽度和高度分别由控件宽度、高度决定:QWidget::width()QWidget::height()

除了视口坐标,还有逻辑坐标和窗口坐标,以后介绍。

6.1.2 绘图属性

QPainter 具有 3 各重要属性:

  • QPainter::pen,是一个 QPen 对象,用于控制线条颜色、宽度、线型
  • QPainter::brush,是一个 QBrush 对象,用于设置一个区域的填充特性,可以设置填充颜色、填充方式、渐变特性,也可以指定图片做材质填充;
  • QPainter::font,是一个 QFont 对象,用于绘制文字时设置文字样式、大小

下面以一个实例来介绍。假设 TestWidget 类是一个继承于 QWidget、已设计的一个类,只需要在其上绘制图案就完成任务,那么函数这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void TestWidget::paintEvent(QPaintEvent* event) {
// 初始化一个 QPainter 对象,与 TesWidget 对象关联、
QPainter painter(this);

painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
int W = this->width(); // 记录绘图区宽度和高度
int H = this->height();
// QRect 类是个二维图形类,初始化一个长方形,后文会详细介绍
QRect rect(W/4, H/4, W/2, H/2);

// 初始化一个指定的画笔
QPen pen;
pen.setWidth(3); // 笔的线宽
pen.setColor(Qt::red); // 笔的颜色
pen.setStyle(Qt::SolidLine); // 笔画的线的样式(这里是实线)
pen.setCapStyle(Qt::FlatCap); // 笔画的线的端点样式
pen.setJoinStyle(Qt::BevelJoin); // 笔画的线的连接点样式
// 笔设置完后,给 QPainter 装上指定的笔:
painter.setPen(pen);

// 初始化一个画刷
QBrush brush;
brush.setColor(Qt::Yellow); // 画刷填充的颜色
brush.setStyle(Qt::SolidPattern); // 画刷填充的样式(这里是实心)
// 画刷设置完后,给 QPainter 装上指定画刷:
painter.setBrush(brush);

// 绘图
painter.drawRect(rect);
}

上面的程序先从 QPainter 说起,QPainter::setRenderHint(QPainter::Antialiasing) 表示绘制图形抗锯齿(默认关闭),QPainter::setRenderingHint(QPainter::Textantialiasing)

然后是 QPen,以上已经展示了大多数情况下会用到的方法:如何设置线宽、颜色、线的样式、端点样式和连接点样式

其中,线的样式有枚举量 Qt::PenStyle,常用的值有 Qt::SolidLine(实线)、Qt::DashLine(虚线)、Qt::DotLine(点状线)、Qt::DashDotLine(点划线)、还有自定义样式 Qt::CustomDashLine,要结合 setDashOffset()setDashPattern() 使用。

线端点的样式有枚举量 Qt::PenCapStyle,常用的值有 Qt::Square(方形端点)、Qt::FlatCap(平端点)、Qt::RoundCap(圆润端点)。

线条连接样式有枚举量 Qt::PenJoinStyle,常用的值有 Qt::BevelJoinQt::MiterJoinQt::RoundJoin

然后是 QBrush,主要常用的方法就 4 个:

1
2
3
4
void QBrush::setColor(const QColor& color);
void QBrush::setStyle(Qt::BrushStyle style);
void QBrush::setTexture(const QPixmap& pixmap);
void QBrush::setTextureImage(const QImage& image);

QColor 和之前见到过的一样,可以用枚举类型,也可以手动创建对象。

先介绍 Qt::BrushStyle 枚举类型,它定义的是填充样式,常用的值有:Qt::NoBrush(不填充)、Qt::SolidBrush(单一颜色填充)、Qt::HorPattern(水平线填充)、Qt::VerPattern(垂直线填充)、Qt::TexturePattern(材质填充,需要指定 texture 或 texture image)、Qt::LinearGradientPattern(线性渐变,需要使用 QLinearGradient 类作为 brush)、Qt::RadialGradientPattern(辐射渐变,需要使用 QRadialGradient 类作为 brush)、Qt::ConicalGradientPattern(圆锥渐变,需要使用 QConicalGradient 类作为 brush);

渐变的使用暂时不介绍了,用到再上网查。

补充:QPixmap 和 QImage

下面分析一下 QPixmapQImage本身的使用方法。

它们的构造函数比较简单,可以是含有图片的 uri 字符串,也可以是 QUrl,构造后即可将图片像素信息读入对象中。

两者有一些差别,QPixmap 主要是用于绘图,针对屏幕显示而最佳化设计(适合小图片的呈现),QImage 主要是为图像I/O、图片访问和像素修改而设计的类(适合大图片的编辑)。

这些差别可以从常用的方法上看出:

1
2
3
4
5
6
7
// QImage 类允许从空构造, QImage::QImageFormat 枚举类型含有多种图片格式
QImage::QImage(int x, int y, QImage::QImageFormat f);
QImage::QImage(const QString& picture);
// 方法允许直接修改像素点
// 注:QRgb 构造函数参数就是 R、G、B 值
void QImage::setPixel(int x, int y, const QRgb& rgb);
// 其他非常多方法,等到用到再介绍

二者相互转换:

1
2
QPixmap QPixmap::fromImage(const QImage& image);
QImage QPixmap::toImage();

补充:基本图形元件

最后,介绍上面的示例中的最后一行 painter.drawRect(rect);,这说明 QPainter 底层能够直接绘制出基本的几何图形,这称为 QPainter 绘制的基本图形元件。下面介绍一些 QPainter 类中常用的图形元件:

  • 绘制一个点 和 一组点:QPainter::drawPoint(const QPoint& p);QPainter::drawPoints(const QPoint[] points, int size); 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    painter.drawPoint(QPoint(W/2, H/2));
    // 一组点 --------------------------
    QPoint points[] = {
    QPoint(5*W/12, H/4),
    QPoint(3*W/4, 5*H/12),
    QPoint(2*W/4, 5*H/12)
    };
    painter.drawPoints(points, 3);

    这里的 QPoint 类之前在鼠标事件中就遇到过,构造函数就是坐标系中的 x 和 y;

  • 绘制弧线:QPainter::drawArc(const QRect& rect, int startAngle, int spanAngle);(这里的 angle 是角度制);示例:

    1
    2
    3
    4
    QRect rect(W/4, H/4, W/2, H/2);
    int start = 90 * 16; // 起始为坐标系中 90° 角,其中 0° 指向 x 轴正向
    int span = 90 * 16; // 旋转 90°
    painter.drawArc(rect, start, span);

    聪明的小伙伴可能会问,为什么要乘以 16 呢?因为,出于绘图精细粒度和数据的保存考量,Qt 中大多数绘图角度使用整型(int),并且一个单位是 1 / 16 个角度(degree)。

  • 绘制弦:QPainter::drawChord(const QRect& rect, int startAngle, int spanAngle); 示例:

    1
    2
    3
    4
    QRect rect(W/4, H/4, W/2, H/2);
    int start = 90 * 16;
    int span = 90 * 16;
    painter.drawChord(rect, start, span);
  • 绘制矩形:QPainter::drawRect(const QRect& rect); 上面的例子就是示例;

  • 擦除矩形QPainter::eraseRect(const QRect& rect); 示例显然。本质上是等效于用当前背景色填充该区域

  • 绘制圆角矩形:QPainter::drawRoundedRect(const QRect& rect); 使用方法同矩形;

  • 绘制扇形:QPainter::drawPie(const QRect& rect, int startAngle, int spanAngle); 示例:

    1
    2
    3
    4
    QRect rect(W/4, H/4, W/2, H/2);
    int start = 40 * 16;
    int span = 120 * 16;
    painter.drawPie(rect, start, span);
  • 绘制凸多边形:QPainter::drawConvexPolygon(const QPoint[] pArr, int size); 示例:

    1
    2
    3
    4
    5
    6
    7
    QPoint points[4] = {
    QPoint(5*W/12, H/4),
    QPoint(3*W/4, 5*H/12),
    QPoint(5*W/12, 3*H/4),
    QPoint(W/4, 5*H/12)
    };
    painter.drawConvexPolygon(points, 4);
  • 绘制闭合多边形(闭合折线):QPainter::drawPolygon(const QPoint[] points, int size); 使用方法同 “绘制凸多边形”;

  • 绘制不闭合折线:QPainter::drawPolyline(const QPoint[] points, int size); 使用方法同 “绘制凸多边形”;

  • 绘制椭圆:QPainter::drawEllipse(const QRect& rect); 示例:

    1
    2
    QRect rect(W/4, H/4, W/2, H/2);
    painter.drawEllipse(rect);

    这里实际上是利用了椭圆的外接矩形唯一的特性,长轴长是矩形的长,宽也同理。

  • 绘制图片QPainter::drawImage(const QRect& rect, const QImage& image);QPainter::drawPixmap(const QRect& rect, const QPixmap& pixmap);示例:

    1
    2
    3
    4
    5
    6
    7
    QRect rect(W/4, H/4, W/2, H/2);
    QImage image(":/imgs/qt.jpg");
    painter.drawImage(rect, image);
    // 或者 Pixmap 图形 ----------------
    QRect rect(W/4, H/4, W/2, H/2);
    QPixmap pixmap(":/imgs/qt.jpg");
    painter.drawPixmap(rect, pixmap);
  • 绘制线段:QPainter::drawLine(const QLine& line); 示例:

    1
    2
    QLine line(W/4, H/4, W/2, H/2);
    painter.drawLine(line);

    这里的 QLine 类,和之前的 QPointQRect 一样,都是表示二维图形的类。它的构造函数可以是两个点的坐标,也可以是两个 QPoint 对象。

  • 绘制线段组:QPainter::drawLines(const QVector<QLine>& lines); 示例:

    1
    2
    3
    4
    5
    6
    7
    QRect rect(W/4, H/4, W/2, H/2);
    QVector<QLine> lines;
    lines.append(QLine(rect.topLeft(), rect.bottomRight()));
    lines.append(QLine(rect.topRight(), rect.bottomLeft()));
    lines.append(QLine(rect.topLeft(), rect.bottomLeft()));
    lines.append(QLine(rect.topRight(), rect.bottomRight()));
    painter.drawLines(lines);

    这里用到了 QRect 类的调用顶点方法:

    1
    2
    3
    4
    QPoint QRect::topLeft() const;
    QPoint QRect::topRight() const;
    QPoint QRect::bottomLeft() const;
    QPoint QRect::bottomRight() const;
  • 绘制定制的路径:QPainter::drawPath(const QPainterPath& path); 示例:

    1
    2
    3
    4
    5
    QRect rect(W/4, H/4, W/2, H/2);
    QPainterPath path;
    path.addEllipse(rect);
    path.addRect(rect);
    painter.drawPath(path);

    这里的 QPainterPath 不是基本图形元件,而是复合图形对象。它是由一系列绘图操作的顺序集合,很多方法和 QPainter 很接近,也有专用函数,例如 lineTo(const QPoint&)(在当前起点到指定点间连一条线段) closeSubPath()connectPath() 等,感兴趣查阅文档。

  • 绘制文字:QPainter::drawText(const QRect& rect, const QString& text); 示例:

    1
    2
    3
    4
    5
    6
    QRect rect(W/4, H/4, W/2, H/2);
    QFont font;
    font.setPointSize(30);
    font.setBold(true);
    painter.setFont(font); // 这里用 painter 设置字体
    painter.drawText(rect, "Hello, Qt");

QRect 和 QRectF

我们在上面的例子中可以看到,几乎所有的绘画过程竟然都用到了 QRect 类。

看文档的同学可能会问,诶,这个经常看到 QRectQRectF 类同时出现,它们有什么关系吗?实际上两者的目标是一样的,只不过是 “历史遗留问题”。

先出现的 QRect 的构造方法之一是左上、右下的点的坐标。这个构造是准确的,但是调用 bottomRight()bottonLeft()topRight() 返回的位置和实际位置相差 1 个单位。这是由于对于像素中心的划分方法导致的,只有 topLeft() 返回数据与实际数据相同,官方文档描述如下:

而后出现的 QRectF,修复了这个问题,所有方法都是准确的。所以,你既可以使用 QRect,但要记住这个 ± 1 的“陷阱”,也可以用 QRectF,这取决于自己,又或是项目的兼容性。

因为在 Qt 几乎所有的官方函数中,使用到 QRect 类的地方都用了 QRectF 重载了一下。留着 QRect 是为了向前兼容。

还有 QPointQPointF 等,想要中文版了解原因,强烈推荐这篇文章:传送门🚪

6.1.3 绘图坐标系 和 坐标变换

坐标变换

在 6.1.1 中提到过,目前为止我们使用的都是视口坐标,这是 Qt 绘图的默认坐标系统,也被称为绘图设备的物理坐标。为了绘图方便,QPainter 还提供了变换绘图坐标的功能,例如平移、旋转等。这个时候,由于旋转涉及大量数学计算,所以使用逻辑坐标系统是最方便的。逻辑坐标会在接下来的讨论中逐步解释。

下面是 QPainter 关于坐标变换的相关方法:

1
2
3
4
5
6
7
8
void QPainter::translate(qreal dx, qreal dy);
void QPainter::rotate(qreal angle);
void QPainter::scale(qreal sx, qreal sy);
void QPainter::shear(qreal sh, qreal sv);

void QPainter::save();
void QPainter::restore();
void QPainter::resetTransform();

先解释 qreal,它就是 Qt 用来存储实数的基础数据类型,和 double 类似的使用。

QPainter::translate()将坐标系统的原点平移给定的偏移量

⚠ 注意,这里平移的是坐标系统,不会改变已有图形的绝对位置,只会改变当前绘图的坐标、改变原有图形在当前坐标系统下的坐标。下面所有的功能也都是这样!!!

QPainter::rotate()将坐标系统顺时针(+)旋转给定角度

注:这个角度由于是 qreal,所以是 1° 的角度制。

QPainter::scale()将坐标系统的 x、y 轴分别缩放指定倍数

注:倍数大于 1 放大,小于 1 缩小。

QPainter::shear()将坐标系统在水平、垂直方向做指定倍数的扭转变换

QPainter::save() 保存 painter 当前的状态,并将其压入堆栈

QPainter::restore() 从堆栈中弹出并恢复到上一个 painter 状态

QPainter::resetTransform() 复位所有的坐标变换

绘图坐标系

现在解释之前的 “视口坐标系”、“窗口坐标系” 和 “逻辑坐标系”。

视口(viewport)表示绘图设备的任意一个矩形区域的物理坐标,默认情况下等于绘图设备的整个矩形区域(意味着可以更改);

1
void QPainter::setViewport(int x, int y, int width, int height);

窗口(window)和 视口 是同一个矩形,只不过窗口是用逻辑坐标定义的坐标系

逻辑坐标可以理解为一种相对的坐标,不代表物理坐标,只是数学上的参照。

上面的解释非常抽象,以一个例子说明:

如上图,(a)的外围矩形框代表绘图设备的物理大小、坐标范围,假设宽 300px,高 200px。现在取中间的正方形阴影区域作为视口。因此视口的左上角在设备的物理坐标(50,0),右下角(250,200);可以这么设置:painter.setViewport(50, 0, 200, 200);而在设置指定视口后,逻辑坐标还没有确定,因为没有确定窗口

对于给定的视口,可以在其上定义窗口,这里定义窗口就是在定义逻辑坐标系。为什么这么说?因为别忘了窗口和视口是同一个矩形,这时设置窗口坐标相当于指定窗口在逻辑坐标系中的位置,进而可以由 QPainter 底层计算出逻辑坐标的情况。

例如,这里我设置 painter.setWindow(-50, -50, 100, 100); 代表当前的窗口左上角在逻辑坐标的位置是(-50,-50),当前窗口的逻辑宽度、逻辑高度都是 100;注意:逻辑宽度、逻辑高度设置后,可能比例与物理宽高不一致,这取决于您给定的数字。这个例子中逻辑长度和物理长度是 1:1。由此建立的逻辑坐标系如图(b)所示。以后的坐标都按逻辑坐标系来给定

为什么要大费周章引入这一系列坐标系?直接用视口坐标(物理坐标)不行吗?其实窗口坐标(逻辑坐标)有优点,就是只需按窗口坐标的定义来绘图,不用管实际物理坐标范围。例如在固定边长 100px(物理长度)的正方形窗口中绘图,当实际设备的大小变化(可能是用户拖动),绘制图形会自动变化实际大小来适应相对大小

练习

现在综合之前的知识做个练习:在画布上绘制一个正五角星,并在一旁绘制至少 2 个旋转了不同角度的正五角星。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 请自行实现 Canvas 类
void Canvas::paintEvent(QPaintEvent* event) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QPen pen;
pen.setWidth(3);
pen.setColor(Qt::blue);
pen.setStyle(Qt::SolidLine);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
painter.setPen(pen);
QBrush brush;
brush.setStyle(Qt::NoBrush);
painter.setBrush(brush);

int H = this->height();
int W = this->width();
const qreal PI = 3.141592653;
QRectF rect(-W/2, -H/8, W/2, H/4);

painter.setViewport(W/8, H/8, 3*W/4, 3*H/4);
// 数学好的同学这里一眼就看出来,逻辑长度:物理长度 = 3:4
painter.setWindow(0, -H/2, W, H);

const qreal m = qMin(H, W);

QPoint center_1(-m/2+m/6, 0);
QPoint center_2(0, 0);
QPoint center_3(m/2-m/6, 0);

QPoint p_1_1(0, -m/6);
QPoint p_1_2(m/6*std::cos(2*PI/5-PI/2), m/6*std::sin(2*PI/5-PI/2));
QPoint p_1_3(m/6*std::cos(4*PI/5-PI/2), m/6*std::sin(4*PI/5-PI/2));
QPoint p_1_4(m/6*std::cos(6*PI/5-PI/2), m/6*std::sin(6*PI/5-PI/2));
QPoint p_1_5(m/6*std::cos(8*PI/5-PI/2), m/6*std::sin(8*PI/5-PI/2));

QPainterPath* path = new QPainterPath(p_1_1);
path->lineTo(p_1_3);
path->lineTo(p_1_5);
path->lineTo(p_1_2);
path->lineTo(p_1_4);
path->lineTo(p_1_1);

painter.drawPath(*path);
painter.translate(m/3, 0);
painter.rotate(30);
painter.drawPath(*path);
painter.translate(m/3, 0);
painter.rotate(30);
painter.drawPath(*path);

delete path;
}

6.2 QGraphicsView 进阶绘图

在介绍 QGraphicsView 前,请大家回想在第 4 章使用的 QTableWidget(父类 QTableView),它是 model/view(模型/视图)结构。在 Qt 中,model/view 结构是 Qt 界面组件显示与编辑数据的一种结构,视图时显示和编辑数据的界面组件,而模型是视图与原始数据之间的接口。最典型的应用是在数据库软件中的表单渲染。

主要用到的视图组件有 QListViewQTreeViewQTableView 等,它们对应的 XXXWidget 则是一层包装,直接用项存储,而在更高级的用法中就需要 model/view 的结构了。看下面的关系图:

  • 数据(Data):就是实际的数据,例如数据库的一个表、SQL 查询结果、内存里的一个数组、磁盘文件结构等等;

  • 视图或视图组件(View):是屏幕上的界面组件,视图从数据模型获得每个数据项的模型索引(model index),然后依此获得数据并显示。Qt 中的例子如下,大家根据需要,用到再查询文档使用:

    1
    2
    3
    4
    5
    QListView: 用于显示单列的列表数据,适用于一维数据的展示和操作
    QTreeView: 用于显示树状结构数据
    QTableView: 用于显示表格状数据,适用于二维表格型数据的展示和操作
    QColumnView: 用于多个 QListView 显示树状层次,每层用一个 QListView 表示
    QHeaderView: 提供表头或列表头的视图组件,例如 QTableView 的行表头和列表头

    这些视图组件一般调用 setModel() 来设置模型或数据模型的种类

  • 模型或数据模型(Model):与实际数据通信,并且为视图组件提供数据接口。主要是从原始数据提取内容,并提示视图组件进行显示、编辑。Qt 中的例子如下,大家根据需要,用到再查询文档使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    QStringListModel: 用于处理字符串列表的数据模型类
    QStandardItemModel: 标准的基于项数据的数据模型类,每个项数据可以是任何数据类型
    QFileSytemModel: 计算机上文件系统的数据模型类
    QSortFilterProxyModel: 与其他数据类型结合,提供排序、过滤等功能的数据模型类

    QSqlQueryModel: 用于数据库 SQL 查询结果的数据模型类
    QSqlTableModel: 用于数据库的一个数据表的数据模型类
    QSqlRelationalTableModel: 用于关系型数据库的数据模型类

    // 如果上面的类不能满足要求,还可以自己从 QAbstractItemModel、QAbstractListModel、QAbstractTableModel 等类自行定制
  • 代理(Delegate):这可以让用户定制数据的显示和编辑方式。默认情况下代理显示一个数据,当数据被编辑时,代理通过 model index 和数据模型通信(一般会提供编辑器,默认 QLineEdit);

    通俗地解释一下,第 4 章中,我们使用 QTableWidget 来创建表格区域时,双击修改表格会弹出一个 QLineEdit,输入回车后消失,它就是代理。当然,除了 QLineEdit,对于特定场景,例如只允许输入整型,那么 QSpinBox 合适,如果要从列表中选择数据填入,那么 QComboBox 合适。这时,可以从 QStyledItemDelegate 来继承创建一个自定义代理类

  • View、Model、Delegate 三者间通过信号-槽通信:源数据改变时,model 向 view 发射信号;用户在 view 中操作数据时,view 向 model 发射信号;用户借助代理编辑数据时,delegate 向 model 和 view 发射信号。

  • Model/View 结构有一些约定的实现需要了解。

    • 数据形式上还是以项的形式存在;这意味着第 4 章中对于 “QTableWidgetItem 设计” 的探讨,这里对于所有的 QStandardItem 都是一样的,都有 “role” 的说法;
    • 为了保证数据表示 和 数据存取方式的隔离,数据模型中引入了模型索引(model index)的概念。通过数据模型存取的每一个数据都有一个模型索引,视图组件 view 和代理 delegate 都通过调用 model 的模型索引来定位、获取数据;
    • 模型索引有个专门的类来表示:QModelIndex,它提供数据存取的临时指针(数据模型内部组织数据的结构随时可能改变)。如果想要持久性的索引,可能需要 QPersistentModelIndex 类,但会牺牲部分的性能和空间;
    • 由模型索引确定数据,需要向 QModelIndex 传递 3 个参数:行号、列号、父项的模型索引。但请注意,这不代表底层的数据以二维数组存储,只是这么表示更方便,所以 QModelIndex 的接口就这么设计。行和列的编号好说,在 List ModelTable Model 中就是原本的意思,在 Tree Model 中行指深度优先的编号;父项索引也好说,在 Tree Model 中就是原本的意思,在 List ModelTable Model 中就是空对象(QModelIndex());

铺垫了这么多 model/view 结构的知识,接下来终于可以开始讨论 QGraphicsView 的使用了。

6.2.1 场景、视图、图形项

前一节叙述的 QPainter 绘图只能实现一些简单的绘制事件。对于复杂度高、交互性强、图形间的逻辑关系复杂的情况,很可能就需要 Graphics View 绘图架构来完成。

这种架构是基于 Graphics/Item 的模型/视图模式,和 Model/View 模式类似。Graphics/Item 模式由 3 个部分组成:场景、视图、图形项

场景

QGraphicsScene 类提供绘图场景(Scene)。场景本身不可见,是一个抽象的管理图形项的容器,可以向场景加入图形项,获取场景中某个图形项等功能,具体如下:

  • 提供管理大量图形项的快速接口;
  • 将事件传播给每个图形项;
  • 管理每个图形项的状态,例如选择、焦点状态;
  • 管理未经变换的渲染功能(主要用于打印);

场景中除了图形项,还有背景层前景层,通常用 QBrush 指定,也可以重写 drawBackground()drawForeground() 来自定义效果;

视图

QGraphicsView 提供绘图的视图(view)组件,用于显示场景中的内容。可以为一个场景设置多个视图,也可以对同一个数据集提供不同视口。下图展示了场景、视图、图形项三者间的关系:

虚线框是场景,视图 1 范围比场景大,因此可以显示全部内容(默认居中显示在视图中,可调整视图的 Alignment 属性);视图 2 小于场景,仅能显示一部分内容,但会自动提供滚动条在整个场景中滚动。

视图负责接收键鼠输入事件,并转换为场景事件,经过坐标变换后传送给可视场景

图形项

图形项(Graphics Item)就是一些基本图形元件,基类是 QGraphicsItem。Qt 提供了一些基本的图形项,例如椭圆 QGraphicsEllipseItem、矩形 QGraphicsRectItem、文字 QGraphicsTextItem 等。

QGraphicsItem 支持如下操作:

  • 一切鼠标、键盘、按键输入事件;
  • 支持拖放操作;
  • 支持组合,可以是父子项关系组合,也可以通过 QGraphicsItemGroup 类进行组合;

6.2.2 Graphics View 的坐标系统

概念

Graphics View 系统有 3 个有效坐标系:图形项坐标场景坐标视图坐标

其中,绘图时,场景坐标等价于 QPainter 的逻辑坐标,一般以场景中心为原点;

视图坐标与设备坐标(物理坐标)相同,是绝对的,默认以左上角为原点

图形项坐标是 局部逻辑坐标,一般以某个图形项的几何中心为原点。

这里很多教程和书籍写的很麻烦,下面用一段话来总结一下:

首先对于一个图形项而言,就只讨论图形项坐标。图形项坐标就是以当前图形项为参考系的坐标;而这个图形项本身整体坐标以几何中心为位置,按其父级 QGraphicsItem 的图形项坐标来表示位置。如果上层没有 QGraphicsItem,那么事件的位置就是场景坐标位置。这就意味着图形项坐标层层嵌套,最外层是场景坐标系。因此,场景坐标描述了每个顶层图形项的坐标位置。

而视图坐标就是窗口界面(widget)的物理坐标(单位像素),只与 widget 有关,与场景无关。更重要的是,所有鼠标事件、拖动事件的坐标首先由视图坐标定义,然后由转换矩阵转换为场景坐标,以便与图形项交互

使用方法

那么如何使用?首先创建场景的时候就可以定义场景坐标范围:

1
QGraphicsScene::QGraphicsScene(int x, int y, int width, int height);

在使用时,虽然 QGraphicsScene 描述的是顶层 QGraphicsItem,但有一层封装可以取得所有图形项的场景坐标:

1
QPoint QGraphicsItem::scenePos() const;

还有 QGraphicsItem 在其父级中的坐标:

1
QPoint QGraphicsItem::pos() const;

注:除了上面两个方法,QGraphicsItem 的方法返回的位置几乎都是自己的局部坐标

例如获得 QGraphicsItem 的边界矩形框的方法,返回的就是边界矩形在自己的局部坐标系下的坐标值:

1
QRect QGraphicsItem::boundingRect() const;

既然 pos() 有对应在场景中的 scenePos(),那么 boundingRect() 也有在场景中的 sceneBoundingRect()可以指出当前图形项在场景中的边界矩形、图形项的边界有什么变化等

场景本身也会在变化时发射 QGraphicsScene::changed() 信号,参数是一个场景坐标中的 QRect[],表示发生变化的矩形区

坐标映射

在场景中操作图形项的时候,在场景、图形项、视图间的坐标变换是非常有用的,这被称为 “坐标映射”。我们可以通过 QGraphicsView::mapToScene() 从视图坐标映射为场景坐标,然后用 QGraphicsScene::itemAt() 获取场景中鼠标光标处的图形项

6.2.3 主要类的接口

在接口列表中,我们省去了设置函数对应的读取函数,例如 setScene() 有对应的 scene() 读,后者是有的,但不会赘述,同时省去作用域符 :: 和显然的参数、const 修饰。

QGraphicsView

对于这个类,主要讨论的是它的视口坐标(视图坐标,也就是物理坐标)。下面列出类中常用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 场景类型的方法
void setScene(); // 设置与 view 关联的场景
void setSceneRect(); // 用 QRect 设置
// 外观类型
void setAlignment(); // 设置场景在视图中的对齐方式,默认上下都居中
void setBackgroundBrush(); // 设置场景的背景画刷,用来管理背景
void setForegroundBrush(); // 前景
void setRenderHints(); // 设置绘图选项,回想一下 QPainter::setRenderHints()
// 交互类型
void setInteractive(); // 设置场景是否允许交互。如果禁止,那么键鼠事件也会被忽略
QRect rubberBandRect(); // 返回当前选中的矩形框
void setRubberBandSelectionMode(); // 就是设置选择模式:能否选择、能否多选等,请回想 QTableWidget::setSelectionMode()
QGraphicsItem* itemAt(); // 获取视图(物理)坐标系中某位置处的图形项
QList<QGraphicsItem*> items(); // 返回场景中所有 / 某个选中区域的图形项列表
// 坐标映射型
QPoint mapFromScene(); // 场景坐标 转 视图坐标
QPointF mapToScene(); // 视图坐标 转场景坐标

QGraphicsScene

这个类就是用于管理图形项的场景,是图形项的容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 场景类型
void setSceneRect(); // 设置场景的矩形边界,和 QGraphicsView 一样能改
// 分组类型
QGraphicsItemGroup* createItemGroup(); // 创建图形项组
void destroyItemGroup(); // 解除一个图形项组
// 输入焦点类型
QGraphicsItem* focusItem(); // 返回当前获得焦点的图形项
void clearFocus(); // 去除选择焦点
bool hasFocus(); // 当前视图是否有焦点
// 图形项操作类型
void addItem(); // 添加一个**已创建**的图形项
void removeItem(); // 删除图形项
void clear(); // 清除场景中所有图形项
QGraphicsItem* mouseGrabberItem(); // 返回鼠标抓取的图形项
QList<QGraphicsItem*> selectedItems(); // 与QGraphicsView::items()差距很小
void clearSelection(); // 清除当前所有选中
QGraphicsItem* itemAt(); // 获取场景坐标系下某位置的顶层图形项,和 QGraphicsView::itemAt(); 就是输入坐标有差异
QList<QGraphicsItem*> items(); // 参考 QGraphicsView::items();
// 添加图形项类型
QGraphicsEllipseItem* addEllipse(); // 加一点椭圆
QGraphicsLineItem* addLine(); // 加一点线段
QGraphicsPathItem* addPath(); // 加一点 QPainterPath
QGraphicsPixmapItem* addPixmap(); // 加一点 pixmap
QGraphicsPolygonItem* addPolygon(); // 加一点多边形
QGraphicsRectItem* addRect(); // 加一点矩形
QGraphicsSimpleTextItem* addSimpleText(); // 加一点简单文字
QGraphicsTextItem* addText(); // 加一点字符串
QGraphicsProxyWidget* addWidget(); // 加一点界面组件
// 味道好极了

QGraphicsItem

它是所有图形项的基类,用户可以从它继承定义自己的图形项类。Qt 中常见的图形项类的继承关系如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
QGraphicsItem
|
├─ QAbstractGraphicsShapeItem
| |
| ├─ QGraphicsEllipseItem
| ├─ QGraphicsPathItem
| ├─ QGraphicsPolygonItem
| ├─ QGraphicsRectItem
| └─ QGraphicsSimpleTextItem
|
├─ QGraphicsLineItem
├─ QGraphicsPixmapItem
├─ QGraphicsObject
| |
| └─ QGraphicsTextItem
|
└─ QGraphicsItemGroup

这个 QGraphicsItem 类提供了对图形项的基本操作方法,常见方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 属性设置类型
void setFlags(); // 设置图形项的操作属性,例如可选择、可移动等
void setOpacity(); // 如其名,设置透明度
void setGraphicsEffect(); // 设置图形效果
void setSelected(); // 设置图形项是否被选中
void setData(); // 用户自定义数据
// 坐标类型
void setX(); // 图形项在父级中的 X 坐标
void setY(); // Y 坐标
void setZValue(); // Z 值控制叠放次序
void setPos(); // 图形项在父级中的位置
QPointF scenePos(); // 返回图形项在场景中的坐标,相当于在 mapToScene 上包装
// 坐标变换类型
void resetTransform(); // 复位坐标系,取消所有变换
void setRotation(); // 顺时针(+)旋转指定角度
void setScale(); // 等比例缩放
// 坐标映射类型
QPointF mapFromItem(); // 将另一个图形项的一个**点**映射到本图形项的局部坐标
QPointF mapFromParent();// 将父级的一个点映射到本图形项的局部坐标
QPointF mapFromScene(); // 将场景的一个点映射到本图形项的局部坐标
QPointF mapToItem(); // 将本图形项的一个点映射到另一个图形项的局部坐标
QPointF mapToParent(); // 将本图形项的一个点映射到父级的局部坐标
QPointF mapToScene(); // 将本图形项的一个点映射到场景坐标
// 绘制类型
// 注意!QGraphicsItem 也可以像 QPainter 一样指定绘制的工具
// ...(略)
// 再补充一个改变鼠标悬停样式的方法:
void setCursor(); // 参数请参考 QApplication::setOverrideCursor()

这里说一下 QGraphicsItem::setFlags() 的使用,参数就是 QGraphicsItem::QGraphicsItemFlag 枚举类型,其值也是十六进制数码,也允许按位或组合,常见值有:QGraphcisItem::ItemIsMovableQGraphicsItem::ItemIsSelectableQGraphicsItem::ItemIsFocusable 等;

6.3 章末总结

6.3.1 知识补充

Qt 中有格式化字符串:

1
2
// static function
QString QString::asprintf(const char* format, __VAR_ARGS__);

格式化占位符和 C++ 的 printf 函数相同;

Qt 中还有一个类,比较常用,但是内容很少,就不再另起一节——它就是 QTimer

QTimer 用于创建和管理定时器。它提供了一种机制,可以在给定的时间间隔内发出信号。比较常用的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
QTimer::QTimer(QObject* parent = nullptr);

int QTimer::interval() const; // 查询时间间隔
void QTimer::setInterval(int milliSeconds); // 设置触发间隔
bool QTimer::isActive() const; // 当前是否正在计时
bool QTimer::isSingleShot() const; // 是否是只触发一次的计时器
// slots:
void QTimer::start(); // 计时开始
void QTimer::stop(); // 计时停止
// signals:
// 除非是仅触发一次的计时器,否则每当开始后 interval 的整数倍时间就 emit 一次这个信号
void QTimer::timeout();

6.3.2 类图总结

前面几章遇到的类图的总结如下: