Search code examples
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, borders including. They need to stay at the corner of your view or outside of it. For that, a simple centerScene method can fix the behavior of 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. Translate the origin to the desired fixed point (the pixel under the cursor).
    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.
    Even when it does, hopefully, the result will look natural.

    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());
    
        constexpr double minZoom = 1., maxZoom = 40.;
    
        // Calculate zoom factor
        double factor = pow(1.5, event->angleDelta().y() / 240.0);
        double newScale = currentScale * factor;
        if (newScale < 0.99 * minZoom || newScale > 1.01 * maxZoom) // Do not allow zooming outside desired range
            return;
        else if (newScale < minZoom) { //The zoom may be 0.999999... due to floating point error -> change it back to 1.0  
            factor /= newScale;
            newScale = minZoom;
        }
        else if (newScale > maxZoom) { // Do not allow zoom > 40x
            factor = factor * maxZoom / newScale;
            newScale = maxZoom;
        }
    
        // 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 "positive" (>100%) zoom level (if any).
      If you were to allow "negative" (<100%) zoom levels, 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 to change it if not to your liking.
    3. FYI, none of your Q_OBJECT macros were needed since you are not declaring signals/slots in your classes nor are you doing introspection.