c++qtqgraphicsviewqgraphicssceneqgraphicspixmapitem

How can I make the image return to its original centered position after zooming in, panning, and then zooming out?


I'm building an image viewer widget with zoom-in and zoom-out capabilities, including support for panning (click-drag to move the image around).

Currently, zooming out directly after zooming in works as expected, with the image returning to its starting position. However, when I zoom in, pan the image to a different position, and then zoom out, the image does not return to its original centered position.

Demonstration video: https://i.imgur.com/xgt9V2H.gif

Demonstration GIF:
enter image description here

The black area is the view background, it shouldn't be visible.

I’m looking for help with the calculations needed to correctly reposition the image when zooming out, specifically within this part of the code: if (factor < 1.0) // Zooming out { ... }.

Minimal reproducible example:

#include <QtWidgets>
#include <QGraphicsView>
#include <QGraphicsScene>
#include <QGraphicsPixmapItem>

class ZoomGraphicsView : public QGraphicsView
{
    Q_OBJECT
public:
    ZoomGraphicsView()
    {
        scene = new QGraphicsScene(this);
        setScene(scene);

        // Basic setup - explicitly disable scrollbars
        setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
        setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
        setRenderHint(QPainter::SmoothPixmapTransform);
        setViewportUpdateMode(QGraphicsView::FullViewportUpdate);
        setFrameShape(QFrame::NoFrame);
        setTransformationAnchor(QGraphicsView::NoAnchor);
        setResizeAnchor(QGraphicsView::NoAnchor);
        setBackgroundBrush(Qt::black);
        setAlignment(Qt::AlignCenter);
        setOptimizationFlag(QGraphicsView::DontAdjustForAntialiasing);

        // Initialize pixmap item
        pixmapItem = new QGraphicsPixmapItem();
        scene->addItem(pixmapItem);

        // Initialize base scale
        baseScale = 1.0;
        currentScale = 1.0;
        isZooming = false;

    }

    void setImage(const QImage& image)
    {
        QPixmap newPixmap = QPixmap::fromImage(image);
        if (newPixmap.isNull())
            return;

        // Update pixmap
        pixmapItem->setPixmap(newPixmap);

        if (firstImage)
        {
            resetView();
            firstImage = false;
        }

        // Update scene rect to match the viewport
        scene->setSceneRect(viewport()->rect());
    }

protected:
    void wheelEvent(QWheelEvent* event) override
    {
        if (pixmapItem->pixmap().isNull())
            return;

        isZooming = true;

        // Store cursor position relative to scene
        QPointF mousePosScene = mapToScene(event->position().toPoint());
        QPointF mousePosCurrent = event->position();

        // Calculate zoom factor
        double factor = pow(1.5, event->angleDelta().y() / 240.0);
        double newScale = currentScale * factor;

        // Handle zoom out specifically
        if (factor < 1.0) // Zooming out
        {
            if (newScale < baseScale)
            {
                resetView();
                isZooming = false;
                event->accept();
                return;
            }

            // 1. Get the current positions before any transformation
            QRectF viewRect = viewport()->rect();
            QPointF mousePos = event->position();
            QPointF mousePosScene = mapToScene(mousePos.toPoint());

            // 2. Calculate the view center and the distance from mouse to center
            QPointF viewCenter = mapToScene(viewRect.center().x(), viewRect.center().y());
            QPointF mouseOffset = mousePosScene - viewCenter;

            // 3. Apply the new scale
            QTransform newTransform;
            newTransform.scale(newScale, newScale);
            setTransform(newTransform);

            // 4. Calculate how much the mouse point moved after scaling
            QPointF newMousePosScene = mapToScene(mousePos.toPoint());
            QPointF deltaPos = newMousePosScene - mousePosScene;

            // 5. Adjust the view to maintain the mouse position
            translate(deltaPos.x(), deltaPos.y());

            // 6. Calculate the scaled image bounds
            QRectF imageRect = pixmapItem->boundingRect();
            QRectF scaledImageRect = QRectF(imageRect.topLeft() * newScale, imageRect.size() * newScale);

            // 7. Ensure the view stays within bounds
            QPointF currentCenter = mapToScene(viewRect.center().x(), viewRect.center().y());
            QPointF newCenter = currentCenter;

            // 8. Apply bounds to keep image filling the view
            if (scaledImageRect.width() < viewRect.width())
                newCenter.setX(scaledImageRect.center().x());
            else
            {
                qreal minX = viewRect.width() / 2.0;
                qreal maxX = scaledImageRect.width() - minX;
                newCenter.setX(qBound(minX, currentCenter.x(), maxX));
            }

            if (scaledImageRect.height() < viewRect.height())
                newCenter.setY(scaledImageRect.center().y());
            else
            {
                qreal minY = viewRect.height() / 2.0;
                qreal maxY = scaledImageRect.height() - minY;
                newCenter.setY(qBound(minY, currentCenter.y(), maxY));
            }

            // 9. Update to the bounded position
            centerOn(newCenter);
            currentScale = newScale;

            qDebug() << "\nviewRect:      " << viewRect;
            qDebug() << "mousePos:        " << mousePos;
            qDebug() << "mousePosScene:   " << mousePosScene;
            qDebug() << "viewCenter:      " << viewCenter;
            qDebug() << "mouseOffset:     " << mouseOffset;
            qDebug() << "newMousePosScene:" << newMousePosScene;
            qDebug() << "deltaPos:        " << deltaPos;
            qDebug() << "currentCenter:   " << currentCenter;
            qDebug() << "newCenter:       " << newCenter;
            qDebug() << "imageRect:       " << imageRect;
            qDebug() << "scaledImageRect: " << scaledImageRect;
            qDebug() << "factor:          " << factor;
            qDebug() << "newScale:        " << newScale;
        }
        else // Zooming in
        {
            if (newScale > 40.0)
            {
                isZooming = false;
                event->accept();
                return;
            }

            // Update scale
            currentScale = newScale;

            // Apply new transform
            QTransform newTransform = transform();
            newTransform.scale(factor, factor);
            setTransform(newTransform);

            // Calculate new scene position under mouse after scaling
            QPointF newMousePosScene = mapToScene(mousePosCurrent.toPoint());
            QPointF offset = newMousePosScene - mousePosScene;

            // Adjust view position to keep mouse point stable
            translate(offset.x(), offset.y());
        }

        currentScale = newScale;
        isZooming = false;
        event->accept();
    }

    void mousePressEvent(QMouseEvent* event) override
    {
        if (event->button() == Qt::LeftButton)
        {
            isPanning = true;
            lastMousePos = event->pos();
            setCursor(Qt::ClosedHandCursor);
            event->accept();
        }
    }

    void mouseMoveEvent(QMouseEvent* event) override
    {
        if (isPanning)
        {
            QPointF delta = mapToScene(event->pos()) - mapToScene(lastMousePos);
            QPointF newCenter = mapToScene(viewport()->rect().center()) - delta;
            centerOn(newCenter);
            lastMousePos = event->pos();
            event->accept();
        }
    }

    void mouseReleaseEvent(QMouseEvent* event) override
    {
        if (event->button() == Qt::LeftButton)
        {
            isPanning = false;
            setCursor(Qt::ArrowCursor);
            event->accept();
        }
    }

    void resizeEvent(QResizeEvent* event) override
    {
        QGraphicsView::resizeEvent(event);
        if (!pixmapItem->pixmap().isNull())
        {
            resetView();
            scene->setSceneRect(viewport()->rect());
        }
    }

    // Override to block automatic scrolling only during zoom
    void scrollContentsBy(int dx, int dy) override
    {
        if (!isZooming)
            QGraphicsView::scrollContentsBy(dx, dy);
    }

private:
    QGraphicsScene* scene;
    QGraphicsPixmapItem* pixmapItem;
    bool firstImage = true;
    bool isPanning = false;
    bool isZooming = false;
    QPoint lastMousePos;
    qreal baseScale;
    qreal currentScale;

    void resetView()
    {
        // Reset transform
        setTransform(QTransform());

        // Calculate scale to fit view
        QRectF viewRect = viewport()->rect();
        QRectF imageRect = pixmapItem->boundingRect();

        qreal scaleX = viewRect.width() / imageRect.width();
        qreal scaleY = viewRect.height() / imageRect.height();
        baseScale = qMin(scaleX, scaleY);
        currentScale = baseScale;

        // Center the pixmap in the view
        pixmapItem->setPos((viewRect.width() - imageRect.width() * baseScale) / 2.0,
            (viewRect.height() - imageRect.height() * baseScale) / 2.0);

        // Apply base scale
        QTransform transform;
        transform.scale(baseScale, baseScale);
        setTransform(transform);
    }
};



class Widget: public QWidget
{
    Q_OBJECT
public:
    Widget()
    {
        ZoomGraphicsView* view = new ZoomGraphicsView;
        QHBoxLayout* layout = new QHBoxLayout(this);
        layout->addWidget(view);

        QFile file("test.png");
        file.open(QIODevice::ReadOnly);
        QImage image;
        image.load(&file, "PNG");
        file.close();

        view->setFixedSize(image.size());
        view->setImage(image);
    }
}

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    Widget w;
    w.show();

    return a.exec();
}

Solution

  • Basically, you want to ensure that the corners your scene do not go in the middle of you view. A simple centerScene method can ensure that for both the wheelEvent and mouseMoveEvent methods.

    While we are at it, there is some room to shorten your code. Zooming in/out with a fixed point is usually done with this simple 3-step trick:

    1. Move the origin to the desired fixed point (the pixel under the cursor in your case) with a translation.
    2. Scale up or down.
    3. Do the opposite translation.

    So long as centerScene does not decide the scene must be moved, this makes it very easy to have a fixed point under the cursor.

    This results in the adjusted wheelEvent and mouseMoveEvent methods + additional centerScene:

    void wheelEvent(QWheelEvent* event) override
    {
        if (pixmapItem->pixmap().isNull())
            return;
            
        // Store cursor position relative to scene
        QPointF mousePos        = event->position(),
                mousePosCurrent = mapToScene(mousePos.x(), mousePos.y());
    
        // Calculate zoom factor
        double factor = pow(1.5, event->angleDelta().y() / 240.0);
        double newScale = currentScale * factor;
        if (newScale < 1.) { // Do not allow zooming out (in absolute)
            factor /= newScale;
            newScale = 1.;
        }
        else if (newScale > 40.) { // Do not allow zoom > 40x
            factor = factor * 40. / newScale;
            newScale = 40.;
        }
    
        // Zoom centered on the mouse cursor
        QTransform t;
        t.translate(mousePosCurrent.x(), mousePosCurrent.y());
        t.scale(factor, factor);
        t.translate(-mousePosCurrent.x(), -mousePosCurrent.y());
        setTransform(t, true);
            
        currentScale = newScale;
        centerScene();
    
        event->accept();
    }
    
    void mouseMoveEvent(QMouseEvent* event) override
    {
        if (isPanning)
        {
            QPointF delta = mapToScene(event->pos()) - mapToScene(lastMousePos);
            QTransform t;
            t.translate(delta.x(), delta.y());
            setTransform(t, true);
            centerScene();
            lastMousePos = event->pos();
            event->accept();
        }
    }
    
    void centerScene()
    { // First check if the top left corner is in the middle of the view.
        QPointF topLeft = mapFromScene(0, 0);
        if (topLeft.x() > 0 || topLeft.y() > 0) {
            QTransform t;
            t.translate(
                topLeft.x() > 0 ? -topLeft.x() / currentScale : 0,
                topLeft.y() > 0 ? -topLeft.y() / currentScale : 0
            );
            setTransform(t, true);
        }
        else { // If not, then check if perhaps the bottom right corner is.
            QPointF bottomRight = mapFromScene(width(), height());
            if (bottomRight.x() < width() || bottomRight.y() < height()) {
                QTransform t;
                t.translate(
                    bottomRight.x() < width() ? -(bottomRight.x() - width()) / currentScale : 0,
                    bottomRight.y() < height() ? -(bottomRight.y() - height()) / currentScale : 0
                );
                setTransform(t, true);
            }
        }
    }
    

    NB:

    1. centerScene relies on the fact zooming out stops when it cancels the current zoom (if any).
      If you were to allow it, you would need to implement something to e.g. align the image in the center of the widget.
    2. I have implemented the same (I think) range of allowed zoom levels as you but you may have noticed that I let the rest of the method execute when hitting the limits. This is to ensure the view does not get blocked by floating-point rounding issues (e.g. refusing to zoom out because it calculates 0.99999999999942). Feel free to modify.