Пользовательский интерфейс в Qt

Кастомные виджеты в Qt Widgets: наследование от QWidget и paintEvent

Введение

Стандартные виджеты Qt Widgets покрывают большинство типичных интерфейсных задач, но иногда нужен полностью кастомный визуальный элемент — индикатор, специфичный график, игровое поле. Создание собственного виджета через наследование от QWidget и переопределение paintEvent() даёт полный контроль над отрисовкой с использованием QPainter. В статье построим простой кастомный круговой индикатор прогресса.

Концепция

QWidget::paintEvent(QPaintEvent *event) вызывается фреймворком Qt каждый раз, когда виджет нуждается в перерисовке — при первом показе, изменении размера, или после явного вызова update()/repaint(). Внутри этого метода создаётся QPainter, привязанный к самому виджету, и через его методы (drawEllipse, drawArc, drawText и десятки других) формируется визуальное содержимое. Важно: update() асинхронно планирует перерисовку (объединяя несколько вызовов в один реальный paintEvent), а repaint() форсирует немедленную синхронную перерисовку — в большинстве случаев предпочтителен именно update().

Пример кода: Кастомные виджеты в Qt Widgets

// circularprogressbar.h
#pragma once
#include <QWidget>

class CircularProgressBar : public QWidget
{
    Q_OBJECT
    Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)

public:
    explicit CircularProgressBar(QWidget *parent = nullptr) : QWidget(parent)
    {
        setMinimumSize(100, 100);
    }

    int value() const { return m_value; }

    void setValue(int value)
    {
        value = qBound(0, value, 100);
        if (m_value == value) return;
        m_value = value;
        emit valueChanged(value);
        update(); // планируем перерисовку
    }

signals:
    void valueChanged(int value);

protected:
    void paintEvent(QPaintEvent *event) override
    {
        Q_UNUSED(event);

        QPainter painter(this);
        painter.setRenderHint(QPainter::Antialiasing);

        const int side = qMin(width(), height());
        const QRectF rect((width() - side) / 2.0 + 10, (height() - side) / 2.0 + 10,
                           side - 20, side - 20);

        // Фоновое кольцо
        QPen backgroundPen(QColor(220, 220, 220), 8);
        painter.setPen(backgroundPen);
        painter.drawArc(rect, 0, 360 * 16);

        // Дуга прогресса
        QPen progressPen(QColor(66, 133, 244), 8, Qt::SolidLine, Qt::RoundCap);
        painter.setPen(progressPen);
        const int spanAngle = static_cast<int>(360.0 * m_value / 100.0 * 16);
        painter.drawArc(rect, 90 * 16, -spanAngle); // начинаем сверху, по часовой стрелке

        // Текст в центре
        painter.setPen(Qt::black);
        painter.drawText(rect, Qt::AlignCenter, QString("%1%").arg(m_value));
    }

private:
    int m_value = 0;
};
// Использование
#include "circularprogressbar.h"
#include <QApplication>
#include <QTimer>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    CircularProgressBar progressBar;
    progressBar.resize(200, 200);
    progressBar.show();

    auto *timer = new QTimer(&progressBar);
    QObject::connect(timer, &QTimer::timeout, [&progressBar]() {
        progressBar.setValue((progressBar.value() + 5) % 101);
    });
    timer->start(100);

    return app.exec();
}

Пояснения к коду

setValue() ограничивает значение через qBound(), проверяет на отсутствие реального изменения, испускает сигнал valueChanged и вызывает update() — именно update() фактически инициирует следующий вызов paintEvent(), без него изменение m_value никак не отразилось бы визуально. Внутри paintEvent включается сглаживание через setRenderHint(QPainter::Antialiasing) — без этого дуги и окружности выглядели бы заметно «ступенчатыми».

Углы в drawArc() указываются в шестнадцатых долях градуса (поэтому умножение на 16) — особенность API Qt, унаследованная из ранних версий, и легко забываемая деталь при первом использовании этого метода. Отрицательный spanAngle означает рисование дуги по часовой стрелке от начального угла, заданного как 90 градусов (что соответствует положению «12 часов» на циферблате при стандартной системе координат Qt).

Подводные камни

  • Вызов repaint() вместо update() в коде, который часто меняет состояние (например, в плотном цикле или в ответ на частые события) приводит к избыточным синхронным перерисовкам, заметно проседая по производительности — update() корректно объединяет множественные запросы в одну перерисовку перед следующей итерацией event loop.
  • Тяжёлые вычисления внутри paintEvent(). Поскольку этот метод может вызываться достаточно часто (особенно при анимациях или изменении размера окна), любые дорогие вычисления внутри него (а не только отрисовка) создают видимые «просадки» отзывчивости интерфейса — тяжёлые расчёты стоит выполнять заранее и кэшировать результат для использования в paintEvent.
  • Забытый setMinimumSize()/политика размеров (sizePolicy). Без явного указания минимального размера кастомный виджет может быть сжат до неприемлемо малых размеров layout-менеджером, что особенно заметно для виджетов с фиксированным визуальным контентом, не масштабирующимся пропорционально вниз без потери читаемости.
  • Использование абсолютных пиксельных координат без учёта DPI-масштабирования. На экранах с высоким DPI (Retina, 4K) фиксированные значения толщины линии или отступов в пикселях могут выглядеть слишком тонкими или толстыми — для по-настоящему адаптивного кастомного виджета стоит учитывать devicePixelRatioF() или работать в логических, а не физических пикселях, что Qt по умолчанию и делает, но стоит проверять на практике на разных дисплеях.