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
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();
}
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:
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:
centerScene
relies on the fact zooming out stops when it cancels the current zoom (if any).0.99999999999942
). Feel free to modify.