QT 绘图基础学习

绘图基础

绘图基础

这一节介绍 Qt 的绘图基础知识,我们都知道,Qt 里绘图使用的是 QPainter,但是首先需要弄明白:在什么上绘图和在哪里绘图,然后才是怎么绘图,我们就围绕这几个问题来展开。

在什么上绘图

The QPaintDevice class is the base class of objects that can be painted on with QPainter.

A paint device is an abstraction of a two-dimensional space that can be drawn on using a QPainter. Its default coordinate system has its origin located at the top-left position. X increases to the right and Y increases downwards. The unit is one pixel.

The drawing capabilities of QPaintDevice are currently implemented by the QWidget, QImage, QPixmap, QGLPixelBuffer, QPicture, and QPrinter subclasses.

上面的内容来自于 Qt 的帮助文档,在 QPaintDevice 的子类里用 QPainter 绘图,最常见的就是在 QWidget, QPixmap, QPixture, QPrinter 上面绘图。

在哪里绘图

知道了可以在哪些类中绘图了,总不能在这些类的子类中随便写个函数就可以绘图了吧!这需要分情况,在 MainWidget 的构造函数里创建一个 QPixmap,并在它上面画图,然后设置为 QLabel 的 pixmap:

MainWidget::MainWidget(QWidget *parent)
    : QWidget(parent), ui(new Ui::MainWidget) {
    ui->setupUi(this);

    // 创建 pixmap
    pixmap = QPixmap(100, 100);
    pixmap.fill(Qt::gray);
    QPainter painter(&pixmap);
    painter.drawRect(10, 10, 80, 80);
    painter.drawText(20, 30, "Hello World");

    // 使用 pixmap
    ui->label->setPixmap(pixmap);
}

在非 widget 上绘图,如上面的 QPixmap,在什么地方都可以,但是在 QWidget 及其子类里绘图却没有这么自由,通常都是想要在哪个 widget 上绘图,就需要在它自己的 paintEvent() 函数绘图(重载 paintEvent() 函数)。

例如类 PandoraWidget 是 QWidget 的子类,要在它上面画一个矩形,它的 paintEvent() 函数如下:

void PandoraWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this); // this 是 PandoraWidget 的指针
    painter.setPen(Qt::gray);
    painter.setBrush(Qt::green);
    painter.drawRect(10, 10, 50, 50);
}

考虑一个问题,类 PandoraWidget 上有一个叫 magicLabel 的 QLabel,打算在 magicLabel 上画一个矩形,PandoraWidget 的 paintEvent() 函数像下面这样用 magicLabel 构造 QPainter,然后绘图可以吗?

void PandoraWidget::paintEvent(QPaintEvent *) {
    QPainter painter(ui->magicLabel); // 注意这里
    painter.setPen(Qt::gray);
    painter.setBrush(Qt::green);
    painter.drawRect(10, 10, 50, 50);
}

运行程序,结果并没有在 magicLabel 上绘制出矩形,而且还输出了下面的错误:

QWidget::paintEngine: Should no longer be called
QPainter::begin: Paint device returned engine == 0, type: 1
QPainter::setPen: Painter not active
QPainter::setBrush: Painter not active
QPainter::drawRects: Painter not active

总是提示 Painter not active,上面提到过:想要在哪个 widget 上绘图,就需要在它自己的 paintEvent() 函数绘图,这里的 paintEvent() 函数是 PandoraWidget 的,所以绘图到 PandoraWidget 上成功了,但 paintEvent() 函数不是 QLabel 的,所以企图绘图到 magicLabel 上没成功。

那是不是就是说,如果想在 magicLabel 上绘图,就必须新创建一个类例如叫 MagicLabel,并且继承自 QLabel,然后在它的 paintEvent() 里绘图?如果有 10 个子 widget,都想在上面画点啥,是不是每个 widget 都要对应创建一个类来实现绘图?我就是想画个圈而已,要创建这么多类也太麻烦了,真的想画个圈圈诅咒 Qt 啊。

莫急莫急,这里传大家本人秘藏多年的一绝技,就能在 PandoraWidget 的函数里给 magicLabel 绘图了:在事件过滤器eventFilter() 中拦截 magicLabel 的 QEvent::Paint 事件,用 magicLabel 创建 QPainter,就可以在 magicLabel 上绘图了,上代码,否则估计有人要把我画在圈圈里了:

PandoraWidget::PandoraWidget(QWidget *parent) 
    : QWidget(parent), ui(new Ui::PandoraWidget) {
    ui->setupUi(this);
    ui->magicLabel->installEventFilter(this);
}

bool PandoraWidget::eventFilter(QObject *watched, QEvent *event) {
    if (watched == ui->magicLabel && event->type() == QEvent::Paint) {
        magicTime();
    }

    return QWidget::eventFilter(watched, event);
}

void PandoraWidget::magicTime() {
    QPainter painter(ui->magicLabel);
    painter.setPen(Qt::gray);
    painter.setBrush(Qt::green);
    painter.drawRect(10, 10, 50, 50);
}

怎么绘图

下图来自《C++ GUI Programming with Qt 4》,列出了 QPainter 常用的画图方法,都是以 draw 开头,非常直观的列出了绘图函数和绘制出来的图形:

下面具体的介绍这些函数的使用,它们中很多都有重载的函数,这里只使用其中的一种,其它的用法都差不多,就不一一介绍了。

坐标系

数学中使用的是笛卡尔坐标系,X 轴正向向右,Y 轴正向向上。但是,QPainter 也有自己的坐标系,和笛卡尔坐标系不一样,原点在 widget 的左上角而不是正中心,X 轴正向向右,Y 轴正向向下,每个 widget 都有自己独立的坐标系。

画线段 - drawLine()

给定 2 个点,使用 drawLine() 画一条线段。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.drawLine(30, 30, 150, 150);
}

drawLine() 有什么用?例如可以用来画网格线。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.translate(30, 30);

    int w = 300;
    int h = 210;
    int gap = 30;

    // 画水平线
    for (int y = 0; y <= h; y += gap) {
        painter.drawLine(0, y, w, y);
    }

    // 画垂直线
    for (int x = 0; x <= w; x += gap) {
        painter.drawLine(x, 0, x, h);
    }
}

画多线段 - drawLines()

给定 N 个点,第 1 和第 2 个点连成线,第 3 和第 4 个点连成线,……,N 个点练成 (N+1)/2 条线,如果 N 是奇数,第 N 个点和 (0,0) 连成线。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(20, 20);

    static const QPointF points[4] = {
        QPointF(0.0, 100.0),
        QPointF(20.0, 0.0),
        QPointF(100.0, 0.0),
        QPointF(120.0, 100.0)
    };

    painter.drawLines(points, 2); // 4 个点连成 2 条线
}

画折线- drawPolyline()

给定 N 个点,第 1 和第 2 个点连成线,第 2 和第 3 个点连成线,……,第 N-1 和第 N 个点连成线,N 个点共连成 N-1 条线。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(20, 20);

    static const QPointF points[4] = {
        QPointF(0.0, 100.0),
        QPointF(20.0, 0.0),
        QPointF(100.0, 0.0),
        QPointF(120.0, 100.0)
    };

    painter.drawPolyline(points, 4);
}

画多边形 - drawPolygon()

给定 N 个点,第 1 和第 2 个点连成线,第 2 和第 3 个点连成线,……,第 N-1 和第 N 个点连成线,第 N 个点和第 1 个点连接成线形成一个封闭的多边形。

drawPolygon() 和 drawPolyline() 很像,但是 drawPolygon() 画的是一个封闭的区域,可以填充颜色,而 drawPolyline() 画的是一些线段,即使它们连成一个封闭的区域也不能填充颜色。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(20, 20);

    static const QPointF points[4] = {
        QPointF(0.0, 100.0),
        QPointF(20.0, 0.0),
        QPointF(100.0, 0.0),
        QPointF(120.0, 100.0)
    };

    painter.drawPolygon(points, 4);
}

可以用 drawPolygon() 来画圆,其实本没有圆,正多边形的边多了,便成了圆,这正是计算机里绘制曲线的原理,插值逼近,在曲线上取 N 个点,点之间用线段连接起来,当 N 越大时,连接出来的图形就越平滑,越接近曲线。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing); // 启用抗锯齿效果
    painter.translate(width() / 2, height() / 2); // 把坐标原点移动到 widget 的中心
    painter.setBrush(Qt::lightGray);

    const int COUNT  = 10;  // 边数,越大就越像圆
    const int RADIUS = 100; // 圆的半径
    QPointF points[COUNT];  // 顶点数组

    for (int i = 0; i < COUNT; ++i) {
        float radians = 2 * M_PI / COUNT * i; // M_PI 是 QtMath 里定义的,就是 PI
        float x = RADIUS * qCos(radians);
        float y = RADIUS * qSin(radians);
        points[i] = QPointF(x, y);
    }

    painter.drawPolygon(points, COUNT);
}

为了介绍方便,数组 points 是在 paintEvent() 里创建的,每次调用 paintEvent() 时都会重新生成一次 points,实际项目里可不能这么做,因为 paintEvent() 会被多次的调用,每次调用都会生成 points。数组 points 只有在必要的时候才重新生成,否则就是浪费计算资源,所以可以放到构造函数里,或者点击按钮改变 COUNT 的值后在对应的槽函数里重新生成 points,然后调用 update() 函数刷新界面。

画矩形 - drawRect()

给定矩形左上角的坐标和矩形的长、宽就可以绘制矩形了。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(30, 30);

    int x = 0;
    int y = 0;
    int width = 100;
    int height = 100;

    painter.drawRect(x, y, width, height);
}

画圆角矩形- drawRoundRect() & drawRoundedRect()

绘制圆角矩形有 2 个方法:drawRoundRect() 和 drawRoundedRect(),需要给定圆角矩形左上角的坐标、长、宽、圆角的半径。

当 drawRoundedRect() 中第 7 个参数 Qt::SizeMode 为 Qt::RelativeSize 时,表示圆角半径的单位是百分比,取值范围是 [0, 100],此时 drawRoundedRect() 等价于 drawRoundRect(),其实底层是用这个百分比和对应边长的一半相乘得到圆角的半径(单位是像素)。Qt::SizeMode 为 Qt::AbsoluteSize 时,表示圆角半径的单位是像素。

有意思的是,在 QSS 中圆角半径大于对应边长的一半,圆角效果就没了,但是使用 drawRoundedRect() 时,圆角的半径大于对应边长的一半时,圆角效果仍然有效,个人认识这个是 QSS 的 bug,但是已经存在很久了。

下面使用不同的参数绘制了 3 个圆角矩形,便于比较他们之间的异同:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.setBrush(Qt::lightGray);
    painter.translate(30, 30);

    painter.drawRoundRect(0, 0, 100, 100, 50, 50); // 50%, 50%
    painter.drawRoundedRect(130, 0, 100, 100, 50, 50, Qt::AbsoluteSize); // 50px, 50px
    painter.drawRoundedRect(260, 0, 100, 100, 100, 100, Qt::RelativeSize); // 100%, 100%
}

画椭圆,圆 - drawEllipse()

给定椭圆的包围矩形(bounding rectangle),使用 drawEllipse() 绘制椭圆。圆是特殊的椭圆,椭圆有两个焦点,这两个焦点合为一个的时候就是一个正圆了,当包围矩形是正方形时,drawEllipse() 绘制的就是圆。

当然,画圆的方法很多,上面我们就使用了 drawPolygon(),drawRounedRect() 的方法画圆,不过从语义上来说,用 drawEllipse() 来画圆显得更舒服一些。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(30, 30);

    painter.drawRect(0, 0, 200, 100);    // 椭圆的包围矩形
    painter.setBrush(Qt::lightGray);
    painter.drawEllipse(0, 0, 200, 100); // 椭圆
    
    painter.drawEllipse(230, 0, 100, 100); // 圆
}

画弧,弦,饼图 - drawArc(), drawChord(), drawPie()

画弧使用 drawArc()
画弦使用 drawChord()
画饼图用 drawPie()

把这三个函数放在一起介绍,因为它们的参数都一样,而且 arc, chord, pie 外形也有很多相似之处:

void QPainter::drawArc(const QRectF & rectangle, int startAngle, int spanAngle)
void QPainter::drawPie(const QRectF & rectangle, int startAngle, int spanAngle)
void QPainter::drawChord(const QRectF & rectangle, int startAngle, int spanAngle)

  • rectangle: 包围矩形
  • startAngle: 开始的角度,单位是十六分之一度,如果要从 45 度开始画,则 startAngle 为 45 * 16
  • spanAngle: 覆盖的角度,单位是十六分之一度
  • 绘制圆心为包围矩形的正中心,0 度在圆心的 X 轴正方向上
  • 角度的正方向是逆时针方向

下面程序的结果如图:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    static int startAngle = 45 * 16; // 开始角度是 45 度
    static int spanAngle = 130 * 16; // 覆盖角度为 130 度
    static QRectF boundingRect(0, 0, 150, 150); // 包围矩形

    painter.translate(30, 30);
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(boundingRect); // 绘制包围矩形
    painter.setBrush(Qt::lightGray);
    painter.drawArc(boundingRect, startAngle, spanAngle); // 画弧

    painter.translate(180, 0);
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(boundingRect); // 绘制包围矩形
    painter.setBrush(Qt::lightGray);
    painter.drawChord(boundingRect, startAngle, spanAngle); // 画弦

    painter.translate(180, 0);
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(boundingRect); // 绘制包围矩形
    painter.setBrush(Qt::lightGray);
    painter.drawPie(boundingRect, startAngle, spanAngle); // 画饼图
}

修改 startAngle 和 spanAngle 为负值看看是什么效果。

绘制 QPixmap - drawPixmap()

Pixmap 的绘制有下面四种方式(每种方式都有几个重载的函数,没有全部列举出来):
  1. 在指定位置绘制 pixmap,pixmap 不会被缩放

    /* pixmap 的左上角和 widget 上 x, y 处重合 */
    void QPainter::drawPixmap(int x, int y, const QPixmap & pixmap)
    void QPainter::drawPixmap(const QPointF &point, const QPixmap &pixmap)
  2. 在指定的矩形内绘制 pixmap,pixmap 被缩放填充到此矩形内

    /* target 是 widget 上要绘制 pixmap 的矩形区域 */
    void QPainter::drawPixmap(int x, int y, int width, int height, const QPixmap &pixmap)
    void QPainter::drawPixmap(const QRect &target, const QPixmap &pixmap)
  3. 绘制 pixmap 的一部分,可以称其为 sub-pixmap

    /* source 是 sub-pixmap 的 rectangle */
    void QPainter::drawPixmap(const QPoint &point, const QPixmap &pixmap, const QRect &source)
    void QPainter::drawPixmap(const QRect &target, const QPixmap &pixmap, const QRect &source)
    void QPainter::drawPixmap(int x, int y, const QPixmap &pixmap, 
                              int sx, int sy, int sw, int sh)
  4. 平铺绘制 pixmap,水平和垂直方向都会同时使用平铺的方式

    void QPainter::drawTiledPixmap(const QRect &rectangle, 
                                   const QPixmap &pixmap, 
                                   const QPoint &position = QPoint())
    void QPainter::drawTiledPixmap(int x, int y, int width, int height, 
                                   const QPixmap & pixmap, 
                                   int sx = 0, int sy = 0)

    drawTiledPixmap() 比我们自己计算 pixmap 的长宽,然后实现平铺的效率高一些:Calling drawTiledPixmap() is similar to calling drawPixmap() several times to fill (tile) an area with a pixmap, but is potentially much more efficient depending on the underlying window system.

使用上面这张图来演示 drawPixmap() 的各种用法,左上角绘制原始大小的 pixmap,右上角缩放绘制 pixmap 到指定的矩形内 QRect(225, 20, 250, 159),中间绘制 sub-pixmap,底部则使用平铺的方式绘制,最后结果如下图(文字是标记上去帮助理解的):

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QPixmap pixmap(":/resources/Paint-Base-Bufferfly.png"); // 从资源文件读取 pixmap

    painter.drawPixmap(20, 20, pixmap); // 按原始尺寸绘制 pixmap
    painter.drawPixmap(225, 20, 250, 159, pixmap); // 缩放绘制 pixmap
    painter.drawPixmap(20, 133, pixmap, 128, 0, 57, 46); // 绘制 pixmap 的一部分

    painter.translate(0, 199);
    painter.drawTiledPixmap(0, 0, width(), height(), pixmap);
}

绘制 QImage - drawImage()

绘制 QImage 使用 drawImage(),其用法和 drawPixmap() 的很像,就不多做介绍了。

绘制文本 - drawText()

绘制文本非常常见,QPushButton,QLabel,QTableView 等等都得用,看似简单,其实里面有很多的学问,要掌握好还是挺不容易的。

文本的绘制有两种方式:
  1. 在指定位置绘制文本,不会自动换行

    void QPainter::drawText(int x, int y, const QString &text)
    void QPainter::drawText(const QPoint &position, const QString &text)
  2. 在指定的矩形内绘制文本,设置 flags 能够实现自动换行,对齐等

    void QPainter::drawText(const QRect& rectangle, 
                            int flags, 
                            const QString &text,
                            QRect *boundingRect = 0)
    flags 为下面的值之一或则为对其取或的结果,例如靠上剧中 Qt::AlignTop | Qt::AlignHCenter:
    • Qt::AlignLeft
    • Qt::AlignRight
    • Qt::AlignHCenter
    • Qt::AlignJustify
    • Qt::AlignTop
    • Qt::AlignBottom
    • Qt::AlignVCenter
    • Qt::AlignCenter
    • Qt::TextDontClip
    • Qt::TextSingleLine
    • Qt::TextExpandTabs
    • Qt::TextShowMnemonic
    • Qt::TextWordWrap
    • Qt::TextIncludeTrailingSpaces

接下来就具体地介绍怎么绘制文本,先从最简单任务开始:在 widget 的左上角(0, 0)处绘制字符串 "jEh"。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.setFont(QFont("Times", 150, QFont::Bold)); // 使用大一些的字体

    int x = 0;
    int y = 0;
    painter.drawText(x, y, "jEh");
}

…… 出师不利啊,本以为是很简单的一件事,那还不手到擒来么,不曾想结果却让人大跌眼镜,只显示出了 j 的小尾巴。如果把 y 的值设置大一些,如 150,则就可以完全显示出来了。但是,y 要多大才合适?不能一点一点的试吧,否则字体变了,y 的值又不合适了,完全不科学,这要如何是好?

相信大多数人和我一样,刚开始的时候都认为 drawText() 的 x, y 是字符串左上角的坐标,其实不然,它是字符串的第一个字符的 origin 的坐标,y 就是字体的 base line 的 y 坐标,什么是 origin,base line? 看完下图基本上就明白了:

文本是基于 base line 绘制的,而不是左上角,所以上面的文本显示不全就很好理解了。

还是原来的问题,从 widget 的左上角开始绘制文本,那么 y 就应该和 ascent 一样大,但是怎么得到 ascent 的值呢?难到需要我们记住每种字体的 ascent 的值吗?这也是一种方法,如果愿意,未尝不可,但是,大可不必如此,类 QFontMetrics 就是用来提供字体信息的,提供了很多函数,如取得 line height 用 height(),用 width() 计算字符串的宽度,ascent(), descent(), xHeight() 等, 函数的名字已经很好的表明它的作用,在此就不再一一介绍,更多的函数请参考 Qt 的帮助文档。所以为了达到我们的目的,只需要把 y = 0 修改为 int y = metrics.ascent() 就可以了:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.setFont(QFont("Times", 150, QFont::Bold));

    QFontMetrics metrics = painter.fontMetrics();
    int x = 0;
    int y = metrics.ascent();
    painter.drawText(x, y, "jEh");
}

再考虑一个问题,像下图这样,要在一个矩形里居中显示字符串,应该怎么做呢?

有了 QFontMetrics,想必对大家来说问题已经不大,得到字符串的宽、高、ascent,简单的居中计算,就可以得到 origin 的坐标了。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.setFont(QFont("Times", 150, QFont::Bold));

    QRect rect(20, 20, 300, 200);
    painter.drawRect(rect);

    // 居中绘制文本
    QFontMetrics metrics = painter.fontMetrics();
    int stringHeight = metrics.ascent() + metrics.descent(); // 不算 line gap
    int stringWidth = metrics.width("jEh"); // 字符串的宽度
    int x = rect.x() + (rect.width() - stringWidth) / 2;
    int y = rect.y() + (rect.height() - stringHeight) / 2 + metrics.ascent();
    painter.drawText(x, y, "jEh");

    // 绘制字符串的包围矩形
    y = rect.y() + (rect.height() - stringHeight) / 2;
    painter.setPen(Qt::lightGray);
    painter.drawRect(x, y, stringWidth, stringHeight);
}

把字体的包围矩形也画出来,这样就能很清晰的看到字符串的居中效果了。也许你还会问,这不是还有一点点没有居中吗?这个和字体有关系,换成等宽字体如 Menlo 后就可以看到确实是完全居中的,说明 QFontMetrics 得到的字体信息没问题,只是有的字体为了美观漂亮作了一些调整,对于这些字体如果要完全的居中效果的话,只好在使用上面的计算方式后再手动的微调一下就好了。

开始的时候说过,drawText() 绘制文本有两种方式,不会自动换行和在给定的矩形中自动换行,下面就举例说明,先绘制一行很长但不会自动换行的文本,然后在给定的矩形 QRect(20, 35, 200, 80) 里绘制会自动换行,向右靠齐的文本,效果如下图(发现超出矩形的字符不显示):

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QString text = "QPainter provides highly optimized functions to do "
                   "most of the drawing GUI programs require. It can draw "
                   "everything from simple lines to complex shapes "
                   "like pies and chords. "
                   "看看是否也支持中文呢,如果不支持那就悲剧了!";

    QRect rect(20, 35, 200, 80);
    int flags = Qt::TextWordWrap | Qt::AlignRight; // 自动换行,向右对齐

    painter.drawText(20, 20, text);
    painter.drawRect(rect); // 画出矩形,可以看到超出此矩形的部分文本不可见
    painter.drawText(rect, flags, text);
}

计算文本的包围矩形(Bounding Rectangle)

在给定的矩形里面绘制文本,超出的部分不显示,例如 QTableView 的 item 显示文本时就是这样的,文本超出了 item 的范围,就用 ... 表示。例如开发一个聊天软件,也用这种方式绘制文本,消息太长,超出了范围的部分就不显示,万一有两个异地恋人用这个软件聊天,异地恋是很敏感的,消息显示不全,有些话很有可能理解成相反的意思,可想而知会造成多少误会,自己还不知道为什么,都无从解释,如果因此而断送了一桩美好的姻缘,那是何等的罪过,显然这种显示文本的方式在这里是不适合的。观察 QQ 显示消息的样式,是由消息来决定显示的范围,有的很长,有的很短,而不是在固定大小的矩形内显示消息。

那么,怎么根据消息的大小来确定显示的范围呢(所谓的自适应)?

首先显示的宽度应该是确定的,关键是高度的计算,可以逐个字符的把他们的宽度加起来(不同的字体每个字符的宽度不一样),当大于显示的宽度就换行,高度也对应的加上一行的高度,这样就能计算出最终的高度了,也就知道了显示消息的矩形大小,使用上面的技术 在给定的矩形里面绘制文本 就能自适应的显示消息了。

虽然我们已经知道了自适应显示消息的原理,不过仍稍显麻烦,其实 Qt 已经给我们提供了相关的 API,使用QFontMetrics::boundingRect() 可以计算出文本的包围矩形,然后在用上面的方法绘制文本就可以了,下面的程序,改变窗口的宽度,能动态的计算出显示文本所有内容的包围矩形,解决了上面说的在给定的矩形中,文本太长时显示不全的问题:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QString text = "QPainter provides highly optimized functions to do "
                   "most of the drawing GUI programs require. It can draw "
                   "everything from simple lines to complex shapes "
                   "like pies and chords. "
                   "看看是否也支持中文呢,如果不支持那就悲剧了!";

    int width = this->width() - 40; // 显示文本的宽度,为窗口的宽度减去 40 像素
    int flags = Qt::TextWordWrap; // 自动换行

    // 计算文本在指定宽度下的包围矩形
    QFontMetrics metrics = painter.fontMetrics();
    QRect textBoundingRect = metrics.boundingRect(QRect(0, 0, width, 0), flags, text);

    painter.translate(20, 20);
    painter.drawRect(textBoundingRect);
    painter.drawText(textBoundingRect, flags, text);
}

metrics.boundingRect(QRect(0, 0, width, 0), flags, text),第一个参数 rect 中最关键的是 width(显示文本的宽度),至于其中的 x, y 坐标和得到的 textBoundingRect 中的 x, y 是一样的,只是为了计算方便而已,height 没有什么意义,随便给个值就行,flags 和前面说过的 flags 一样。

QTableView 中没有显示完的字符串后面都会跟着一个 ...,这个怎么来的呢?不妨看看 QFontMetrics::elidedText(),一切就真像大白了。

elide: 省略

绘制路径 - drawPath()

路径 QPainterPath,先来一段 Qt 帮助文档里对路径的描述吧:

A painter path is an object composed of a number of graphical building blocks, such as rectangles, ellipses, lines, and curves. Building blocks can be joined in closed subpaths, for example as a rectangle or an ellipse. A closed path has coinciding start and end points. Or they can exist independently as unclosed subpaths, such as lines and curves.

A QPainterPath object can be used for filling, outlining, and clipping.

也就是说,路径可以由多个图形组成,例如矩形,椭圆,线,曲线,贝塞尔曲线等,一个路径可以和另一个路径合并,也可以从一个路径里扣掉另一个路径,路径可以用来创建复杂的图形,也可以用来限制绘图的区域实现特殊的效果等。

下面的程序画了一个很奇怪的图形,用来演示 QPainterPath 添加线,贝塞尔曲线,矩形,扣去其他 QPainterPath 等,只要发挥你的想像,就可以用 QPainterPath 组合出很多复杂的图形:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QPainterPath path;
    path.lineTo(100, 0);
    path.cubicTo(200, 0, 100, 50, 200, 100); // 贝塞尔曲线
    path.closeSubpath(); // 画一条线到路径的第一个点,闭合路径
    path.addRect(50, 50, 50, 50); // 加一个矩形

    QPainterPath path2;
    path2.addEllipse(80, 30, 50, 50);
    path = path.subtracted(path2); // 扣掉圆

    painter.translate(20, 20);
    painter.setBrush(Qt::lightGray);
    painter.drawPath(path);

    painter.setBrush(Qt::NoBrush);
    painter.drawRect(path.boundingRect());
}

QPainterPath 还可以描绘和填充文字,这个效果在写音乐播放器时,显示歌词很有用:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QString text("jEh 太极");
    QFont font("Xingkai SC", 120, QFont::Bold);
    QFontMetrics metrics(font);

    int x1 = 0;
    int y1 = 0;
    int x2 = 0;
    int y2 = y1 + metrics.height();

    // 使用渐变填充    
    QLinearGradient gradient(x1, y1 + 40, x2, y2);
    gradient.setColorAt(0, QColor(255, 255, 255));
    gradient.setColorAt(1, QColor(50, 50, 50));

    // 用文本创建 QPainterPath,第一、二个参数是 baseline 的坐标
    QPainterPath path;
    path.addText(x1, y1 + metrics.ascent(), font, text);

    painter.translate(20, -20);
    painter.setBrush(gradient);
    painter.setPen(QPen(QColor(80, 80, 80), 2));
    painter.drawPath(path);
}

由于字体的原因,计算出来的结果不够理想,做了一些微调,前面我们也提到过,字体不同时,会有细微的差别,为了实现更好的效果,有时需要手动微调一下,这里就遇到了这种情况。

QPainterPath::pointAtPercent(qreal t) 是一个很有用的函数,t 的值为 [0, 1.0],可以取得路径上任意一点的坐标,在动画一节里会使用这个函数来实现动画的插值函数,让物体沿着任意的路径运动,这里没有用 Qt 的动画框架实现了让物体沿着任意的路径运动:

// 文件名:BezierCurveAnimationWidget.h
#ifndef BEZIERCURVEANIMATIONWIDGET_H
#define BEZIERCURVEANIMATIONWIDGET_H

#include <QWidget>
#include <QPainterPath>

class BezierCurveAnimationWidget : public QWidget {
    Q_OBJECT

public:
    explicit BezierCurveAnimationWidget(QWidget *parent = 0);
    ~BezierCurveAnimationWidget();

protected:
    void timerEvent(QTimerEvent *event) Q_DECL_OVERRIDE;
    void paintEvent(QPaintEvent *event) Q_DECL_OVERRIDE;

private:
    QPainterPath path;
    float percent;
    float step;
    int timerId;
};

#endif // BEZIERCURVEANIMATIONWIDGET_H


// 文件名:BezierCurveAnimationWidget.cpp
#include "BezierCurveAnimationWidget.h"

#include <QPainter>
#include <QtGlobal>
#include <QTimerEvent>

BezierCurveAnimationWidget::BezierCurveAnimationWidget(QWidget *parent) :
    QWidget(parent) {
    step = 0.03;
    percent = 0;

    // 构造一个任意的曲线
    path.cubicTo(50, 0, 30, 100, 100, 100);
    path.cubicTo(150, 100, 250, 0, 200, 100);
    path.cubicTo(150, 100, 250, 0, 300, 140);
    path.quadTo(150, 310, 150, 100);

    timerId = startTimer(60);
}

BezierCurveAnimationWidget::~BezierCurveAnimationWidget() {
}

void BezierCurveAnimationWidget::timerEvent(QTimerEvent *event) {
    // 不停的更新 percent 的值,刷新界面,实现动画效果
    if (event->timerId() == timerId) {
        percent += step;

        if (percent < 0 || percent > 1) {
            step = -step;
            percent = qMin(percent, 1.0F);
            percent = qMax(percent, 0.0F);
        }

        update();
    }
}

void BezierCurveAnimationWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(20, 20);

    painter.drawPath(path);
    painter.setBrush(Qt::red);
    painter.drawEllipse(path.pointAtPercent(percent), 4, 4);
}

贝塞尔曲线 - Bezier Curve

QPainterPath 可以用来画贝塞尔曲线,什么是贝塞尔曲线呢?开始学的时候,经常听到贝塞尔曲线,但一直不知道是什么东西,很神秘的样子,据说很复杂,一直没敢学,人类对陌生的东西总是有恐惧感,这一部分就来揭开贝塞尔曲线神秘的面纱(大部分内容都来自于网络)。

贝塞尔曲线(The Bézier Curves),是一种在计算机图形学中相当重要的参数曲线(3D的称为曲面)。贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所发表,他运用贝塞尔曲线来为汽车的主体进行设计。

一般的矢量图形软件通过它来精确画出曲线,贝塞尔曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。贝塞尔曲线是计算机图形学中相当重要的参数曲线,在一些比较成熟的位图软件中也有贝塞尔曲线工具,如PhotoShop等。

贝塞尔曲线还是很抽象的,如果不是看了下面的这些动态图,演示了贝塞尔曲线的生成过程,估计仍然很难明白贝塞尔曲线是什么样的,控制点是什么,有什么用。

塞尔曲线的通用公式为:

看上去是不是很复杂,难以理解?谁都一样,开始时看到这么复杂的公式,都会头大,但是看完下面的一阶,二阶,三阶贝塞尔曲线的方程和生成动画后就明白了,原来大名鼎鼎的贝塞尔曲线也不难嘛。

一阶贝塞尔曲线 就是线段,没有控制点,其参数方程为

下图是生成一阶贝塞尔曲线的动画:

二阶贝塞尔曲线 只有一个控制点,为 P1,其参数方程为

下图是生成二阶贝塞尔曲线的动画:

把其中的任意一帧拿出来分析,可以看到 MP0/MP1,NP1/Np2,KM/KN 都为 t/(1-t),是不是一下又明白了很多,其他阶的贝塞尔曲线也是这样的。

三阶贝塞尔曲线 有两个控制点,为 P1, P2,其参数方程为

下图是生成三阶贝塞尔曲线的动画:

贝塞尔曲线的更多介绍和动画请参考 http://bbs.csdn.net/topics/390358020

绘制二阶贝塞尔曲线使用 quadTo(),第一个参数是控制点,第二个参数是曲线的终点

void QPainterPath::quadTo(const QPointF &c, const QPointF &endPoint)

绘制三阶贝塞尔曲线使用 cubicTo(),第一个和第二个参数是控制点,第三个参数是曲线的终点

void QPainterPath::cubicTo(const QPointF &c1, const QPointF &c2, const QPointF &endPoint)

或许你会问:为什么只看到了控制点和终点,没有看到起点?这是因为 QPainterPath 默认的起点在 (0, 0),可以使用 moveTo() 改变起点,前一条线的终点就是下一条线的起点,结束亦是开始,人生亦是如此,生活处处皆道理,留心处处是学问,一花一世界,一叶一菩提,编程亦能悟乾坤。

这里展示一个小程序,QPainterPath 添加了三条贝塞尔曲线,每条曲线有两个控制点,如图显示的 0 到 5 个共 6 个控制点,拖动控制点就会改变它的坐标,然后生成新的贝塞尔曲线并显示出来,实时的看到变化的结果。通过拖动控制点,可以生成各种不同的平滑曲线,这就是贝塞尔曲线的魅力所在。

// 文件名:BezierCurveWidget.h
#ifndef BEZIERCURVEWIDGET_H
#define BEZIERCURVEWIDGET_H

#include <QWidget>
#include <QPointF>
#include <QList>
#include <QPainterPath>

class BezierCurveWidget : public QWidget {
    Q_OBJECT

public:
    explicit BezierCurveWidget(QWidget *parent = 0);
    ~BezierCurveWidget();

protected:
    void mousePressEvent(QMouseEvent *event) Q_DECL_OVERRIDE;
    void mouseMoveEvent(QMouseEvent *event) Q_DECL_OVERRIDE;
    void paintEvent(QPaintEvent *event) Q_DECL_OVERRIDE;

    /**
     * 使用 breakPoints 和 controlPoints 创建贝塞尔曲线
     * 当控制点的坐标变化后都重新生成一次贝塞尔曲线
     */
    QPainterPath createBezierCurve();

    /**
     * 创建显示控制点的圆的 bounding rectangle
     * @param index 控制点在 controlPoints 中的下标
     */
    QRect createControlPointBundingRect(int index);

    /**
     * 为了设置坐标方便,从 (0, 0) 开始设置而不是实际绘制的坐标,对绘制贝塞尔曲线和控制点的
     * 坐标系做了偏移,在计算的时候,坐标也要是相对于新坐标系的坐标才行,不能是原始的坐标,
     * 所以要对其也好做相应的偏移
     * @param point - 例如鼠标按下时在 widget 上的原始坐标
     */
    QPointF translatedPoint(const QPointF &point) const;

private:
    QPainterPath bezierCurve;       // 贝塞尔曲线
    QList<QPointF *> breakPoints;   // 贝塞尔曲线端点的坐标
    QList<QPointF *> controlPoints; // 贝塞尔曲线控制点的坐标
    int pressedControlPointIndex;   // 鼠标按住的控制点在 controlPoints 里的下标
    int controlPointRadius;         // 显示控制点的圆的半径

    int translatedX; // 坐标系 X 轴的偏移量
    int translatedY; // 坐标系 Y 轴的偏移量
    int flags;       // 文本显示的参数
};

#endif // BEZIERCURVEWIDGET_H


// 文件名:BezierCurveWidget.cpp
#include "BezierCurveWidget.h"
#include <QPainter>
#include <QMouseEvent>

BezierCurveWidget::BezierCurveWidget(QWidget *parent) : QWidget(parent) {
    // 贝塞尔曲线的端点
    breakPoints.append(new QPointF(0, 0));
    breakPoints.append(new QPointF(100, 100));
    breakPoints.append(new QPointF(200, 0));
    breakPoints.append(new QPointF(300, 100));

    // 第一段贝塞尔曲线控制点
    controlPoints.append(new QPointF(50, 0));
    controlPoints.append(new QPointF(50, 100));

    // 第二段贝塞尔曲线控制点
    controlPoints.append(new QPointF(150, 100));
    controlPoints.append(new QPointF(150, 0));

    // 第三段贝塞尔曲线控制点
    controlPoints.append(new QPointF(250, 0));
    controlPoints.append(new QPointF(250, 100));

    // 坐标设置好后就可以生成贝塞尔曲线了
    bezierCurve = createBezierCurve();

    controlPointRadius = 8;
    translatedX = 50;
    translatedY = 50;
    flags = Qt::AlignHCenter | Qt::AlignVCenter; // 水平和垂直居中
}

BezierCurveWidget::~BezierCurveWidget() {
    qDeleteAll(breakPoints);
    qDeleteAll(controlPoints);
}

void BezierCurveWidget::mousePressEvent(QMouseEvent *event) {
    pressedControlPointIndex = -1;

    // 绘制贝塞尔曲线和控制点的坐标系做了偏移,鼠标按下的坐标也要相应的偏移
    QPointF p = translatedPoint(event->pos());

    // 鼠标按下时,选择被按住的控制点
    for (int i = 0; i < controlPoints.size(); ++i) {
        QPainterPath path;
        path.addEllipse(*controlPoints.at(i), controlPointRadius, controlPointRadius);

        if (path.contains(p)) {
            pressedControlPointIndex = i;
            break;
        }
    }
}

void BezierCurveWidget::mouseMoveEvent(QMouseEvent *event) {
    // 移动选中的控制点
    if (pressedControlPointIndex != -1) {
        QPointF p = translatedPoint(event->pos());
        controlPoints.at(pressedControlPointIndex)->setX(p.x());
        controlPoints.at(pressedControlPointIndex)->setY(p.y());
        bezierCurve = createBezierCurve(); // 坐标发生变化后重新生成贝塞尔曲线
        update(); // 刷新界面
    }
}

QPainterPath BezierCurveWidget::createBezierCurve() {
    QPainterPath curve;
    curve.moveTo(*breakPoints.at(0));
    curve.cubicTo(*controlPoints[0], *controlPoints[1], *breakPoints[1]);
    curve.cubicTo(*controlPoints[2], *controlPoints[3], *breakPoints[2]);
    curve.cubicTo(*controlPoints[4], *controlPoints[5], *breakPoints[3]);

    return curve;
}

QRect BezierCurveWidget::createControlPointBundingRect(int index) {
    int x = controlPoints.at(index)->x() - controlPointRadius;
    int y = controlPoints.at(index)->y() - controlPointRadius;

    return QRect(x, y, controlPointRadius * 2, controlPointRadius * 2);
}

QPointF BezierCurveWidget::translatedPoint(const QPointF &point) const {
    return point - QPointF(translatedX, translatedY);
}

void BezierCurveWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(translatedX, translatedY);

    // 绘制贝塞尔曲线
    painter.drawPath(bezierCurve);

    // 绘制控制点和控制点的序号
    painter.setBrush(Qt::lightGray);
    for (int i = 0; i < controlPoints.size(); ++i) {
        QRect rect = createControlPointBundingRect(i);
        painter.drawEllipse(rect);
        painter.drawText(rect, flags, QString("%1").arg(i));
    }
}


// 文件名:main.cpp
#include "BezierCurveWidget.h"
#include <QApplication>

int main(int argc, char *argv[]) {
    QApplication a(argc, argv);
    BezierCurveWidget w;
    w.show();
    return a.exec();
}

这个程序有一个问题,不能像拖动控制点那样拖动曲线的端点改变贝塞尔曲线,这就作为留给大家的作业吧。

画笔 - QPen

QPen 用来绘制轮廓,先看个简单的例子:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(50, 50);

    QPainterPath path;
    path.lineTo(100, 100);
    path.quadTo(200, 100, 200, 0);

    QPen pen1(Qt::darkGray, 20, Qt::SolidLine, Qt::RoundCap, Qt::MiterJoin);
    painter.setPen(pen1);
    painter.drawPath(path);
    painter.drawRect(250, 0, 200, 100);

    // 自定义 dash pattern
    QPen pen2;
    QVector<qreal> dashes;
    qreal space = 4;
    dashes << 3 << space << 9 << space << 27 << space;
    pen2.setDashPattern(dashes);

    painter.translate(-30, -30);
    painter.setPen(pen2);
    painter.drawRect(0, 0, 510, 160);
}

可以设置画笔的粗细,颜色,填充方式(Brush),端点的样式(Cap Style),相交的样式(Join Style),Style(SolidLine, DashLine, DotLine 等)。

Pen Style 如图:

Cap Style 和 Join Style 如图所示:

其实我一直不明白设置画笔的宽度后,图形的边框是怎么计算的,是边框包围住整个图形还是边框整个在图形内部,直到看到下图时才明白(又是截图自 Qt 的帮助文档,里面真是包罗万象啊,希望大家多多阅读 Qt 的帮助文档),以矩形为例(逻辑上来说矩形是没有边框的):

就用程序来验证上面的说法吧,放大程序的截图,就能看到各边边框的宽度:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.translate(40, 40);

    painter.setPen(QPen(Qt::darkGray, 
                        31, // 边框宽 31 像素
                        Qt::SolidLine, 
                        Qt::SquareCap, 
                        Qt::MiterJoin));
    painter.drawRect(0, 0, 100, 100);

    // 设置边框宽 0 像素
    painter.setPen(Qt::NoPen);
    painter.setBrush(Qt::darkGray);
    painter.drawRect(150, 0, 100, 100);
}

还有一个问题,如果把边框的宽度设置为 0,是不是画出的图形的边框宽度就是 0,即看不到边框了呢?其实即使设置为 0,但是还是会有一个像素宽度的边框,只不过这个边框不会受 transformation 的影响,例如 scale,文档里是这么说的:

A line width of zero indicates a cosmetic pen. This means that the pen width is always drawn one pixel wide, independent of the transformation set on the painter.

边框的宽度设置为 0 和 1 在不使用 scale, shear 等 transform 的时候看上去是一样的,都是 1 个像素,例如有 scale 的时候,缩放后边宽为 0 的边框还是一个像素,边宽为 1 的边框根据缩放比例进行缩放:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.translate(20, 20);
    painter.scale(4, 4); // 水平和垂直方向都放大 4 倍

    painter.setPen(QPen(Qt::black, 0));
    painter.drawRect(0, 0, 40, 40); // 绘制出的矩形边框宽为 1

    painter.setPen(QPen(Qt::black, 1));
    painter.drawRect(50, 0, 40, 40); // 绘制出的矩形边框宽为 4
}

蚂蚁线 - QPen 自定义 style 的应用

QPen 已经提供了一些默认的 style,如 SolidLine, DashLine 等,但是满足不了所有的需求,所以还提供了自定义 style 的接口 QPen::setDashPattern(),其参数是一个 QVector,vector 中下标为偶数的位置存储 dash 的长度,奇数位置存储空白的长度,如 vector 的数据为 [3, 4, 9, 4](偶数个元素)表示:画线时以 3 个 dash 开始,接着是4 个空白,接下来是 9 个 dash,4 个空白,此时 vector 的元素已经用完,则从头开始使用 vector 的元素,接着画 3 个 dash,4 个空白,9 个 dash,4 个空白,依此类推。

动物的一种本能现象,领头的蚂蚁以随机的路线走向食物或洞穴,第二只蚂蚁紧跟其后以相同的路线行走,每一个后来的蚂蚁紧跟前面蚂蚁行走,排成一条线的现象。在图像影像软件中表示选区的动态虚线,因为虚线闪烁的样子像是一群蚂蚁在跑,所以俗称蚂蚁线。在 Photoshop 中建立选区后,选区的边线就叫蚂蚁线:

前面的例子中也使用了自定义 style,但有点简单,有没有复杂点的应用呢?下面我们就用自定义 style 实现蚂蚁线。

// 文件名:AnimationDashPatternWidget.h
#ifndef ANIMATIONDASHPATTERNWIDGET_H
#define ANIMATIONDASHPATTERNWIDGET_H

#include <QWidget>
#include <QVector>

class AnimationDashPatternWidget : public QWidget {
    Q_OBJECT

public:
    explicit AnimationDashPatternWidget(QWidget *parent = 0);
    ~AnimationDashPatternWidget();

protected:
    void timerEvent(QTimerEvent *event) Q_DECL_OVERRIDE;
    void paintEvent(QPaintEvent *event) Q_DECL_OVERRIDE;

private:
    void advanceDashes();

    int timerId;
    int dashes;
    int spaces;
    const int PATTERN_LENGTH;
    QVector<qreal> dashPattern;
};

#endif // ANIMATIONDASHPATTERNWIDGET_H


// 文件名:AnimationDashPatternWidget.cpp
#include "AnimationDashPatternWidget.h"
#include <QTimerEvent>
#include <QPainter>
#include <QPen>
#include <QPainterPath>

AnimationDashPatternWidget::AnimationDashPatternWidget(QWidget *parent) :
    QWidget(parent), PATTERN_LENGTH(4) {
    dashes = PATTERN_LENGTH;
    spaces = PATTERN_LENGTH;

    for (int i = 0; i < 400; ++i) {
        dashPattern << PATTERN_LENGTH;
    }

    timerId = startTimer(150);
}

AnimationDashPatternWidget::~AnimationDashPatternWidget() {
}

void AnimationDashPatternWidget::timerEvent(QTimerEvent *event) {
    if (event->timerId() == timerId) {
        advanceDashes();
        update(); // 更好的方式是更新蚂蚁线的所在的范围,而不是整个界面都刷新,用 update(rect)
    }
}

void AnimationDashPatternWidget::advanceDashes() {
    if (PATTERN_LENGTH == dashes && PATTERN_LENGTH == spaces) {
        dashes = 0;
        spaces = 0;
    }

    if (dashes == 0 && spaces < PATTERN_LENGTH) {
        ++spaces;
    } else if (dashes < PATTERN_LENGTH && PATTERN_LENGTH == spaces) {
        ++dashes;
    }

    dashPattern[0] = dashes;
    dashPattern[1] = spaces;
}

void AnimationDashPatternWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QPen pen;
    pen.setDashPattern(dashPattern); // 蚂蚁线的 style

    painter.translate(20, 20);
    painter.setPen(Qt::white);
    painter.drawRect(0, 0, 100, 100);
    painter.setPen(pen);
    painter.drawRect(0, 0, 100, 100);

    QPainterPath path;
    path.cubicTo(50, 0, 50, 100, 100, 100);
    path.cubicTo(150, 0, 150, 100, 200, 0);

    painter.translate(120, 0);
    painter.setPen(Qt::white);
    painter.drawPath(path);

    painter.setPen(pen);
    painter.drawPath(path);
}

比较难以理解的就是 advanceDashes() 的算法,观察蚂蚁线的运动,发现如下规律:

  • 第一个数字是开始的 dash 长度
  • 第二个数字是 dash 后面跟着的 space 的长度
  • 第三个数是 dash 的长度
  • 第四个数是 space 的长度
  • 第五个数是 dash 的长度
  • 第六个数是 space 的长度
  • ……
 ...............
 0 4 4 4 ... 4 4
 1 4 4 4 ... 4 4
 2 4 4 4 ... 4 4
 3 4 4 4 ... 4 4
 4 4 4 4 ... 4 4
 0 1 4 4 ... 4 4
 0 2 4 4 ... 4 4
 0 3 4 4 ... 4 4
 0 4 4 4 ... 4 4
 1 4 4 4 ... 4 4
 ...............

只有第一个和第二个数字是变化的:

  • 当第一个数字是 0 时,第二个数字从 0 递增到 4
  • 当第二个数字是 4 时,第一个数字从 0 递增到 4
  • 当第一个和第二个数字都是 4 时,设置它们为 0,然后就会重复上面的步骤,实现了动画效果

程序运行后就像下图这样,蚂蚁线会不停的运动:

画刷 - QBrush

QBrush 是用来填充图形用的.

The QBrush class defines the fill pattern of shapes drawn by QPainter.
A brush has a style, a color, a gradient and a texture.

下图在来自 QBrush 的帮助文档内容,列出了 Qt 自带的 brush:

上面绘制文字的路径的例子中用了线性渐变的填充方式,平铺绘制 QPixmap 使用了 QPainter::drawTiledPixmap(),也可以像下面这样使用 texture pattern 实现平铺绘制:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    QPixmap pixmap(":/resources/Paint-Base-Bufferfly.png");
    QBrush brush(pixmap);
    painter.setBrush(brush);
    painter.drawRect(0, 0, width(), height());
}

渐变有三种:QLinearGradient, QConicalGradient and QRadialGradient,接下来将介绍它们的使用和实现原理。

QLinearGradient

QLinearGradient 是线性渐变,也就是颜色的各个分量(red, green, blue)在两点之间的变化是线性的,需要设置渐变的起始和结束坐标、颜色,超出渐变范围的填充方式,它并不能单独的使用,而是要和 QBrush 一起使用实现填充效果,主要有以下一些函数:

// 创建 QLinearGradient,同时设置起始和结束坐标
QLinearGradient(const QPointF &start, const QPointF &finalStop)
QLinearGradient(qreal x1, qreal y1, qreal x2, qreal y2)

// 设置渐变的颜色,position 的取值范围是 [0.0, 1.0]
setColorAt(qreal position, const QColor &color)

// 超出渐变范围后的填充方式,默认使用 PadSpread: 
//     QGradient::PadSpread
//     QGradient::RepeatSpread
//     QGradient::ReflectSpread
void setSpread(Spread method)

// 使用渐变创建画刷
QBrush(const QGradient &gradient)

下图来自 QLinearGradient 的帮助文档,两个灰色的点表示渐变的起始和结束位置,从黄色渐变到有点发灰的黄色,同时展示了超出渐变范围时的三种填充方式:

为了介绍 QLinearGradient 的使用,下面的程序使用线性渐变,在垂直方向从红色渐变到蓝色,填充矩形 QRect(20, 20, 200, 200):

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    QRect rect(20, 20, 200, 200);

    // 渐变开始的坐标为 (20, 20), 结束的坐标为 (20, 220)
    QLinearGradient gradient(rect.x(), rect.y(),
                             rect.x(), rect.y() + rect.height());
    gradient.setColorAt(0.0, Qt::red);
    gradient.setColorAt(1.0, Qt::blue);
    // 超出渐变范围后的填充方式
    gradient.setSpread(QGradient::ReflectSpread);

    painter.setPen(Qt::NoPen);
    painter.setBrush(gradient); // QBrush(const QGradient &gradient)
    painter.drawRect(rect);
}

如果不用 QLinearGradient,怎么实现上面的渐变效果呢?也既是线性渐变的原理是什么呢?
以求线段上任意点的坐标为例,如图,已知线段的两端点 A(x1, y1),B(x2, y2),求线段上任意一点 M 的坐标 (x, y),则

根据两点的距离公式可以求出线段的长度 |AB|(用 || 表示线段的长度)
t = |AM| / |AB|;
因为 |AM| >= 0 且 |AM| <= |AB|,所以 t 的值为 [0.0, 1.0],用 length 表示 |AB|,则
x = x1 + t * length
y = y1 + t * length

t 为 0.0 时 M 和 A 重合,t 为 1.0 时 M 和 B 重合。
因为 t 的值为 0 到 1 之间,所以可以用循环求出 AB 上任意点的坐标 

for (float t = 0.0; rate <= 1.0; t += 0.1) {
    x = x1 + t * length;
    y = y1 + t * length;
}

其实这就是线段的参数方程。

上面可以理解为坐标的渐变,变化的是坐标的 x, y 分量,颜色的渐变理论上也是一样的,只不过要变化的是颜色的 R, G, B 三个分量。如果同时已知点 A,B 的坐标和颜色 (r1, g1, b1), (r2, g2, b2),那么 M 点的坐标和颜色为:

for (float t = 0.0; rate <= 1.0; t += 0.1) {
    x = x1 + t * length;
    y = y1 + t * length;
    
    r = r1 + t * (r2-r1);
    g = g1 + t * (g2-g1);
    b = b1 + t * (b2-b1);
}

也既是说,如果知道某个点对应的 t,那么就能计算出此点的颜色。如下图,要在矩形内沿着 AB 进行渐变填充,已知点 A,B 的坐标和颜色,在矩形内任意一点 N 的坐标也是已知的(循环遍历矩形内所有的点),那么就可以求出点 N 在 AB 上的投影 M(MN 垂直于 AB),t=|AM|/|AB|,使用上面的方法求出点 M 的颜色,点 M 的颜色就是点 N 的颜色。

对于下图垂直方向的渐变来说,点 A(x1, y1) 为矩形的左上角,点 B(x2, y2) 为矩形的坐下角,矩形内任意一点 N(x, y) 在 AB 上的投影 M 的坐标为 (x1, y),所以 t = (y-y1)/(y2-y1),知道了 t,那么就能计算出对应的颜色了。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 渐变填充的矩形
    QRect rect(20, 20, 200, 200);

    // 渐变开始和结束的颜色
    QColor gradientStartColor(255, 0, 0);
    QColor gradientFinalColor(0, 0, 255);

    int r1, g1, b1, r2, g2, b2;
    gradientStartColor.getRgb(&r1, &g1, &b1);
    gradientFinalColor.getRgb(&r2, &g2, &b2);

    qreal y1 = rect.y();
    qreal y2 = rect.y() + rect.height();

    // 计算矩形中每一个点的颜色,然后用此颜色绘制这个点
    for (int x = rect.x(); x <= rect.x() + rect.width(); ++x) {
        for (int y = rect.y(); y <= rect.y() + rect.height(); ++y) {
            qreal t = (y-y1) / (y2-y1);
            t = qMax(0.0, qMin(t, 1.0));

            int r = r1 + t * (r2-r1);
            int g = g1 + t * (g2-g1);
            int b = b1 + t * (b2-b1);

            painter.setPen(QColor(r, g, b));
            painter.drawPoint(x, y);
        }
    }
}

运行程序,看看效果是不是和使用 QLinearGradient 的一样?

对于下图这样指定渐变的开始和结束位置,非垂直和水平方向渐变的实现,关键是求任意一点在另一条线上的投影,有很多方法和公式可以使用,这里我们使用 QTransform 进行移动,旋转求出 t,计算出对应的颜色,由于 QTransform 的知识比较复杂,这里就不作深入介绍,有兴趣的可以自行查看相关文档。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 渐变填充的矩形
    QRect rect(20, 20, 200, 200);

    // 渐变开始和结束的颜色、位置
    QColor gradientStartColor(255, 0, 0);
    QColor gradientFinalColor(0, 0, 255);
    QPoint gradientStartPoint(60, 60);
    QPoint gradientFinalPoint(180, 180);

    // 颜色分量
    int r1, g1, b1, r2, g2, b2;
    gradientStartColor.getRgb(&r1, &g1, &b1);
    gradientFinalColor.getRgb(&r2, &g2, &b2);

    qreal dx = gradientFinalPoint.x() - gradientStartPoint.x();
    qreal dy = gradientFinalPoint.y() - gradientStartPoint.y();
    qreal length = qSqrt(dx*dx + dy*dy); // 渐变开始和结束的线段的长度
    float radian = qAtan2(dy, dx); // 渐变方向和 X 轴的夹角

    // 先移动,后旋转,要先调用旋转的函数,然后在调用移动的函数,一定要注意这点,
    // 因为底层实现是 matrix 矩阵右乘点的坐标的列矩阵
    QTransform transform;
    transform.rotateRadians(-radian);
    transform.translate(-gradientStartPoint.x(), -gradientStartPoint.y());

    // 计算矩形中每一个点的颜色,然后用此颜色绘制这个点
    for (int x = rect.x(); x <= rect.x() + rect.width(); ++x) {
        for (int y = rect.y(); y <= rect.y() + rect.height(); ++y) {
            QPointF p = transform.map(QPointF(x ,y));
            qreal t = p.x() / length;
            t = qMax(0.0, qMin(t, 1.0));

            int r = r1 + t * (r2-r1);
            int g = g1 + t * (g2-g1);
            int b = b1 + t * (b2-b1);

            painter.setPen(QColor(r, g, b));
            painter.drawPoint(x, y);
        }
    }
}

t<0 或 t>1 时,即超出渐变范围后的填充方式是需要考虑的,我们这里的实现就是 PadSpread 的方式,怎么实现 RepeatSpread 和 ReflectSpread 的渐变呢?这个就作为大家的作业吧。

QRadialGradient

QRadialGradient 名为 径向渐变,在圆的范围内进行渐变,有三个主要参数:圆心、半径、焦点:

QRadialGradient(const QPointF &center, qreal radius, 
                const QPointF &focalPoint)
QRadialGradient(const QPointF & center, qreal radius)

圆心和半径确定颜色渐变的范围,焦点是渐变开始的点,渐变结束的点在圆周上。很多人都认为径向渐变是从圆心开始渐变的,其实不是这样的,只不过焦点和圆心默认是在同一个位置,所以看上去渐变好像是从圆心开始。

如图,我们故意设置圆心和焦点不在同一个位置,这样就能很明显的看到渐变的范围,开始和结束的位置,连接焦点和圆周的线上的点的颜色做线性渐变(是不是知道怎么实现 QRadialGradient 了?)。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(width() / 2, height() / 2);

    qreal radius = 150;    // 半径
    QPointF center(0, 0);  // 圆心
    QPointF focus(80, 30); // 焦点

    // 径向渐变
    QRadialGradient gradient(center, radius, focus);
    gradient.setColorAt(0.0, Qt::red);
    gradient.setColorAt(1.0, Qt::blue);

    // 径向渐变填充圆
    painter.setPen(Qt::darkGray);
    painter.setBrush(gradient);
    painter.drawEllipse(center, radius, radius);

    // 绘制圆心和焦点
    painter.setBrush(Qt::gray);
    painter.drawEllipse(center, 4, 4);
    painter.drawEllipse(focus, 4, 4);
}

QConicalGradient

QConicalGradient 名为 角度渐变,只需要指定渐变的中心和开始的角度:

QConicalGradient(const QPointF &center, qreal angle)
QConicalGradient(qreal cx, qreal cy, qreal angle)

经过线性渐变和径向渐变的学习,相信现在大家都能很容易的推断得出角度渐变的原理,这里就不作解释,作为悬念留给大家吧。

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);
    painter.translate(width() / 2, height() / 2);

    qreal startAngle = 45; // 渐变开始的角度
    QPointF center(0, 0);  // 渐变的中心

    QConicalGradient gradient(center, startAngle);
    gradient.setColorAt(0.0, Qt::red);
    gradient.setColorAt(0.33, Qt::green);
    gradient.setColorAt(0.66, Qt::blue);
    gradient.setColorAt(1.0, Qt::red);

    painter.setPen(Qt::darkGray);
    painter.setBrush(gradient);
    painter.drawEllipse(center, 150, 150);
}

QPainter 的 save() & restore()

void QPainter::save() - Saves the current painter state (pushes the state onto a stack). A save() must be followed by a corresponding restore().

void QPainter::restore() - Restores the current painter state (pops a saved state off the stack).

save() 用于保存 QPainter 的状态,restore() 用于恢复 QPainter 的状态,save() 和 restore() 一般都是成对使用的,如果只调用了 save() 而不调用 restore(),那么保存就没有意义了,保存是为了能恢复被保存的状态而使用的。QPainter 的状态有画笔,画刷,字体,变换(旋转,移动,切变,缩放)等。

使用 save() 和 restore() 有什么好处呢?例如我们要写这么一个程序,把 QPainter 的坐标中心从左上角移动到 (100, 100),然后画出坐标轴,接下来顺时针选择坐标轴 45 度,设置画笔,画刷,字体,画一个矩形和字符串,最后恢复 QPainter 到最开始的状态,再画一个矩形和字符串,就像下图这样:

如果我们不知道 QPainter 提供了 save() 和 restore() 来保存和恢复它的状态,那么就只好定义变量一个一个的保存好所有的状态,需要的时候再逐一恢复:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);

    QPen pen = painter.pen();
    QBrush brush = painter.brush();
    QFont font = painter.font();

    // 移动坐标轴
    painter.translate(100, 100);

    // 绘制坐标轴所在的线
    painter.drawLine(-100, 0, 100, 0);
    painter.drawLine(0, -100, 0, 100);

    painter.rotate(45);
    painter.setPen(Qt::red);
    painter.setBrush(Qt::blue);
    painter.setFont(QFont("Monaco", 30));

    painter.drawRect(-50, -50, 100, 100);
    painter.drawText(0, 0, "Hello");

    // 恢复 QPainter 的状态
    painter.rotate(-45);
    painter.translate(-100, -100);
    painter.setFont(font);
    painter.setPen(pen);
    painter.setBrush(brush);

    painter.drawRect(250, 50, 100, 100);
    painter.drawText(250, 50, "Hello");
}

不过,现在我们知道了 save() 和 restore(),就不需要像上面这样一个一个的保存和恢复 QPainter 的状态了,调用一下 save() 所有的状态就保存好了,是不是比使用那么多变量犀利了很多?需要恢复的时候只要调用 restore() 所有保存好的状态都恢复回来了,实现同样的功能,代码简洁了很多,也不会不小心漏掉某些状态而出错:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);

    // 保存 QPainter 的状态
    painter.save();
    painter.translate(100, 100);

    // 绘制坐标轴所在的线
    painter.drawLine(-100, 0, 100, 0);
    painter.drawLine(0, -100, 0, 100);

    painter.rotate(45);
    painter.setPen(Qt::red);
    painter.setBrush(Qt::blue);
    painter.setFont(QFont("Monaco", 30));
    
    painter.drawRect(-50, -50, 100, 100);
    painter.drawText(0, 0, "Hello");

    // 恢复 QPainter 的状态
    painter.restore();

    painter.drawRect(250, 50, 100, 100);
    painter.drawText(250, 50, "Hello");
}

此外,save() 和 restore() 可以调用多个的,是以堆栈的形式保存和恢复的,最后保存的先恢复:

void MainWidget::paintEvent(QPaintEvent *) {
    QPainter painter(this);

    painter.save(); // 保存状态 1
    ...
    painter.save(); // 保存状态 2
    ...
    painter.save(); // 保存状态 3

    painter.restore(); // 恢复状态 3
    ...
    painter.restore(); // 恢复状态 2
    ...
    painter.restore(); // 恢复状态 1
}

猜你喜欢

转载自blog.csdn.net/p942005405/article/details/79891773