本页内容

Scribble 示例

“涂鸦”示例演示了如何重写QWidget 中的部分事件处理程序,以接收应用程序控件生成的事件。

我们重写了鼠标事件处理程序以实现绘图功能,重写了 paint 事件处理程序以更新应用程序,并重写了 resize 事件处理程序以优化应用程序的外观。此外,我们还重写了 close 事件处理程序,以便在应用程序终止之前拦截关闭事件。

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

“Scribble”示例的屏幕截图

通过 Scribble 应用程序,用户可以绘制图像。 “File ”菜单允许用户打开和编辑现有图像文件、保存图像以及退出应用程序。在绘制过程中,“Options ”菜单允许用户选择笔色和笔宽,以及清除屏幕。此外,“Help ”菜单为用户提供了关于 Scribble 示例的具体信息,以及关于 Qt 的总体信息。

该示例由两个类组成:

  • ScribbleArea 是一个自定义控件,用于显示QImage 并允许用户在其上绘图。
  • MainWindow 提供位于ScribbleArea 上方的菜单。

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

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() 来实际执行绘制操作,以及resizeImage() 来更改QImage 的大小。print() 槽函数负责打印操作。

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

  • modified 当涂鸦区域中显示的图像存在未保存的更改时,true 为真。
  • scribbling 当用户在涂鸦区域内按住鼠标左键时,该变量为true 。
  • penWidth penColor 分别存储应用程序中当前所用画笔的宽度和颜色。
  • image 用于存储用户绘制的图像。
  • lastPoint 保存最后一次鼠标点击或鼠标移动事件时光标的位置。

ScribbleArea 类的实现

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 保证每个颜色通道(红、绿、蓝和透明度)都使用 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);

我们根据所需的输出格式构建一个高分辨率的 QPrinter 对象,并使用 QPrintDialog 对话框请用户指定页面大小,并指示输出在页面上的排版方式。

如果用户接受对话框中的设置,我们将执行向绘图设备打印的任务:

    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 。我们重写了QWidget 中定义的closeEvent()处理函数。open() 、save() 、penColor() 和penWidth() 这四个插槽分别对应菜单项。此外,我们还创建了四个私有函数。

我们使用布尔函数maybeSave() 来检查是否存在未保存的更改。如果存在未保存的更改,我们会给用户提供保存这些更改的机会。如果用户点击Cancel ,该函数将返回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();
}

关闭事件会发送给用户想要关闭的控件,通常是通过单击“File|Exit ”或单击X 标题栏按钮来实现的。通过重写事件处理程序,我们可以拦截关闭应用程序的操作。

在此示例中,我们利用关闭事件提示用户保存任何未保存的更改。相关逻辑位于 `maybeSave() ` 函数中。如果 `maybeSave() ` 返回 `true`,则表示没有修改或用户已成功保存,此时我们接受该事件。 此时应用程序可以正常退出。如果 `maybeSave() ` 返回 `false`,说明用户点击了 `Cancel`,因此我们会“忽略”该事件,使应用程序不受其影响。

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 As 菜单项,然后从格式菜单中选择一个选项时,会调用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() 插槽中获取新的笔宽,我们使用QInputDialog 。QInputDialog 类提供了一个简单的便捷对话框,用于从用户处获取单个值。 我们使用静态函数QInputDialog::getInt(),该函数结合了QLabel 和QSpinBox 。QSpinBox 初始化为涂鸦区域的笔宽,允许的范围为1到50,步长为1(这意味着向上和向下箭头会将值增加或减少1)。

布尔变量ok 将在用户点击OK 时设置为true ,在用户按下Cancel 时设置为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() 函数中,我们创建代表菜单项的操作,并将它们连接到相应的槽中。特别是,我们创建了“Save As ”子菜单中的操作。我们使用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 子菜单分别添加到File 、Options 和Help 菜单中。

QMenu 类提供了一个用于菜单栏、上下文菜单和其他弹出菜单的菜单控件。QMenuBar 类提供了一个包含QMenu下拉列表的水平菜单栏。最后,我们将File 和Options 菜单放入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 向用户发出警告,提示图片已被修改,并提供保存修改的机会。

与QColorDialog 和QFileDialog 类似,创建QMessageBox 的最简单方法是使用其静态函数。QMessageBox 提供了一系列沿两个维度排列的不同消息:严重程度(疑问、信息、警告和严重)以及复杂度(所需的响应按钮数量)。由于此消息相当重要,因此我们在此使用warning() 函数。

如果用户选择保存,我们会调用私有函数saveFile() 。为简化起见,我们使用 PNG 作为文件格式;用户随时可以点击Cancel 并以其他格式保存文件。

当用户点击Cancel 时,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

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.