Slideshow style item navigation with zooming in of current item



  • This is a followup to an earlier (solved) question of mine. Since then, the requirements have changed somewhat.

    Now we need to create a user interface where items are arranged as a horizontal list at the top. It must be possible to scroll through them and select one as the current item. The current item should then move out of the list and be maximized in the middle, similar to what the slideshow.qml example in examples/quick/views/visualdatamodel/ does.

    The horizontal list I can do with a ListView. It is the current item zooming that is a problem. I could "steal" that item from the ListView and reparent it to the underlying item. But the problem is that later I might select a different item, so the current item should move back in at the same location in the list it was earlier, so it has to remember its previous place somehow. I am not sure if ListView can handle that.

    I was thinking of solving this by placing each item in a "container" item. So, the ListView delegate is then this container item, with the actual visible item inside. When maximizing the current item, I reparent the visible item, and the container stays in the ListView. To make it seem as if the item was moved out, I could then also set the width of the container item to 0.

    Does this sound reasonable, or is there a simpler way? slideshow.qml does not use a ListView, this is why I am asking. Also, would a container item width of 0 potentially cause problems?

    Also please note that the visible items are a custom item type that must not be created more often than necessary. This is because it does some custom OpenGL painting (similar to what the openglunderqml example does) to render objects that have a texture on them that is filled with video frames. (This is done with custom GStreamer code, not with QtMultimedia.) More importantly, this has to be able to run on embedded i.MX6 hardware, so it is crucial to not have more open video streams than necessary, so one data model item must not be represented by more than one delegate. This is why I prefer to shift around existing items instead of creating new ones.



  • You might try a Tumbler but horizontal.

    Here is a working example:

    import QtQuick 2.9
    import QtQuick.Controls 2.2
    
    
    Tumbler {
      id: tumbler
    
      property real factor: 15
    
      SystemPalette { id: activPal; colorGroup: SystemPalette.Active }
    
      width: 800  //parent.width
      height: factor * 10
      visibleItemCount: Math.min(((width / (factor * 7)) / 2) * 2 - 1, 7)
      model: 15
      delegate: Component {
        Column {
          spacing: factor / 4
          opacity: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
          scale: 1.7 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
          Text {
            font { pixelSize: factor * 3 }
            text: modelData + 1
            anchors.horizontalCenter: parent.horizontalCenter
            color: tumbler.currentIndex === modelData ? activPal.highlightedText : activPal.text
          }
          Text {
            anchors.horizontalCenter: parent.horizontalCenter
            width: factor * 8
            text: modelData * 1008
            horizontalAlignment: Text.AlignHCenter
            color: activPal.text
            font { bold: tumbler.currentIndex === modelData; pixelSize: factor * 0.8 }
          }
        }
      }
      contentItem: PathView {
        id: pathView
        model: tumbler.model
        delegate: tumbler.delegate
        clip: true
        pathItemCount: tumbler.visibleItemCount + 1
        preferredHighlightBegin: 0.5
        preferredHighlightEnd: 0.5
        dragMargin: width / 2
        path: Path {
          startX: 0
          startY: factor * 1.4
          PathLine {
            x: pathView.width
            y: factor * 1.4
          }
        }
      }
      Rectangle {
        z: -1; width: factor * 9; height: parent.height * 0.5
        x: parent.width / 2 - width / 2; y: 2
        color: activPal.highlight
        radius: width / 12
      }
    }
    
    

    I plucked it from my code, so there can be more stuff than necessary and a bit messy, but You may see 'in action' is this approach suits You.



  • @SeeLook Wow, this is perfect! I did not know the Tumbler exists. I'm still a beginner at QML. I just have to arrange the items a bit to be more vertically centered, but otherwise this is even better than my idea, because the selected element does not have to be zoomed in! (I guess I could still do that if I want a 100% maximized mode, but I can live without it for now, I think the tumbler will suffice.) Thanks!

    EDIT: To make it even better, it would be good if I could click on an item and the tumbler would scroll to it, as an addition to the dragging motion. I figure that I'd have to add a custom onClick handler to the contentItem that sets accepted to false, and then, in a MouseArea in the delegate, I'd have to set the Tumbler's current index to the index of the clicked element?



  • @dv__
    I tried to add 'custom' click but the tumbler stopped working then.
    But I didn't put much efforts to search why. Scrolling only is sufficient for me for now.
    ... but if You will find how to make it either scrollable and clickable, please write.



  • @SeeLook I got it to work like this. However, I'm now not so sure if the Tumbler is really the way to go. What we are doing here is essentially bending the Tumbler . The actual items are still "vertical", just in the same row. The child items from these items are repositioned, and they are what we see. However, to really zoom in the current video, the adjacent items would have to be scaled down pretty hard so they don't consume too much width. Probably doable by adjusting the visibleItemCount formula, but so far I ended up with cases where the items overlap, and I can't afford that, because the 3D objects in my custom quickitems are being drawn directly with GL..

    Window {
    	id: window
    	visible: true
    	width: 800
    	height: 600
    	property var itemWidth: 200
    	property var itemHeight: 200
    
    	ListModel {
    		id: nameModel
    		ListElement { name: "Alice" }
    		ListElement { name: "Bob" }
    		ListElement { name: "Jane" }
    		ListElement { name: "Peter" }
    		ListElement { name: "James" }
    		ListElement { name: "A" }
    		ListElement { name: "B" }
    		ListElement { name: "C" }
    		ListElement { name: "D" }
    	}
    
    	Component {
    		id: itemDelegate
    		Item {
    			opacity: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
    			scale: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2) * 0.7
    			Rectangle {
    				anchors.horizontalCenter: parent.horizontalCenter
    				anchors.verticalCenter: parent.verticalCenter
    				width: window.itemWidth
    				height: window.itemHeight
    				border.color: "red"
    				border.width: 5
    				color: "green"
    				Text {
    					anchors.horizontalCenter: parent.horizontalCenter
    					anchors.verticalCenter: parent.verticalCenter
    					text: name
    				}
    				MouseArea {
    					anchors.fill: parent
    					onClicked: {
    						console.log("New current index: " + index);
    						tumbler.currentIndex = index
    					}
    				}
    			}
    		}
    	}
    
    	Tumbler {
    		id: tumbler
    		anchors.fill: parent
    		model: nameModel
    		visibleItemCount: Math.floor(width / window.itemWidth - 1) | 1 // bitwise OR to make sure the item count is always odd and at least 1
    		delegate: itemDelegate
    		contentItem: PathView {
    			model: tumbler.model
    			delegate: tumbler.delegate
    			clip: true
    			pathItemCount: tumbler.visibleItemCount
    			preferredHighlightBegin: 0.5
    			preferredHighlightEnd: 0.5
    			dragMargin: width / 2
    			path: Path {
    				startX: 0
    				startY: tumbler.height / 2
    				PathLine {
    					x: tumbler.width
    					y: tumbler.height / 2
    				}
    			}
    		}
    	}
    }
    


  • And here is a third version. This one allows for maximizing the current item. To make sure it isn't maximized while the tumbler's pathview is moving, I temporarily create a connection to onMovementEnded and tear it down once I maximized.

    Unclear yet:

    1. disconnect() a non-connected function - is this ok?
    2. On touchscreens, does onMovementEnded() trigger when the items really don't move anymore? The documentation makes it sound as if it is triggered when the user no longer flicks the view, that is, releases the finger - but the items are still moving then.
    3. What if I delete the current item? What if it is currently maximized?
    import QtQuick 2.0
    import QtQuick.Controls 2.0
    import QtQuick.Layouts 1.3
    import QtQuick.Window 2.0
    
    Window {
    	id: window
    	visible: true
    	width: 800
    	height: 600
    	property var itemWidth: 200
    	property var itemHeight: 200
    
    	ListModel {
    		id: nameModel
    		ListElement { name: "Alice" }
    		ListElement { name: "Bob" }
    		ListElement { name: "Jane" }
    		ListElement { name: "Peter" }
    		ListElement { name: "James" }
    		ListElement { name: "A" }
    		ListElement { name: "B" }
    		ListElement { name: "C" }
    		ListElement { name: "D" }
    	}
    
    	Component {
    		id: itemDelegate
    		Item {
    			opacity: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
    			scale: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2) * 0.7
    			z: (tumbler.model.count - Math.abs(index - tumbler.currentIndex)) / tumbler.model.count
    			property alias maximized: itemDelegateRect.maximized
    			property alias innerWidth: itemDelegateRect.width
    			property alias innerHeight: itemDelegateRect.height
    			Rectangle {
    				id: itemDelegateRect
    				anchors.horizontalCenter: parent.horizontalCenter
    				anchors.verticalCenter: parent.verticalCenter
    				property bool maximized: false
    				width: window.itemWidth
    				height: window.itemHeight
    				border.color: "red"
    				border.width: 5
    				color: "green"
    				Text {
    					anchors.horizontalCenter: parent.horizontalCenter
    					anchors.verticalCenter: parent.verticalCenter
    					text: name
    				}
    				MouseArea {
    					anchors.fill: parent
    					onClicked: {
    						console.log("New current index: " + index);
    						tumbler.currentIndex = index
    					}
    				}
    				Behavior on width { NumberAnimation { duration: 300; easing.type: Easing.InOutCubic } }
    				Behavior on height { NumberAnimation { duration: 300; easing.type: Easing.InOutCubic } }
    			}
    		}
    	}
    
    	function toggleMaximize() {
    		var item = tumbler.currentItem;
    		if (item.maximized) {
    			item.innerWidth = Qt.binding(function() { return window.itemWidth; });
    			item.innerHeight = Qt.binding(function() { return window.itemHeight; });
    			tumbler.contentItem.interactive = true;
    		} else {
    			item.innerWidth = Qt.binding(function() { return tumbler.width; });
    			item.innerHeight = Qt.binding(function() { return tumbler.height; });
    			tumbler.contentItem.interactive = false;
    		}
    		item.maximized = !(item.maximized);
    		tumbler.contentItem.onMovementEnded.disconnect(toggleMaximize);
    	}
    
    	ColumnLayout {
    		anchors.fill: parent
    		Tumbler {
    			id: tumbler
    			model: nameModel
    			visibleItemCount: Math.floor(width / window.itemWidth - 1) | 1
    			delegate: itemDelegate
    			contentItem: PathView {
    				model: tumbler.model
    				delegate: tumbler.delegate
    				clip: true
    				pathItemCount: tumbler.visibleItemCount
    				preferredHighlightBegin: 0.5
    				preferredHighlightEnd: 0.5
    				dragMargin: width / 2
    				interactive: true
    				snapMode: PathView.SnapToItem
    				path: Path {
    					startX: 0
    					startY: tumbler.height / 2
    					PathLine {
    						x: tumbler.width
    						y: tumbler.height / 2
    					}
    				}
    			}
    			Layout.fillWidth: true
    			Layout.fillHeight: true
    		}
    
    		Button {
    			id: maximizeButton
    			text: "Toggle maximize"
    			Layout.fillWidth: true
    			Layout.fillHeight: false
    
    			onClicked: {
    				if (tumbler.contentItem.moving)
    					tumbler.contentItem.onMovementEnded.connect(toggleMaximize);
    				else
    					toggleMaximize();
    			}
    		}
    	}
    }
    


  • @dv__ Thanks for sharing your experience. My 'bended' Tumbler also reacts for clicks now.
    BTW, I think all QML controls are 'meant to be bend' , so as far as one doesn't break - it is allowed.

    In 'our' examples the crucial part is the PathView which does all tricks. It just uses model and delegate from the tumbler.

    I noticed that also in Your example hitting 'toggle maximize' button without touching tumbler before, causes maximizing wrong item (not this one in the middle). My workaround is to use a timer as follow:

    Timer { // workaround to properly select 0 item, call it with delay
        running: true
        interval: 50
        onTriggered: tumbler.currentIndex = 0
      }
    


  • Hmm good catch!

    Although it turns out that I can't flick the list, because the items require some custom mouse move handling ... which reduces it to a non-interactive PathView. So, solved I guess! Thanks for pointing out the Tumbler though, and I hope my examples prove to be useful to others.



Looks like your connection to Qt Forum was lost, please wait while we try to reconnect.