涂鸦示例

涂鸦示例展示了如何重新实现一些QWidget的事件处理器以接收为应用小部件生成的事件。

我们重新实现了鼠标事件处理器以实现绘图、重绘事件处理器以更新应用以及调整大小事件处理器以优化应用的显示效果。此外,我们还重新实现了关闭事件处理器以在终止应用之前拦截关闭事件。

该示例还演示了如何使用QPainter实时绘制图像以及重绘小部件。

Screenshot of the Scribble example

使用涂鸦应用,用户可以绘制图像。文件的菜单让用户能够打开和编辑现有图像文件、保存图像并退出应用。在绘图过程中,选项菜单允许用户选择笔的颜色和粗细,以及清除屏幕。此外,帮助菜单提供关于涂鸦示例特别是以及 Qt 一般的信息。

示例包括两个类

  • ScribbleArea 是一个自定义小部件,它显示了一个 QImage 并允许用户在上面绘图。
  • MainWindowScribbleArea 之上提供了一个菜单。

我们将首先回顾 ScribbleArea 类。然后,我们将回顾使用 ScribbleAreaMainWindow 类。

ScribbleArea 类定义

class ScribbleArea : public QWidget
{
    Q_OBJECT

public:
    ScribbleArea(QWidget *parent = nullptr);

    bool openImage(const QString &fileName);
    bool saveImage(const QString &fileName, const char *fileFormat);
    void setPenColor(const QColor &newColor);
    void setPenWidth(int newWidth);

    bool isModified() const { return modified; }
    QColor penColor() const { return myPenColor; }
    int penWidth() const { return myPenWidth; }

public slots:
    void clearImage();
    void print();

protected:
    void mousePressEvent(QMouseEvent *event) override;
    void mouseMoveEvent(QMouseEvent *event) override;
    void mouseReleaseEvent(QMouseEvent *event) override;
    void paintEvent(QPaintEvent *event) override;
    void resizeEvent(QResizeEvent *event) override;

private:
    void drawLineTo(const QPoint &endPoint);
    void resizeImage(QImage *image, const QSize &newSize);

    bool modified = false;
    bool scribbling = false;
    int myPenWidth = 1;
    QColor myPenColor = Qt::blue;
    QImage image;
    QPoint lastPoint;
};

ScribbleArea 类继承自 QWidget。我们重新实现了绘制所需 mousePressEvent()mouseMoveEvent()mouseReleaseEvent() 函数。我们还重新实现了 paintEvent() 函数以更新书写区域,以及 resizeEvent() 函数以确保我们在上面绘制的 QImage 在任何时刻都至少与小部件大小相同。

我们需要几个公共函数:openImage() 将从文件中加载图像到书写区域,允许用户编辑图像;save() 将当前显示的图像写入文件;clearImage() 插槽清除书写区域中显示的图像。我们需要实际的绘图所需的私有函数 drawLineTo() 和更改 QImage 大小的 resizeImage()。我们还需要处理打印的 print() 插槽。

我们还需要以下以下私有变量

  • modified 如果书写区域中显示的图像有未保存的更改,则为 true
  • scribbling 当用户在书写区域内按下左鼠标按钮时为 true
  • penWidthpenColor 分别保存应用中当前设置的笔宽和颜色。
  • image 存储用户绘制的图像。
  • lastPoint 保存上次鼠标按下或鼠标移动事件时的鼠标位置。

绘图区域类实现

ScribbleArea::ScribbleArea(QWidget *parent)
    : QWidget(parent)
{
    setAttribute(Qt::WA_StaticContents);
}

在构造函数中,我们为小部件设置了 Qt::WA_StaticContents 属性,表示小部件的内容根植于左上角,并在小部件大小调整时不会改变。Qt 使用该属性来优化调整大小时的绘制事件。这纯粹是一个优化,并且仅应用于内容静态且根植于左上角的小部件。

bool ScribbleArea::openImage(const QString &fileName)
{
    QImage loadedImage;
    if (!loadedImage.load(fileName))
        return false;

    QSize newSize = loadedImage.size().expandedTo(size());
    resizeImage(&loadedImage, newSize);
    image = loadedImage;
    modified = false;
    update();
    return true;
}

openImage() 函数中,我们加载指定的图像。然后,我们使用私有的 resizeImage() 函数将加载的 QImage 调整大小,使其在两个方向上都至少与窗口一样大,并将 image 成员变量设置为加载的图像。最后,我们调用 QWidget::update() 来安排重绘。

bool ScribbleArea::saveImage(const QString &fileName, const char *fileFormat)
{
    QImage visibleImage = image;
    resizeImage(&visibleImage, size());

    if (visibleImage.save(fileName, fileFormat)) {
        modified = false;
        return true;
    }
    return false;
}

saveImage() 函数创建一个 QImage 对象,该对象仅覆盖实际 image 的可见部分,并使用 QImage::save() 保存它。如果图像保存成功,我们将绘图区域的 modified 变量设置为 false,因为没有未保存的数据。

void ScribbleArea::setPenColor(const QColor &newColor)
{
    myPenColor = newColor;
}

void ScribbleArea::setPenWidth(int newWidth)
{
    myPenWidth = newWidth;
}

setPenColor()setPenWidth() 函数设置当前笔颜色和宽度。这些值将用于未来的绘图操作。

void ScribbleArea::clearImage()
{
    image.fill(qRgb(255, 255, 255));
    modified = true;
    update();
}

公共的 clearImage() 插槽清除绘图区域显示的图像。我们只需用白色填充整个图像,这对应于 RGB 值 (255, 255, 255)。就像我们修改图像一样,我们将 modified 设置为 true 并安排重绘。

void ScribbleArea::mousePressEvent(QMouseEvent *event)
{
    if (event->button() == Qt::LeftButton) {
        lastPoint = event->position().toPoint();
        scribbling = true;
    }
}

void ScribbleArea::mouseMoveEvent(QMouseEvent *event)
{
    if ((event->buttons() & Qt::LeftButton) && scribbling)
        drawLineTo(event->position().toPoint());
}

void ScribbleArea::mouseReleaseEvent(QMouseEvent *event)
{
    if (event->button() == Qt::LeftButton && scribbling) {
        drawLineTo(event->position().toPoint());
        scribbling = false;
    }
}

对于鼠标按下和释放事件,我们使用 QMouseEvent::button() 函数来找出导致事件的哪个按钮。对于鼠标移动事件,我们使用 QMouseEvent::buttons() 来找出当前按下的哪个按钮(作为一个或组合)。

如果用户按下左鼠标按钮,我们将鼠标指针位置存储在 lastPoint 中。我们还记录用户目前正在绘制。(scribbling 变量是必要的,因为我们不能假设鼠标移动和鼠标释放事件总是由同一窗口上的鼠标按下事件引起的。)

如果用户按下鼠标左键移动鼠标或释放鼠标按钮,我们调用私有的 drawLineTo() 函数来绘制。

void ScribbleArea::paintEvent(QPaintEvent *event)
{
    QPainter painter(this);
    QRect dirtyRect = event->rect();
    painter.drawImage(dirtyRect, image, dirtyRect);
}

在重实现 paintEvent() 函数时,我们只需为绘图区域创建一个 QPainter,并绘制图像。

此时,您可能想知道为什么我们不在小部件上直接绘制,而不是在 QImage 中绘制并在 paintEvent() 中将 QImage 复制到屏幕上。至少有三个很好的理由

  • 窗口系统要求我们能够在任何时候重新绘制小部件。例如,如果窗口被最小化和恢复,窗口系统可能会忘记窗口的内容并向我们发送绘制事件。换句话说,我们不能依赖于窗口系统来记住我们的图像。
  • Qt通常不允许我们在paintEvent()之外进行绘制。特别是,我们不能在鼠标事件处理程序中进行绘制。(不过,可以通过使用Qt::WA_PaintOnScreen小部件属性来更改这种行为。)
  • 如果正确初始化,QImage保证每个颜色通道(红色、绿色、蓝色和alpha)都使用8位,而QWidget的颜色深度可能更低,这取决于监控器的配置。这意味着如果我们加载24位或32位的图像并将其绘制到QWidget上,然后再将QWidget复制到QImage中,我们可能会丢失一些信息。
void ScribbleArea::resizeEvent(QResizeEvent *event)
{
    if (width() > image.width() || height() > image.height()) {
        int newWidth = qMax(width() + 128, image.width());
        int newHeight = qMax(height() + 128, image.height());
        resizeImage(&image, QSize(newWidth, newHeight));
        update();
    }
    QWidget::resizeEvent(event);
}

当用户启动Scribble应用程序时,会生成一个调整大小事件,然后创建并显示在涂鸦区域中的图像。我们使这个初始图像比应用程序的主窗口和涂鸦区域稍大,以避免在用户调整主窗口大小时总是调整图像(这将会非常低效)。但当主窗口变得比这个初始大小大时,就必须要调整图像大小。

void ScribbleArea::drawLineTo(const QPoint &endPoint)
{
    QPainter painter(&image);
    painter.setPen(QPen(myPenColor, myPenWidth, Qt::SolidLine, Qt::RoundCap,
                        Qt::RoundJoin));
    painter.drawLine(lastPoint, endPoint);
    modified = true;

    int rad = (myPenWidth / 2) + 2;
    update(QRect(lastPoint, endPoint).normalized()
                                     .adjusted(-rad, -rad, +rad, +rad));
    lastPoint = endPoint;
}

drawLineTo()中,我们从最后一次鼠标按下或鼠标移动时的鼠标位置绘制线条,将modified设置为true,生成一个重绘事件,并更新lastPoint,这样在下一次调用drawLineTo()时,我们可以从上次离开的地方继续绘制。

我们可以不带参数调用update()函数,但作为一个简单的优化,我们传入一个QRect,该矩形指定需要更新的涂鸦区域内的矩形,以避免对小部件进行完整的重绘。

void ScribbleArea::resizeImage(QImage *image, const QSize &newSize)
{
    if (image->size() == newSize)
        return;

    QImage newImage(newSize, QImage::Format_RGB32);
    newImage.fill(qRgb(255, 255, 255));
    QPainter painter(&newImage);
    painter.drawImage(QPoint(0, 0), *image);
    *image = newImage;
}

QImage没有很好的API来进行图像缩放。有一个QImage::copy()函数可以做到这一点,但用于扩展图像时,它会用黑色填充新区域,而我们需要白色。

因此,技巧是创建一个正确尺寸的新QImage,将其填充为白色,并使用QPainter在它上面绘制旧图像。新的图像被赋予QImage::Format_RGB32格式,这意味着每个像素都存储为0xffRRGGBB(其中RR、GG和BB是红、绿和蓝颜色通道,ff是十六进制值255)。

打印由print()槽位处理

void ScribbleArea::print()
{
#if defined(QT_PRINTSUPPORT_LIB) && QT_CONFIG(printdialog)
    QPrinter printer(QPrinter::HighResolution);

    QPrintDialog printDialog(&printer, this);

我们使用QPrintDialog来构造所需输出格式的高分辨率QPrinter对象,允许用户指定页面大小并指示如何在页面上格式化输出。

如果对话框被接受,我们执行将任务打印到绘画设备

    if (printDialog.exec() == QDialog::Accepted) {
        QPainter painter(&printer);
        QRect rect = painter.viewport();
        QSize size = image.size();
        size.scale(rect.size(), Qt::KeepAspectRatio);
        painter.setViewport(rect.x(), rect.y(), size.width(), size.height());
        painter.setWindow(image.rect());
        painter.drawImage(0, 0, image);
    }
#endif // QT_CONFIG(printdialog)
}

以这种方式将图像打印到文件只是一个在QPrinter上绘制的问题。我们在绘制到绘画设备前将图像缩放以适合页面上的可用空间。

MainWindow类定义

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);

protected:
    void closeEvent(QCloseEvent *event) override;

private slots:
    void open();
    void save();
    void penColor();
    void penWidth();
    void about();

private:
    void createActions();
    void createMenus();
    bool maybeSave();
    bool saveFile(const QByteArray &fileFormat);

    ScribbleArea *scribbleArea;

    QMenu *saveAsMenu;
    QMenu *fileMenu;
    QMenu *optionMenu;
    QMenu *helpMenu;

    QAction *openAct;
    QList<QAction *> saveAsActs;
    QAction *exitAct;
    QAction *penColorAct;
    QAction *penWidthAct;
    QAction *printAct;
    QAction *clearScreenAct;
    QAction *aboutAct;
    QAction *aboutQtAct;
};

MainWindow类从QMainWindow继承。我们重新实现了来自QWidgetcloseEvent()处理器。函数open()save()penColor()penWidth()对应于菜单项。此外,我们创建了四个私有函数。

我们使用布尔maybeSave()函数来检查是否有任何未保存的更改。如果有未保存的更改,我们允许用户保存这些更改。如果用户点击取消,函数返回false。我们使用saveFile()函数允许用户将显示在涂鸦区域中的图像保存。

MainWindow类实现

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent), scribbleArea(new ScribbleArea(this))
{
    setCentralWidget(scribbleArea);

    createActions();
    createMenus();

    setWindowTitle(tr("Scribble"));
    resize(500, 500);
}

在构造函数中,我们创建了一个涂鸦区域,并将其作为MainWindow小部件的中心小部件。然后我们创建了相关的操作和菜单。

void MainWindow::closeEvent(QCloseEvent *event)
{
    if (maybeSave())
        event->accept();
    else
        event->ignore();
}

关闭事件被发送到用户想要关闭的小部件,通常是通过点击文件|退出或点击标题栏上的X按钮。通过重新实现事件处理程序,我们可以拦截尝试关闭应用程序的尝试。

在此示例中,我们使用关闭事件来询问用户是否保存未保存的更改。该逻辑位于maybeSave()函数中。如果maybeSave()返回true,则没有修改或用户成功保存了它们,我们接受事件。然后应用程序可以正常终止。如果maybeSave()返回false,则用户点击了取消,所以我们“忽略”了该事件,这样应用程序就不会受到影响。

void MainWindow::open()
{
    if (maybeSave()) {
        QString fileName = QFileDialog::getOpenFileName(this,
                                   tr("Open File"), QDir::currentPath());
        if (!fileName.isEmpty())
            scribbleArea->openImage(fileName);
    }
}

open()槽中,我们首先允许用户保存当前显示的图像的任何修改,然后在新图像加载到涂鸦区域之前。然后我们要求用户选择一个文件,并将文件加载到ScribbleArea

void MainWindow::save()
{
    QAction *action = qobject_cast<QAction *>(sender());
    QByteArray fileFormat = action->data().toByteArray();
    saveFile(fileFormat);
}

当用户选择另存为菜单项,并从格式菜单中选择一个选项时,会调用save()槽。首先我们需要做的是使用QObject::sender找出发送信号的操作。此函数返回发送方的QObject指针。由于我们知道发送方是一个操作对象,我们可以安全地进行QObject的强制类型转换。我们可以使用C式强制类型转换或C++ static_cast<>(),但作为防御性编程技术,我们使用qobject_cast。优点是如果对象具有错误类型,则返回空指针。空指针崩溃比不安全的强制类型转换崩溃更容易诊断。

一旦我们有了操作,我们使用QAction::data()获取所选的格式。(当操作创建时,我们使用QAction::setData()来设置随操作附加的自定义数据,该数据作为QVariant。当我们在createActions()中回顾时,对此有更多了解。)

现在我们知道格式了,我们调用私有saveFile()函数来保存当前显示的图像。

void MainWindow::penColor()
{
    QColor newColor = QColorDialog::getColor(scribbleArea->penColor());
    if (newColor.isValid())
        scribbleArea->setPenColor(newColor);
}

我们使用penColor()槽通过QColorDialog从用户那里获取一种新颜色。如果用户选择了一种新颜色,我们就将其作为涂鸦区域的颜色。

void MainWindow::penWidth()
{
    bool ok;
    int newWidth = QInputDialog::getInt(this, tr("Scribble"),
                                        tr("Select pen width:"),
                                        scribbleArea->penWidth(),
                                        1, 50, 1, &ok);
    if (ok)
        scribbleArea->setPenWidth(newWidth);
}

penWidth()槽中检索新的笔宽时,我们使用QInputDialogQInputDialog类提供了一个简单的方便对话框,以从用户那里获取单个值。我们使用静态QInputDialog::getInt()函数,它结合了一个QLabel和一个QSpinBoxQSpinBox初始化了涂鸦区域的笔宽,范围从1到50,步长为1(这意味着上下箭头会增加或减少该值1)。

布尔变量ok将在用户点击确定时设置为true,在用户按取消时设置为false

void MainWindow::about()
{
    QMessageBox::about(this, tr("About Scribble"),
            tr("<p>The <b>Scribble</b> example shows how to use QMainWindow as the "
               "base widget for an application, and how to reimplement some of "
               "QWidget's event handlers to receive the events generated for "
               "the application's widgets:</p><p> We reimplement the mouse event "
               "handlers to facilitate drawing, the paint event handler to "
               "update the application and the resize event handler to optimize "
               "the application's appearance. In addition we reimplement the "
               "close event handler to intercept the close events before "
               "terminating the application.</p><p> The example also demonstrates "
               "how to use QPainter to draw an image in real time, as well as "
               "to repaint widgets.</p>"));
}

我们实现了about()槽来创建一个消息框,描述示例设计用来展示的内容。

void MainWindow::createActions()
{
    openAct = new QAction(tr("&Open..."), this);
    openAct->setShortcuts(QKeySequence::Open);
    connect(openAct, &QAction::triggered, this, &MainWindow::open);

    const QList<QByteArray> imageFormats = QImageWriter::supportedImageFormats();
    for (const QByteArray &format : imageFormats) {
        QString text = tr("%1...").arg(QString::fromLatin1(format).toUpper());

        QAction *action = new QAction(text, this);
        action->setData(format);
        connect(action, &QAction::triggered, this, &MainWindow::save);
        saveAsActs.append(action);
    }

    printAct = new QAction(tr("&Print..."), this);
    connect(printAct, &QAction::triggered, scribbleArea, &ScribbleArea::print);

    exitAct = new QAction(tr("E&xit"), this);
    exitAct->setShortcuts(QKeySequence::Quit);
    connect(exitAct, &QAction::triggered, this, &MainWindow::close);

    penColorAct = new QAction(tr("&Pen Color..."), this);
    connect(penColorAct, &QAction::triggered, this, &MainWindow::penColor);

    penWidthAct = new QAction(tr("Pen &Width..."), this);
    connect(penWidthAct, &QAction::triggered, this, &MainWindow::penWidth);

    clearScreenAct = new QAction(tr("&Clear Screen"), this);
    clearScreenAct->setShortcut(tr("Ctrl+L"));
    connect(clearScreenAct, &QAction::triggered,
            scribbleArea, &ScribbleArea::clearImage);

    aboutAct = new QAction(tr("&About"), this);
    connect(aboutAct, &QAction::triggered, this, &MainWindow::about);

    aboutQtAct = new QAction(tr("About &Qt"), this);
    connect(aboutQtAct, &QAction::triggered, qApp, &QApplication::aboutQt);
}

createAction()函数中,我们创建代表菜单项的操作并将它们连接到相应的槽。特别是我们在"另存为"子菜单中创建了操作。我们使用QImageWriter::supportedImageFormats()获取支持格式的列表(作为一个QList<QByteArray>)。

然后我们遍历列表,为每个格式创建一个操作。我们使用文件格式调用QAction::setData(),这样我们可以在后续通过QAction::data()检索它。我们也可以通过截取操作文本中的"..."来推断文件格式,但那样会显得不够优雅。

void MainWindow::createMenus()
{
    saveAsMenu = new QMenu(tr("&Save As"), this);
    for (QAction *action : std::as_const(saveAsActs))
        saveAsMenu->addAction(action);

    fileMenu = new QMenu(tr("&File"), this);
    fileMenu->addAction(openAct);
    fileMenu->addMenu(saveAsMenu);
    fileMenu->addAction(printAct);
    fileMenu->addSeparator();
    fileMenu->addAction(exitAct);

    optionMenu = new QMenu(tr("&Options"), this);
    optionMenu->addAction(penColorAct);
    optionMenu->addAction(penWidthAct);
    optionMenu->addSeparator();
    optionMenu->addAction(clearScreenAct);

    helpMenu = new QMenu(tr("&Help"), this);
    helpMenu->addAction(aboutAct);
    helpMenu->addAction(aboutQtAct);

    menuBar()->addMenu(fileMenu);
    menuBar()->addMenu(optionMenu);
    menuBar()->addMenu(helpMenu);
}

createMenu()函数中,我们将之前创建的格式操作添加到saveAsMenu。然后我们添加其他操作以及saveAsMenu子菜单到"文件""选项""帮助"菜单。

QMenu类提供用于菜单栏、上下文菜单和其他弹出菜单的菜单小部件。QMenuBar类提供带有下拉QMenu列表的水平菜单栏。最后,我们将"文件""选项"菜单放入MainWindow的菜单栏,我们使用QMainWindow::menuBar()函数获取菜单栏。

bool MainWindow::maybeSave()
{
    if (scribbleArea->isModified()) {
       QMessageBox::StandardButton ret;
       ret = QMessageBox::warning(this, tr("Scribble"),
                          tr("The image has been modified.\n"
                             "Do you want to save your changes?"),
                          QMessageBox::Save | QMessageBox::Discard
                          | QMessageBox::Cancel);
        if (ret == QMessageBox::Save)
            return saveFile("png");
        else if (ret == QMessageBox::Cancel)
            return false;
    }
    return true;
}

mayBeSave()中,我们检查是否有任何未保存的更改。如果有,我们使用QMessageBox警告用户图像已被修改,并提供保存修改的机会。

QColorDialogQFileDialog类似,创建QMessageBox的最简单方法是使用其静态函数。QMessageBox提供了一组不同消息,沿两个轴排列:严重性(问题、信息、警告和关键)和复杂性(必要响应按钮的数量)。这里我们使用warning()函数,因为消息相当重要。

如果用户选择保存,我们调用私有的saveFile()函数。为简单起见,我们使用PNG作为文件格式;用户始终可以按下"取消"使用其他格式保存文件。

maybeSave()函数如果用户点击"取消"则返回false;否则返回true

bool MainWindow::saveFile(const QByteArray &fileFormat)
{
    QString initialPath = QDir::currentPath() + "/untitled." + fileFormat;

    QString fileName = QFileDialog::getSaveFileName(this, tr("Save As"),
                               initialPath,
                               tr("%1 Files (*.%2);;All Files (*)")
                               .arg(QString::fromLatin1(fileFormat.toUpper()))
                               .arg(QString::fromLatin1(fileFormat)));
    if (fileName.isEmpty())
        return false;
    return scribbleArea->saveImage(fileName, fileFormat.constData());
}

saveFile()中,我们弹出一个带有文件名建议的文件对话框。静态QFileDialog::getSaveFileName()函数返回用户选择的文件名。文件不必存在。

示例项目@code.qt.io

© 2024 Qt公司有限公司。包含在此处的文档贡献的版权属于各自的拥有者。本提供的文档是根据自由软件基金会发布的GNU自由文档许可版本1.3的条款许可的。Qt及其标志是芬兰和/或其他国家的The Qt Company Ltd的商标。所有其他商标均为各自拥有者的财产。