Search code examples
qtqmlzooming

Qml Image inside Flickable zoom shifts (and clips)


I am trying to add zoom controls to my image. I tried to start as simple as possible, but anything I try ends up clipping the image.

The image is loaded in a part of the screen, and I want to place it in a Flickable, to be able to zoom and pan. I will add pinch and mouse wheel later, for now I have buttons to set various scale values.

The problem: when zoomed, the image is shifted and clipped. The shift is bothersome, because the zoomed image being bigger than the container, why show the empty space ? But the clipping is much worse, and I don't understand it. Seems the shift is as big as the clip (maybe?).

I tried to change the transformOrigin, not good. I tried to use transform instead of scale, but I saw nothing happen.

import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.0
import QtQuick.Window 2.2

Window {
    width: 1015; height: 550; visible: true
    Item {      // A bunch of buttons to easily test several zoom values and layout is similar
        id: leftSideContainer; width: 100; height: parent.height
        ColumnLayout {
            anchors.fill: parent; Layout.alignment: Qt.AlignCenter;
            Layout.preferredHeight: 40; Layout.fillWidth: true;
            Button { text: "50 %"; onClicked: imageContainer.imageScale = 0.5; }
            Button { text: "80 %"; onClicked: imageContainer.imageScale = 0.8; }
            Button { text: "100 %"; onClicked: imageContainer.imageScale = 1; }
            Button { text: "110 %"; onClicked: imageContainer.imageScale = 1.1; }
            Button { text: "120 %"; onClicked: imageContainer.imageScale = 1.2; }
            Button { text: "150 %"; onClicked: imageContainer.imageScale = 1.5; }
            Button { text: "200 %"; onClicked: imageContainer.imageScale = 2; }
            Button { text: "400 %"; onClicked: imageContainer.imageScale = 4; }
            Button { text: "landscape"; onClicked: { paintedCanvasImage.source = ""; paintedCanvasImage.source = "/examples/landscape.jpg"; } }
            Button { text: "portrait"; onClicked: { paintedCanvasImage.source = ""; paintedCanvasImage.source = "/examples/portrait.jpg"; } }
            Button { text: "square"; onClicked: { paintedCanvasImage.source = ""; paintedCanvasImage.source = "/examples/square.jpg"; } }
        }
    }
    Item {  // Using separate variable and item a bit like my code
        id: imageContainer; property var imageScale: 1;
        anchors { left: leftSideContainer.right; right: parent.right;
                  top: parent.top; bottom: parent.bottom; }
        Rectangle {  // Easy to show background compared to actual image, no other use
            anchors.fill: parent; color: "lightgreen"; }

        onImageScaleChanged: paintedCanvasImage.scale = imageScale;

        Flickable { // I want to pan
            id: flickImage
            anchors.fill: parent

            contentWidth: paintedCanvasImage.paintedWidth * paintedCanvasImage.scale
            contentHeight: paintedCanvasImage.paintedHeight * paintedCanvasImage.scale
            //contentX: ??? I have no idea
            clip: true   // required
            boundsBehavior: Flickable.StopAtBounds
            ScrollBar.vertical: ScrollBar { active: true; policy: ScrollBar.AsNeeded; }
            ScrollBar.horizontal: ScrollBar { active: true; policy: ScrollBar.AsNeeded; }

            Image {
                id: paintedCanvasImage
                width: imageContainer.width; height: imageContainer.height
                source: "/examples/landscape.jpg"  // Trying square, landscape or portrait
                fillMode: Image.PreserveAspectFit   // required
                transformOrigin: Item.TopLeft       // With this, weirdly the vertical fits correctly, horizontal only shifted/clipped
            }
        }
    }
}

Sample run: I will show 100, and 200% top-left and bottom-right, to show clipping and empty area. Note that with transformOrigin: Item.TopLeft the image fits vertically (if the container is more landscape-y than the image, or horizontally otherwise) - but is shifted by some amount that I don't know, to the right:

100 % 200 % top-left with transformOrigin: Item.TopLeft 200 % bottom-right with transformOrigin: Item.TopLeft

I cannot figure out what the transformOrigin should be, or how to use transform instead of scale, or what the flickable's origin must be.
Changing contentX I thought would fix it, but it doesn't - something about the transform must be needed.

Platform: windows as well as embedded linux;
Qt version: 5.12


Solution

  • There are a few mistakes in your code that are causing the problem. First, the clipping issue is due to the absence of transformOrigin: Item.TopLeft, which causes the origin to be the center by default and makes your image scale in negative x and y.
    The shifting issue is caused by the paintedWidth value, which, according to the documentation, can be less than the image item.
    Additionally, using fillMode: Image.PreserveAspectFit will always result in empty spaces around your image if its ratio doesn't fit your container.

    For the solution, I made a few more changes so that you can easily use the wheel or pinch handler.

    In the following code, I added a fitSize property which stores the initial size of the image, and it also calculates the aspect ratio using the sourceSize of the image. I also removed the scaling part of the image to be able to use flickable.resizeContent.

    By multiplying fitSize with iscale, you can get new sizes.

    It may not be best practice, but it works well, and I think you could also use a PinchHandler instead of WheelHandler.

    import QtQuick 2.12
    import QtQuick.Controls 2.12
    
    Page {
        id: root
    
        implicitWidth: 550
        implicitHeight: 300
    
        contentItem: Control {
            id: container
    
            property real iscale: 1
            property size fitSize: {
                const ss = image.sourceSize;
                const ratio = ss.width / ss.height;
                return ratio < width/height ? Qt.size(height * ratio, height) : Qt.size(width, width / ratio);
            }
    
            contentItem: Flickable {
                id: flickable
    
                clip: true
    
                leftMargin: Math.max(0, width - contentWidth) / 2 // Centering the content
                topMargin: Math.max(0, height - contentHeight) / 2
    
                contentWidth: container.fitSize.width
                contentHeight: container.fitSize.height
    
                rebound: Transition {}
                boundsBehavior: Flickable.StopAtBounds
                ScrollBar.vertical: ScrollBar {}
                ScrollBar.horizontal: ScrollBar {}
    
                Image {
                    id: image
    
                    width: flickable.contentWidth
                    height: flickable.contentHeight
                    source: "path/to/image.jpg"
                    onSourceChanged: container.iscale = 1
    
                    MouseArea {
                        hoverEnabled: true
                        anchors.fill: parent
                        onWheel: function(e) {
                            const { width, height } = container.fitSize;
                            const mousePos = Qt.point(mouseX, mouseY);
    
                            container.iscale += e.angleDelta.y / 1200;
                            flickable.resizeContent(width * container.iscale, height * container.iscale, mousePos);
                            flickable.returnToBounds();
                        }
                    }
                }
            }
    
            background: Rectangle { color: "#121314" }
        }
    }