Search code examples
javascriptgridviewqmlgrid-layoutmasonry

QML Staggered (Masonry) GridView or Flow


I've come across the same issue raised in the following question:

How does one create a staggered grid view in QML?

I am looking to implement either a Flow GridLayout or GridView in a Masonry style, see attached pictures of current output and desired.

CURRENT:

Current output

DESIRED:

Desired output

I am not looking to use an external library for this; instead work purely with QML and JavaScript.

I have tried things such as a Flow within a Flickable:

Flickable
{
    anchors.fill: parent
    anchors.margins: 5
    contentHeight: flow.height

    Flow {
        id: flow
        width: parent.width
        height: 800
        spacing: 10
        flow: Flow.TopToBottom
        Repeater{
            model: [80,60,120,75,90,55,140,50,70,90,80,60,120,75,90]
            Rectangle {
                height: modelData
                width: 100
                border.color: "black"
            }
        }
    }
}

Also with my GridLayout working with the preferredHeight and fillHeight:

Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredWidth: Layout.columnSpan
Layout.preferredHeight: Layout.rowSpan

However both present the problem of the rowHeight being set to the tallest object per row.

MY QUESTION IS

How can I create unequal cell size in a grid where the rows are not locked to the tallest object for a masonry view?

The only 'hacky' solution that's come to mind is now have two lists, one whose model is every odd index, the other every even index attached to the same flickable so they scroll as one object, but surely there must be a better way to achieve a masonry view with no whitespace? I have tested this and it does work; (see below) but again, is there a better way to implement?

    function isEven(n) {
        return n % 2 == 0;
    }

    function isOdd(n) {
        return Math.abs(n % 2) == 1;
    }

    JsonListModel {
        id: jsonModel; source: userFeed; keyField: "id"
        fields: ["id", "owner", "downloadUrl","profile_Pic_URL", "tag", "timestamp","post_description", "team", "liked_by", "location"]
    }
    SortFilterProxyModel {
        id: sortedModelOdd;
        Component.onCompleted: {app.userFeedChanged();sourceModel = jsonModel}
        filters: [
            ExpressionFilter {expression: model.tag === exploreFilter},
            ExpressionFilter {expression: isOdd(index)}
        ]
        sorters: RoleSorter {roleName: "timestamp"; ascendingOrder: false}
    }
    SortFilterProxyModel {
        id: sortedModelEven;
        Component.onCompleted: {app.userFeedChanged();sourceModel = jsonModel}
        filters: [
            ExpressionFilter {expression: model.tag === exploreFilter},
            ExpressionFilter {expression: isEven(index)}
        ]
        sorters: RoleSorter {roleName: "timestamp"; ascendingOrder: false}
    }

// i create 2 versions of the same model by using my expressionFilter for odds/evens

    ScrollView {
        anchors.fill: parent
        Row {
            anchors.fill: parent
            spacing: dp(5)
            anchors.margins: dp(5)
            AppListView {
                id: evenModelView
                model: sortedModelEven; emptyText.text: qsTr("No posts yet!"); scale: 0.96
                width: parent.width/2; spacing: dp(5); scrollIndicatorVisible: false;
                delegate: AppImage {width: parent.width; fillMode: Image.PreserveAspectFit; source: model.downloadUrl}
            }
            AppListView {
                id: oddModelView
                model: sortedModelOdd; emptyText.text: qsTr("No posts yet!"); scale: 0.96
                width: parent.width/2; spacing: dp(5);scrollIndicatorVisible: false;
                delegate: AppImage {width: parent.width; fillMode: Image.PreserveAspectFit; source: model.downloadUrl}
            }
        }
    }

Solution

  • I have found a solution (which I touched on in my question - and refined it) so will post as answer but still happy to hear if anyone achieves this result using actual Layouts ect.

        JsonListModel {
            id: jsonModel; source: jsonArray; keyField: "id"
            fields: ["id", "owner", "downloadUrl","profile_Pic_URL", "tag", "timestamp","post_description", "team", "liked_by", "location"]
        }
        SortFilterProxyModel {
            id: sortedModelOdd; Component.onCompleted: {sourceModel = jsonModel}
            filters: [ExpressionFilter {expression: model.tag === exploreFilter; enabled: exploreFilter !== "Everything"}, ExpressionFilter {expression: isOdd(index)}]
            sorters: RoleSorter {roleName: "timestamp"; ascendingOrder: false}
        }
        SortFilterProxyModel {
            id: sortedModelEven; Component.onCompleted: {sourceModel = jsonModel}
            filters: [ExpressionFilter {expression: model.tag === exploreFilter; enabled: exploreFilter !== "Everything"}, ExpressionFilter {expression: isEven(index)}]
            sorters: RoleSorter {roleName: "timestamp"; ascendingOrder: false}
        }
    
    
        AppFlickable {
            id: flickable
            anchors.fill: parent; contentHeight: scrollRow.height + dp(Theme.navigationBar.height)*2; anchors.topMargin: scrollModel.height
            Row {
                id: scrollRow; width: parent.width
                Column {
                    id: col; spacing: dp(5); width: parent.width/2
                    Behavior on y {
                        NumberAnimation {properties: "y"; duration: 1000 }
                    }
                    Repeater {
    
                        id: evenModelView; model: sortedModelEven;
                        delegate: AppCard {
                            id: evenImage; width: (parent.width)-dp(2); margin: dp(5); paper.radius: dp(5); scale: 0.96;
                            media: AppImage {
                                width: parent.width; fillMode: Image.PreserveAspectFit; source: model.downloadUrl; autoTransform: true
                                MouseArea {anchors.fill: parent; onPressAndHold: PictureViewer.show(homePage, model.downloadUrl); onReleased: PictureViewer.close(); onClicked: {exploreStack.push(viewPostComp, {postID: model.id})}}
                            }
                            content: AppText {
                                width: parent.width; padding: dp(15); maximumLineCount: 2; elide: Text.ElideRight; wrapMode: Text.Wrap; text: model.owner.username
                                MouseArea {anchors.fill: parent; onClicked: exploreStack.push(otherUserComp, {userID: model.owner.id})}
                            }
                            actions: Row {
                                IconButton {
                                    icon: {
                                        var likedList = Object.keys(model.liked_by.list)
                                        likedList.indexOf(userData.id) ? IconType.heart : IconType.hearto}
                                    onClicked: {likedList.indexOf(userData.id) ? dataModel.likePost(model.id, false, model.liked_by.count, model.owner.id) : dataModel.likePost(model.id, true, model.liked_by.count, model.owner.id)}
                                }
                            }
                        }
                    }
                }
                Column {
                    id: oddCol; spacing: dp(5); width: parent.width/2
                    Repeater {
                        id: oddModelView; model: sortedModelOdd;
                        delegate: AppCard {
                            id: oddImage; width: (parent.width)-dp(2); margin: dp(5); paper.radius: dp(5); scale: 0.96;
                            media: AppImage {
                                width: parent.width; fillMode: Image.PreserveAspectFit; source: model.downloadUrl; autoTransform: true
                                MouseArea {anchors.fill: parent; onPressAndHold:  PictureViewer.show(homePage, model.downloadUrl); onReleased: PictureViewer.close(); onClicked: {exploreStack.push(viewPostComp, {postID: model.id})}}
                            }
                            content: AppText{
                                width: parent.width; padding: dp(15); maximumLineCount: 2; elide: Text.ElideRight; wrapMode: Text.Wrap; text: model.owner.username;
                                MouseArea {anchors.fill: parent; onClicked: exploreStack.push(otherUserComp, {userID: model.owner.id})}
                            }
                            actions: Row {
                                IconButton {
                                    icon: {
                                        var likedList = Object.keys(model.liked_by.list)
                                        likedList.indexOf(userData.id) ? IconType.heart : IconType.hearto}
                                    onClicked: {likedList.indexOf(userData.id) ? dataModel.likePost(model.id, false, model.liked_by.count, model.owner.id) : dataModel.likePost(model.id, true, model.liked_by.count, model.owner.id)}
                                }
                            }
                        }
                    }
                }
            }
        }
    

    Notes:

    I used a ScrollView initially instead of a Flickable, however this caused serious performance issues.

    I placed this inside of a Loader which did resolve the issue somewhat as it wasn't tearing into performance at runtime, I then changed to a Flickable, which has caused 0 issues thus far (yet to test with a large model).

    Each AppListView needs it's interactive disabled otherwise sometimes scrolling can catch onto only one of the columns and put them out of sync.

    (See picture of implemented result) Dev_Screenshot of style achieved