IT Notes

QGraphicsItem: Пользовательский компонент для выбора области на сцене

Пусть в приложении требуется функция увеличения произвольной области изображения, как под лупой. В качестве примера решения этой задачи скомпонуем простую Qt-программу:

qt-scale-image-viewer-thumbnail

В левой части отображается оригинал. Для него мы используем QGraphicsView и QGraphicsScene. Увеличенная копия выделенной части картинки выводится в правой верхней области окна с помощью QLabel.

Область увеличения изображения задается в виде прямоугольника, который можно перемещать при помощи мыши по принципу Drag&Drop. Размер области увеличения может быть изменен путем манипуляций с углами прямоугольника, соответствующего границам области, по аналогии с тем, к чему нас приучили всевозможные графические редакторы.

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

Реализация

В качестве нашей основной рабочей поверхности воспользуемся QGraphicsView и QGraphicsScene. Определим виджет главного окна для приложения:

class CustomGraphicsItemDemo : public QWidget {
    Q_OBJECT

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

protected:
    void resizeEvent( QResizeEvent* );

private slots:
    void onSelectedAreaChanged( const QRectF& area );

private:
    Ui::CustomGraphicsItemDemo* ui;

    QGraphicsScene m_scene;
    QPixmap m_pix;
};

И добавим базовую реализацию:

Hellcase CS:GO - cases & get exclusive CS2 skins, gloves, knives, agents. Best drop in cases.

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

    ui->grView->setScene( &m_scene );

    m_pix.load( ":/images/image.jpeg" );
    m_scene.setSceneRect( 0, 0, m_pix.width(), m_pix.height() );
    m_scene.addPixmap( m_pix );

    resizeEvent( NULL );
}

CustomGraphicsItemDemo::~CustomGraphicsItemDemo() {
    delete ui;
}

void CustomGraphicsItemDemo::resizeEvent( QResizeEvent* ) {
    static const int GRAPHICS_VIEW_MARGIN = 20;
    double scale = std::min(
                       ( ui->grView->width() - GRAPHICS_VIEW_MARGIN ) / m_scene.width(),
                       ( ui->grView->height() - GRAPHICS_VIEW_MARGIN ) / m_scene.height()
                   );

    ui->grView->resetMatrix();
    ui->grView->scale( scale, scale );
}

void CustomGraphicsItemDemo::onSelectedAreaChanged( const QRectF& area ) {
    static const int SCALED_VIEW_MARGIN = 20;
    ui->lbScaledView->setPixmap(
        m_pix.copy( area.toRect() ).scaled(
            ui->lbScaledView->size().width() - SCALED_VIEW_MARGIN,
            ui->lbScaledView->size().height() - SCALED_VIEW_MARGIN,
            Qt::KeepAspectRatio
        )
    );
}

Чтобы не усложнять пример, я поместил картинку в ресурсы приложения, и загружаю ее в QPixmap оттуда. Размер Сцены задается исходя из размеров изображения, поскольку именно изображение наш основной объект экспериментов. Сама картинка добавляется на Сцену с помощью setPixmap().

Важно: Не забудьте связать Сцену QGraphicsScene с Представлением QGraphicsView:

ui->grView->setScene( &m_scene )

Чтобы изображение всегда вписывалось в Представление, переопределим функцию-обработчик события изменения размеров виджета resizeEvent():

void CustomGraphicsItemDemo::resizeEvent( QResizeEvent* ) {
    static const int GRAPHICS_VIEW_MARGIN = 20;
    double scale = std::min(
                       ( ui->grView->width() - GRAPHICS_VIEW_MARGIN ) / m_scene.width(),
                       ( ui->grView->height() - GRAPHICS_VIEW_MARGIN ) / m_scene.height()
                   );

    ui->grView->resetMatrix();
    ui->grView->scale( scale, scale );
}

Также не забудем про заготовку слота, реагирующего на изменение выбранной области:

void CustomGraphicsItemDemo::onSelectedAreaChanged( const QRectF& area ) {
    static const int SCALED_VIEW_MARGIN = 20;
    ui->lbScaledView->setPixmap(
        m_pix.copy( area.toRect() ).scaled(
            ui->lbScaledView->size().width() - SCALED_VIEW_MARGIN,
            ui->lbScaledView->size().height() - SCALED_VIEW_MARGIN,
            Qt::KeepAspectRatio
        )
    );
}

В качестве компонента для выделения области на Сцене мы могли бы взять за основу QGraphicsRectItem с установленным флагом ItemIsMovable. Но это решение недостаточно гибкое. Придется идти другим путем, и реализовать собственный компонент, унаследовав его от QGraphicsItem:

class AreaSelector : public QObject, public QGraphicsItem {
    Q_OBJECT

    static const int RESIZE_ZONE_SIZE;
    static const int MIN_AREA_SIZE;

public:
    explicit AreaSelector( const QRectF& initialRect );
    ~AreaSelector();

    QRectF boundingRect() const;

    QRectF getSelectedArea() const {
        return m_innerRect;
    }

signals:
    void selectedAreaChanged( const QRectF& area );

protected:
    void paint( QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget = 0 );

    void hoverEnterEvent( QGraphicsSceneHoverEvent* event );
    void hoverLeaveEvent( QGraphicsSceneHoverEvent* event );

    void mousePressEvent( QGraphicsSceneMouseEvent* event );
    void mouseMoveEvent( QGraphicsSceneMouseEvent* event );
    void mouseReleaseEvent( QGraphicsSceneMouseEvent* event );

private:
    enum SelectorZone {
        NONE,
        MOVE_ZONE,
        RESIZE_TOP_LEFT_ZONE,
        RESIZE_TOP_RIGHT_ZONE,
        RESIZE_BOTTOM_LEFT_ZONE,
        RESIZE_BOTTOM_RIGHT_ZONE,
    };

    QHash< SelectorZone, QRectF > generateZones() const;

private:
    QRectF m_innerRect;
    bool m_hovered;

    SelectorZone m_activeZone;
    QPointF m_centerOffset;

};

Нам приходится контролировать каждый аспект поведения компонента вручную, но это дает полный контроль над ситуацией:

const int AreaSelector::RESIZE_ZONE_SIZE = 20;
const int AreaSelector::MIN_AREA_SIZE = 100;

AreaSelector::AreaSelector( const QRectF& initialRect ) :
    m_innerRect( initialRect ), m_hovered( false ), m_activeZone( AreaSelector::NONE ) {

    setAcceptHoverEvents( true );
}

AreaSelector::~AreaSelector() { }

QRectF AreaSelector::boundingRect() const {
    return QRectF(
               m_innerRect.left() - RESIZE_ZONE_SIZE / 2.0,
               m_innerRect.top() - RESIZE_ZONE_SIZE / 2.0,
               m_innerRect.width() + RESIZE_ZONE_SIZE,
               m_innerRect.height() + RESIZE_ZONE_SIZE
           );
}

void AreaSelector::paint( QPainter* painter, const QStyleOptionGraphicsItem*, QWidget* ) {
    painter->drawRect( m_innerRect );

    if( m_hovered ) {
        QHash< SelectorZone, QRectF > zones = generateZones();
        foreach( const SelectorZone& key, zones.keys() ) {
            painter->fillRect( zones[ key ], Qt::white );
            painter->drawRect( zones[ key ] );
        }
    }
}

void AreaSelector::hoverEnterEvent( QGraphicsSceneHoverEvent* ) {
    m_hovered = true;
    update();

}

void AreaSelector::hoverLeaveEvent( QGraphicsSceneHoverEvent* ) {
    m_hovered = false;
    update();
}

void AreaSelector::mousePressEvent( QGraphicsSceneMouseEvent* event ) {
    switch( event->button() ) {
    case Qt::LeftButton: {
        QHash< SelectorZone, QRectF > zones = generateZones();
        foreach( const SelectorZone& key, zones.keys() ) {
            if( zones[ key ].contains( event->pos() ) ) {
                m_activeZone = key;
                break;
            }
        }

        if(
            m_activeZone == AreaSelector::NONE &&
            m_innerRect.contains( event->pos() )
        ) {
            m_activeZone = MOVE_ZONE;
            m_centerOffset = event->pos() - m_innerRect.center();
        }

        break;
    }

    default:
        // Nothing to do here
        break;
    }
}

void AreaSelector::mouseMoveEvent( QGraphicsSceneMouseEvent* event ) {
    prepareGeometryChange();

    QRectF sRect;

    if( scene() ) {
        sRect = scene()->sceneRect();
    }


    switch( m_activeZone ) {
    case MOVE_ZONE:
        m_innerRect.moveCenter( event->pos() - m_centerOffset );

        if( m_innerRect.left() < sRect.left() ) {
            m_innerRect.moveLeft( sRect.left() );
        }

        if( m_innerRect.top() < sRect.top() ) {
            m_innerRect.moveTop( sRect.top() );
        }

        if( sRect.right() < m_innerRect.right() ) {
            m_innerRect.moveRight( sRect.right() );
        }

        if( sRect.bottom() < m_innerRect.bottom() ) {
            m_innerRect.moveBottom( sRect.bottom() );
        }

        break;

    case RESIZE_TOP_LEFT_ZONE:
        m_innerRect.setTopLeft( event->pos() );
        break;

    case RESIZE_TOP_RIGHT_ZONE:
        m_innerRect.setTopRight( event->pos() );
        break;

    case RESIZE_BOTTOM_LEFT_ZONE:
        m_innerRect.setBottomLeft( event->pos() );
        break;

    case RESIZE_BOTTOM_RIGHT_ZONE:
        m_innerRect.setBottomRight( event->pos() );
        break;

    default:
        // Nothing to do here
        break;

    }

    if( m_activeZone != AreaSelector::NONE ) {
        if( m_innerRect.left() < sRect.left() ) {
            m_innerRect.setLeft( sRect.left() );
        }

        if( m_innerRect.top() < sRect.top() ) {
            m_innerRect.setTop( sRect.top() );
        }

        if( sRect.right() < m_innerRect.right() ) {
            m_innerRect.setRight( sRect.right() );
        }

        if( sRect.bottom() < m_innerRect.bottom() ) {
            m_innerRect.setBottom( sRect.bottom() );
        }

        if( m_innerRect.width() < MIN_AREA_SIZE ) {
            if( m_activeZone == RESIZE_BOTTOM_LEFT_ZONE || m_activeZone == RESIZE_TOP_LEFT_ZONE ) {
                m_innerRect.setLeft( m_innerRect.right() - MIN_AREA_SIZE );
            } else {
                m_innerRect.setRight( m_innerRect.left() + MIN_AREA_SIZE );
            }
        }

        if( m_innerRect.height() < MIN_AREA_SIZE ) {
            if( m_activeZone == RESIZE_TOP_LEFT_ZONE || m_activeZone == RESIZE_TOP_RIGHT_ZONE ) {
                m_innerRect.setTop( m_innerRect.bottom() - MIN_AREA_SIZE );
            } else {
                m_innerRect.setBottom( m_innerRect.top() + MIN_AREA_SIZE );
            }
        }
    }

    emit selectedAreaChanged( m_innerRect );
}

void AreaSelector::mouseReleaseEvent( QGraphicsSceneMouseEvent* ) {
    m_activeZone = AreaSelector::NONE;
}

QHash< AreaSelector::SelectorZone, QRectF > AreaSelector::generateZones() const {
    QRectF zoneRect( 0, 0, RESIZE_ZONE_SIZE, RESIZE_ZONE_SIZE );
    QHash< SelectorZone, QPointF > zoneCenters = QHash< SelectorZone, QPointF >();
    zoneCenters[ RESIZE_TOP_LEFT_ZONE ] = m_innerRect.topLeft();
    zoneCenters[ RESIZE_TOP_RIGHT_ZONE ] = m_innerRect.topRight();
    zoneCenters[ RESIZE_BOTTOM_LEFT_ZONE ] = m_innerRect.bottomLeft();
    zoneCenters[ RESIZE_BOTTOM_RIGHT_ZONE ] = m_innerRect.bottomRight();

    QHash< SelectorZone, QRectF > zones;
    foreach( const SelectorZone& key, zoneCenters.keys() ) {
        zoneRect.moveCenter( zoneCenters[ key ] );
        zones[ key ] = zoneRect;
    }

    return zones;
}

Кода получилось довольно много, но его суть определяется в трех пунктах:

  1. Рабочая область компонента возвращается через функцию-член boundingRect(). Не забудьте учесть, что помимо самой области выделения мы берем небольшой запас на маленькие квадраты, указывающие на зоны растяжения, которые появляются по углам прямоугольника при наведении курсора мыши;
  2. Отрисовка компонента происходит в функции paint(). В ней мы рисуем текущую зону выделения и зоны растяжения (маленькие квадраты в углах). Последние отображаются только тогда, когда курсор мыши попадает в boundingRect();
  3. События мыши мы контролируем с помощью функций hoverEnterEvent(), hoverLeaveEvent(), mousePressEvent(), mouseMoveEvent() и mouseReleaseEvent(). Об этом пункте поговорим поподробнее.

Обратите внимание, что для работы hoverEnterEvent(), hoverLeaveEvent() требуется явно включить поддержку с помощью setAcceptHoverEvents( true ). В обработчиках этих событий мы определяем, что курсор мыши входит или выходит из области нашего компонента: меняем значение m_hovered, а затем обеспечиваем перерисовку (неявный вызов paint()) с помощью update().

В обработчике mousePressEvent() мы фиксируем щелчок левой кнопкой мыши. Если щелчок произошел, то мы определяем в какую зону он пришелся (в одну из зон растяжения по углам или в любое другое место, что соответствует зоне перемещения). Если щелчок произошел в зоне компонента, предназначенной для перемещения, то мы дополнительно сохраняем смещение от центра компонента до позиции курсора в момент щелчка.

В mouseReleaseEvent() мы сбрасываем информацию об активной зоне, в которую пришелся щелчок левой кнопкой мыши, поскольку перемещать или менять размер компонента больше не требуется.

Вся "магия" заключена в функции mouseMoveEvent(). В ней мы контролируем все видоизменения компонента. Основная сложность здесь кроется в необходимости контроля границ. Мы учитываем, что область выделения компонента не должна выходить за границы Сцены (т.е. изображения), а также не должна становиться слишком маленькой. Для реализации всего этого нам достаточно простых наборов условий и знаний геометрии школьного уровня.

Осталось только подключить созданный компонент к нашей Сцене:

CustomGraphicsItemDemo::CustomGraphicsItemDemo( QWidget* parent ) :
    // …

    QRectF initialRect( 0, 0, m_pix.width() * 0.2f, m_pix.height() * 0.2f );
    initialRect.moveCenter( m_scene.sceneRect().center() );
    AreaSelector* areaSelector = new AreaSelector( initialRect );
    connect( areaSelector, SIGNAL( selectedAreaChanged( QRectF ) ), SLOT( onSelectedAreaChanged( QRectF ) ) );
    m_scene.addItem( areaSelector );

    // …
}

Выводы

Мы реализовали собственный компонент для Сцены QGraphicsScene, который позволяет выделять прямоугольную область с помощью мыши. Мы использовали этот компонент в качестве лупы, но идею можно обобщить и для решения более сложных задач.

Исходники

 Скачать пример создания пользовательского компонента для QGraphicsScene

Похожие публикации

Комментарии

Спасибо большое за такой пример!

Возник вопрос: почему в Вашем случае не нужны преобразования координат элемента к координатам сцены?

Anonymous:

Спасибо большое за такой пример!

Здравствуйте. Спасибо за комментарий =)

Anonymous:

Возник вопрос: почему в Вашем случае не нужны преобразования координат элемента к координатам сцены?

Это связано с тем, что элемент AreaSelector уже работает в координатах сцены. Все преобразования происходит относительно m_innerRect

Спасибо! До конца не понятно, почему AreaSelector в координатах сцены. В руководстве Qt положение события QGraphicsSceneMouseEvent* event->pos() в системе координат элемента

Anonymous:

Спасибо! До конца не понятно, почему AreaSelector в координатах сцены. В руководстве Qt положение события QGraphicsSceneMouseEvent* event->pos() в системе координат элемента

Более точно можно сказать, что QGraphicsSceneMouseEvent::pos() возвращает позицию элемента в родительской системе координат. В нашем случае явного родителя у экземпляра AreaSelector нет, поэтому родителем неявно считается сцена, что и приводит к совпадению систем координат элемента и сцены

ОЧЕНЬ ХОРОШИЙ ПРИМЕР!

спасибо огромное, пример супер и статья отличная!