Zoom в Qt виджете

Всем привет! Данная статья адресована в первую очередь новичкам, но может пригодиться и опытным Qt-разработчикам. Она посвящена написанию виджета с возможностью зума и навигации. Сейчас объясню, что это и в каких ситуациях это может быть полезно.

Например, все знают интерфейс стандартного просмотрщика изображений. С помощью него мы открываем изображение с диска. При запуске программы мы видим окно с картинкой. С помощью колесика мыши можно увеличивать или уменьшать изображение. Если увеличенная картинка не вмещается в рамки окна, то удерживая кнопку мыши и перемещая курсор, мы можем как бы передвигать изображение, чтобы увидеть другую его часть. Похожий пример — приложение или сайт с картой. А ещё — редакторы изображений, CAD-системы и так далее. Примеров очень много. Но суть одна — мы можем мышью менять масштаб и двигать картинку. Всё удобно и интуитивно понятно!

Но что, если мы захотим сами написать нечто подобное? В Qt есть такой класс, как QScrollArea. Он способен отобразить изображение, не помещающееся на экране. С помощью полос прокрутки можно менять отображаемую область. В общем то и все. Не то, что хотелось бы! Когда я был новичком в Qt, мне не слишком было понятно, как добиться желаемого результата.

В этой статье будут объяснены все детали написания зумироваемого виджета. Для простоты наше приложение будет состоять из одного лишь этого виджета. В нашем примере он будет отображать фигуры, нарисованные с помощью класса QPainter. Но точно также это можно адаптировать под отрисовку изображений. Итак, начнём!

Начало

Создадим графическое приложение, в котором главное окно наследуется от класса QWidget. Тут все стандартно, и QtCreator все сделает за нас. В результате при запуске созданного приложения получим пустое окошко. В нашем примере мы будем отображать простую 2D графику, отрисовываемую средствами QPainter. Поэтому в классе виджета переопределим виртуальный метод отрисовки: В нем мы пропишем отрисовку четырех примыкающих друг к другу прямоугольников разного цвета:

// ZoomWidget.cpp
void ZoomWidget::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event)
    QPainter painter(this);
    int w = 100; // ширина каждого прямоугольника
    int h = 60;  // высота каждого прямоугольника

    painter.setBrush(Qt::green);
    painter.drawRect(0, 0, w, h);    // левый верхний 
    painter.setBrush(Qt::yellow);
    painter.drawRect(w, 0, w, h);    // правый верхний
    painter.setBrush(Qt::magenta);
    painter.drawRect(0, h, w, h);    // левый нижний
    painter.setBrush(Qt::cyan);
    painter.drawRect(w, h, w, h);    // правый нижний
}

При запуске увидим следующую картину:

832ee3113f1de405b1ebfebdf831008d.png

Добавляем навигацию и масштаб

Мы только что нарисовали нашу сцену. Однако в данном случае координаты окна и координаты сцены полностью совпадают. Чтобы получить возможность перемещаться по сцене и менять масштаб, введём новые переменные.

Сперва добавим масштаб. Он будет задаваться числом с плавающей точкой и принимать следующие значения:

  • == 1 — для полноразмерного объекта

  • > 1 — для увеличенного объекта

  • > 0 и < 1 - для уменьшенного объекта

Для навигации введём такую переменную, как координаты «камеры». В самом простом случае она связывает левый верхний угол виджета с координатами отображаемой сцены, с учётом масштаба. Координаты объектов не будут зависеть от координат камеры и от масштаба. Эти величины повлияют на обработку событий мыши, а также изменится функция отрисовки сцены.

Добавим в наш класс два члена:

// ZoomWidget.h
QPointF m_camPos {0., 0.};   // Положение камеры
qreal m_scale {1.0};  // Масштаб

Чтобы проще было разобраться с новыми переменными, рассмотрим несколько примеров. Позиция камеры обозначена как Cam (x, y). Сверху и слева от виджета имеются две шкалы, обозначающие координаты виджета в пикселях по горизонтали и вертикали. Рассмотрим 4 случая:

22ca27cc3252587e7f814b27fab77290.png

На примерах красным изображен прямоугольник размерами 30×20 с координатами левой верхней вершины (0,0). Виджет размечен сеткой. Это сделано для того, чтобы показать, как меняется «цена деления» клетки в зависимости от масштаба. Под ценой деления подразумевается количество реальных единиц (по оси X либо Y), которые охватывает клетка. Перейдем к четырем показанным случаям:

  1. Все максимально просто, камера в начале координат. Поэтому верхний левый угол прямоугольника находится в левом верхнем углу виджета. Размеры прямоугольника не изменяются, так как масштаб равен 1.

  2. Камера «смотрит» на точку (-15,-5), масштаб исходный. Все как в первом случае, только объект сдвинется на координаты (15,5), то есть противоположно сдвигу камеры.

  3. Камера в нуле. Масштаб равен 2, поэтому объект увеличится в 2 раза.

  4. Камера «смотрит» на (-15,-5), как в случае 2. Размеры объекта уменьшаются в два раза, так как масштаб равен 0.5. Обратите внимание, что в отличие от второго случая объект смещен от начала координат так же в 2 раза меньше, на (7.5,2.5).

«Цена деления» клетки в случаях 1–2 соответствует числу пикселей виджета (единичный масштаб), т.е. равна 5. В случае 3 она принимает значение 2.5 (масштаб увеличился в 2 раза). В случае 4 «цена» равна 10 (масштаб уменьшился в 2 раза).

Из рассмотренных случаев можно сделать два вывода:

  1. Размеры объекта изменяются прямо пропорционально масштабу. Так, при единичном масштабе ширина составляет 6 клеток, при двойном увеличении — 12, а при уменьшении в 2 раза — 3 клетки. Аналогично и с высотой.

  2. Смещение координат объекта на виджете прямо пропорционально отрицательному значению камеры, умноженному на масштаб. Можно заметить, что с уменьшением координат камеры (и по X, и по Y) объект как бы удаляется по осям в положительную сторону. А значение масштаба представляет собой некий коэффициент, корректирующий этот сдвиг.

Изменим наш метод отрисовки в соответствии со сделанными выводами. У QPainter есть удобный метод translate, позволяющий сместить положение отрисовщика на заданную позицию. Воспользуемся им для смещения координат объекта.

// ZoomWidget.cpp
void ZoomWidget::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event)
    QPainter painter(this);
    // Домножаем исходные размеры прямоугольников на значение масштаба
    int w = 100 * m_scale;
    int h = 60 * m_scale;

    // Перемещаем отрисовщик в соответствии с масштабом положением камеры
    painter.translate(-m_camPos * m_scale);
    // Дальше ничего не меняется
    painter.setBrush(Qt::green);
    painter.drawRect(0, 0, w, h);
    painter.setBrush(Qt::yellow);
    painter.drawRect(w, 0, w, h);
    painter.setBrush(Qt::magenta);
    painter.drawRect(0, h, w, h);
    painter.setBrush(Qt::cyan);
    painter.drawRect(w, h, w, h);
}

Перемещение по сцене

Переопределим три метода QWidget, связанных с нажатием / отпусканием кнопки мыши, а также с положением курсора. Но сначала введем пару вспомогательных переменных в наш класс:

// ZoomWidget.h
bool m_mousePressed {false}; // Флаг: нажата ли кнопка мыши в данный момент
QPoint m_mousePrevPos;       // Предыдущие координаты курсора мыши с нажатой кнопкой

А теперь перейдем к методам событий мыши:

// ZoomWidget.cpp

// Событие нажатия кнопки мыши
void ZoomWidget::mousePressEvent(QMouseEvent *event)  
{
    m_mousePressed = true;         // Устанавливаем флаг нажатия кнопки
    m_mousePrevPos = event->pos(); // Запоминаем предыдущую координату, как текущую
}
// Событие отпускания кнопки мыши
void ZoomWidget::mouseReleaseEvent(QMouseEvent *event)
{
    m_mousePressed = false;   // Сбрасываем флаг нажатия кнопки
}
// Событие перемещения курсора
void ZoomWidget::mouseMoveEvent(QMouseEvent *event)
{
    // Если в данный момент кнопка мыши не нажата, то ничего не делаем
    if (!m_mousePressed)
        return;

    // Разница координат текущего и предыдущего положений курсора
    QPointF dMousePos = event->pos() - m_mousePrevPos;
    // Сдвигаем камеру обратно разнице, скорректированной по масштабу
    m_camPos -= dMousePos / m_scale;
    m_mousePrevPos = event->pos(); // Запоминаем предыдущую координату, как текущую
    repaint();  // Перерисовываем виджет
}

Подробнее о действиях на строке 24. Разница координат делится на масштаб, чтобы скорректировать поведение при перемещении по сцене. Независимо от масштаба нужно, чтобы объекты сцены «сдвигались» на виджете одинаково при одинаковых смещениях курсора. Если масштаб не учесть, то при уменьшенном масштабе (<1) мы получим слишком медленное перемещение, а при увеличенном (>1) слишком резкое и быстрое. Почему разница вычитается из m_camPos? Допустим, пользователь сдвинул курсор вправо, и он ожидает, что эта точка тоже сдвинется вправо. Чтобы такое произошло, нужно, чтобы камера сместилась в противоположную сторону, то есть влево. Поэтому мы сдвигаем камеру противоположно сдвигу курсора (скорректированному по масштабу).

Возможность менять масштаб

Перегрузим еще один метод класса QWidget, вызывающийся при кручении колеса мыши:

// ZoomWidget.cpp
void ZoomWidget::wheelEvent(QWheelEvent *event)
{
    // Коэффициент изменения масштаба
    const qreal scaleCoef = 1.1;
    // Если колесико повернуто вперед, то масштаб увеличивается, если назад - уменьшается
    qreal newScale = event->angleDelta().y() > 0 ? m_scale * scaleCoef :
        m_scale / scaleCoef;
    m_scale = newScale;
    repaint();
}

В приведенном коде масштаб увеличивается либо уменьшается путем умножения на некую константу. Из моего опыта — это самый подходящий подход в отличие от, например, сложения/вычитания константы. Так изменение масштаба визуально происходит плавно.

Но такая реализация довольно наивна! Когда я был новичком, то реализовывал нечто подобное именно так, и получал вот такой результат:

Сцена с объектом все время куда-то смещается при изменении масштаба! Если честно, сначала мне было не особо понятно, как это исправить. Но немного проанализировав, я понял основную идею. Рассмотрим поведение просмотрщика изображений, вот примерно такое хочется видеть:

1aa5a20b407c5a79a1b7e7d0e24e475f.gif

Можно заметить, что если мы не двигаем курсор, но меняем масштаб, то курсор всегда указывает на одну и ту же точку отображаемой сцены. А в нашей получившейся программе на одну и ту же точку смотрит левый верхний угол, то есть, на ту, куда смотрит «камера». Следовательно, нужно как-то корректировать координаты камеры, когда мы увеличиваем / уменьшаем масштаб. Чтобы разобраться, как это делать, рассмотрим формулу вычисления реальных координат (R) по координатам на виджете (W), координатам камеры (Cam) и масштабу (scale):

R = Cam +  \frac{W}{scale}

К координатам камеры мы прибавляем координату точки на виджете, опять же, скорректированную по масштабу. Делим мы на масштаб потому, что с его увеличением уменьшается число реальных единиц, соответствующих пикселю экрана (аналогия с ценой деления, описанной в начале статьи). Обозначим величины индексами 1 и 2 до и после изменения масштаба, соответственно. Учтем, что точка сцены, на которую смотрит курсор, не меняется. Также не меняются и координаты курсора на виджете. Получим:

\begin{cases} R_1 = Cam_1 +  \frac{W_1}{scale_1} \\ R_2 = Cam_2 +  \frac{W_2}{scale_2} \\ R_! = R_2 \\ W_1 = W_2 = W \end{cases} \\Cam_1 + \frac{W}{scale_1} = Cam_2 + \frac{W}{scale_2} \\ Cam_2 = Cam_1 + W(\frac{1}{scale_1} - \frac{1}{scale_2})

Учтем это в функции кручения колесика мыши:

void ZoomWidget::wheelEvent(QWheelEvent *event)
{
    const qreal scaleCoef = 1.1;
    qreal newScale = event->angleDelta().y() > 0 ? m_scale * scaleCoef :
        m_scale / scaleCoef;
    QPointF dCam = QPointF(event->pos()) * (1. / m_scale - 1. / newScale);
    m_camPos += dCam;
    m_scale = newScale;
    m_mousePrevPos = event->pos();
    repaint();
}

И получим такой результат:

937b33974d4f939cc2457df71e1f70b7.gif

Именно то, что нужно!

Всех благодарю за внимание. Ссылка на полный код программы

© Habrahabr.ru