Attempting QML ListView with pinch zoom: #1

Posted on August 5th, 2017

Pinch-zoomEver since I’ve made my first QML mobile application, FlickrUp for Nokia Symbian, I’ve tried to make an image viewer having list of images that can be pinch zoomed. Something similar to gallery applications. I’ve made a number of half-hearted attempts to make it but had given up maybe a little too soon.

Recently I was working for a client who required something similar for their QML application and finally I had to devote more time and energy on it. Sadly, there is nothing similar to this on web or maybe it is just me not being able to find it.

Anyway, here is my attempt to make a listview like QML control having feature of pinch zooming its item, built using Repeater class of QML.

ZoomableRepeaterListView

Source: ZoomableRepeaterListView

import QtQuick 2.8
import QtQuick.Controls 2.2

Item {
    id: root
    property real zoomFactor: 0.8
    property real itemWidth: 0
    property real itemHeight: 0
    property real itemRootHeight: 0
    property int currentIndex: 0
    property alias model: items.model
    function changeZoom(type) {
        var zoomPercent = root.zoomFactor*100;
        if((type0 && zoomPercent>=400)) return;
        if(zoomPercent<200) { zoomPercent = (Math.round(zoomPercent/10)*10)+(type*10) } else if(zoomPercent>=200 && zoomPercent<300) { zoomPercent += type*20; } else if(zoomPercent>=200 && zoomPercent<300) { zoomPercent += type*20; } else if(zoomPercent>=300) {
            zoomPercent += type*30;
        }
        if(zoomPercent<25) zoomPercent = 25; if(zoomPercent>400)
            zoomPercent = 400;
        var zText = qsTr("%1%").arg(zoomPercent);
    }

    function changeIndex(type) {
        var item = -1
        if(items.count>0) {
            item = root.currentIndex+1+type;
            if(item>0 && item<=items.count) flickable.updateContentY(item-1); } } function changeIndexByNumber(item) { if(items.count>0) {
            if(item>0 && item<=items.count) flickable.updateContentY(item-1); } } anchors.fill: parent Item { id: mainContent clip: true anchors.fill: parent Rectangle { id: mainContentBG anchors.fill: parent } Flickable { id: flickable property real lastContentXRatio: 0 property real lastContentYRatio: 0 clip: true anchors { fill: parent margins: 10 } ScrollIndicator.horizontal: ScrollIndicator { active: flickable.contentWidth>flickable.width }
            ScrollIndicator.vertical: ScrollIndicator { active: flickable.contentHeight>flickable.height }
            contentWidth: flickableContainer.width
            contentHeight: flickableContainer.height

            function updateContentRatio() {
                lastContentXRatio = contentX==0?0:contentX/contentWidth;
                lastContentYRatio = contentY==0?0:contentY/contentHeight;
            }

            function updateContentY(itemIndex) {
                contentY = itemIndex>0?(root.itemHeight+flickableContainer.spacing)*itemIndex:0;
                updateContentRatio();
            }

            onFlickEnded: {
                flickable.updateContentRatio();
            }
            onContentWidthChanged: {
                if(interactive && contentWidth>width && lastContentXRatio!=0)
                    contentX = contentWidth*lastContentXRatio;
            }
            onContentHeightChanged: {
                if(interactive && contentHeight>height && lastContentYRatio!=0)
                    contentY = contentHeight*lastContentYRatio;
            }

            Column {
                id: flickableContainer
                spacing: 10
                anchors {
                    top: parent.top
                    left: parent.left
                }

                Repeater {
                    id: items
                    Item {
                        id: itemRoot
                        anchors.horizontalCenter: parent.horizontalCenter
                        width: image.width
                        height: image.height
                        onHeightChanged: root.itemRootHeight = height

                        PinchArea {
                            id: imagePinchArea
                            width: image.width
                            height: image.height
                            anchors.centerIn: parent

                            SequentialAnimation {
                                id: flickableInteractiveDelayAnimation
                                property alias flickableInteractive: flickableInteractiveAction.value
                                PauseAnimation {
                                    duration: 200
                                }
                                PropertyAction {
                                    id: flickableInteractiveAction
                                    target: flickable; property: "interactive"; value: true
                                }
                            }

                            property real startZoomFactor: 0
                            property var pinchFlickableCenter: 0
                            property real startY: 0
                            onPinchStarted: {
                                flickable.cancelFlick();
                                flickable.interactive = false;
                                startZoomFactor = root.zoomFactor;
                                pinchFlickableCenter = flickable.mapFromItem(imagePinchArea, pinch.startCenter.x, pinch.startCenter.y);
                                startY = flickable.contentY+pinchFlickableCenter.y;
                            }

                            onPinchUpdated: {
                                var minZoomFactor = image.getMinimumZoom();
                                var zoomFactor = startZoomFactor+pinch.scale-1;
                                if(Math.abs(zoomFactor-root.zoomFactor)>0.09) return;
                                var spacing = index*flickableContainer.spacing;
                                root.zoomFactor = zoomFactor<minZoomFactor?minZoomFactor:zoomFactor>4.0?4.0:zoomFactor;
                                flickable.contentX = Math.max(0, pinch.startCenter.x*root.zoomFactor/startZoomFactor-pinchFlickableCenter.x);
                                flickable.contentY = Math.max(0, ((startY-spacing)*root.zoomFactor/startZoomFactor-pinchFlickableCenter.y)+spacing);
                            }

                            onPinchFinished: {
                                // Move its content within bounds.
                                flickable.returnToBounds();
                                flickableInteractiveDelayAnimation.start();
                            }
                            Image {
                                id: image

                                SequentialAnimation {
                                    id: flickableBoundsAnimation
                                    PauseAnimation {
                                        duration: 100
                                    }
                                    ScriptAction {
                                        script: flickable.returnToBounds();
                                    }
                                }

                                function getMinimumZoom() {
                                    if(image.status!=Image.Ready) return 0.25;
                                    var zBestFit, zPageFit, zPageWFit;
                                    var zActualFit = 1.0;
                                    var rC = flickable.width/flickable.height;
                                    var rI = sourceSize.width/sourceSize.height;
                                    if(rC>rI)
                                        zPageFit = flickable.height/sourceSize.height;
                                    else
                                        zPageFit = flickable.width/sourceSize.width;
                                    var eFit = 0;//(flickable.width/sourceSize.width)*0.8;
                                    zBestFit = Math.max(zPageFit, eFit);
                                    zPageWFit = flickable.width/sourceSize.width;

                                    return Math.min(0.25, zBestFit, zActualFit, zPageFit, zPageWFit);
                                }

                                function getBestFitZoom() {
                                    if(image.status!=Image.Ready) return 1.0;
                                    var zPageFit;
                                    var rC = flickable.width/flickable.height;
                                    var rI = sourceSize.width/sourceSize.height;
                                    if(rC>rI)
                                        zPageFit = flickable.height/sourceSize.height;
                                    else
                                        zPageFit = flickable.width/sourceSize.width;
                                    var eFit = (flickable.width/sourceSize.width)*0.8;
                                    return Math.max(zPageFit, eFit);
                                }

                                function fixZoom() {
                                    if(image.status!=Image.Ready || flickable.width==0) return;
                                    if(image.status==Image.Ready) {
                                        root.zoomFactor = getBestFitZoom();
                                    }
                                    flickableBoundsAnimation.start();
                                }

                                function fixFlickableContentWidth() {
                                    flickableContainer.width = width>flickable.width?width:flickable.width
                                    flickable.contentWidth = width>flickable.width?width:flickable.width
                                }

                                clip: true
                                anchors.centerIn: parent
                                //rotation: pRotation //NOTE: For testing
                                source: url
                                width: status==Image.Ready?sourceSize.width*root.zoomFactor:flickable.width*root.zoomFactor
                                onWidthChanged: {
                                    image.fixFlickableContentWidth(); //Since, it will not changing automatically
                                    root.itemWidth = width;
                                }
                                height: status==Image.Ready?sourceSize.height*root.zoomFactor:flickable.height
                                onHeightChanged: root.itemHeight = height

                                Connections {
                                    enabled: index==0
                                    target: flickable
                                    onWidthChanged: {
                                        image.fixFlickableContentWidth(); //Since, it will not changing automatically
                                        image.fixZoom();
                                    }
                                    onHeightChanged: {
                                        image.fixZoom();
                                    }
                                }
                                onStatusChanged: {
                                    image.fixZoom();
                                }
                            }

                            MouseArea {
                                anchors.fill: parent
                                acceptedButtons: Qt.LeftButton | Qt.RightButton
                                onClicked: {
                                }
                                onPressAndHold: {
                                }
                                onDoubleClicked: {
                                    flickable.interactive = false;
                                    var pinchFlickableCenter = flickable.mapFromItem(imagePinchArea, mouseX, mouseY);
                                    var pX = mouseX;
                                    var startZoomFactor = root.zoomFactor;
                                    var pY = flickable.contentY+pinchFlickableCenter.y;
                                    var spacing = index*flickableContainer.spacing;
                                    if(startZoomFactor==1.0) {
                                        root.zoomFactor = image.getBestFitZoom();
                                    } else
                                        root.zoomFactor = 1.0;
                                    var newY = ((pY-spacing)*root.zoomFactor/startZoomFactor)+spacing;
                                    flickable.contentX = Math.max(0, pX*root.zoomFactor/startZoomFactor-pinchFlickableCenter.x);
                                    flickable.contentY = Math.max(0, newY-pinchFlickableCenter.y);
                                    flickable.returnToBounds();
                                    flickableInteractiveDelayAnimation.start();
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

It works okay but has a lot of limitations.

Firstly, all images needs to be of same size.

It doesn’t supports orientation property so different file for different orientation.

Still better than nothing, it supports pinch zoom and double tap zoom and I was able to get following look and implementation on Android,

Android Screenshot

It can be used in following manner,

ZoomableRepeaterVerticalListView {
    anchors.fill: parent
    model: ListModel {
        id: listModel
        ListElement { pID: 1; url: "qrc:/assets/images/sample.jpg" }
        ListElement { pID: 2; url: "qrc:/assets/images/sample.jpg" }
        ListElement { pID: 3; url: "qrc:/assets/images/sample.jpg" }
        ListElement { pID: 4; url: "qrc:/assets/images/sample.jpg" }
    }
}

I’ll re-attempt, trying to overcome these limitations especially the limitation of same sized images.

No Comments to “Attempting QML ListView with pinch zoom: #1”

Leave a Comment