Flixnet theme, Part 4: Making things pretty

In this chapter, we'll try to make the game cells look better!

Fancy game boxes

I'll now replace the green game boxes with something better to look at. There are two main cases we have to support:

So gameAxisDelegate is our game box that right now contains a green rectangle. I'll turn that into an Item, and, for the two cases above, I'll add an initial gray Rectangle and Image:

Component {
    id: gameAxisDelegate

    Item {
        width: vpx(240)
        height: vpx(135)

        Rectangle {
            anchors.fill: parent
            color: "#333"
        }

        Image {
            id: image

            anchors.fill: parent
        }
    }
}

So which image asset should we use? A game box is a rectangle with 16:9 aspect ratio, so the banner would be perfect for this. However, since every asset is potentially missing, we should consider showing other images and provide multiple fallbacks. If we don't have a banner, the next similarly sized one is the steam ("grid icon") asset. Because it's wider than 16:9, we'll need to crop it if we don't want black bars or squashed/stretched images (though you might prefer that). If neither image is available, I'll use boxFront as it tends to be commonly available.

Let's extend the Image object created previously:

Image {
    id: image

    anchors.fill: parent
    visible: source

    // fill the whole area, cropping what lies outside
    fillMode: Image.PreserveAspectCrop

    asynchronous: true
    source: assets.banner || assets.steam || assets.boxFront
    sourceSize { width: 256; height: 256 }
}

I've also made some optimizations here:

With these changes, here's how it looks:

screenshot

Starting to take shape, isn't it?

Let's finish the text-only fallback too:

Component {
    id: gameAxisDelegate

    Item {
        width: vpx(240)
        height: vpx(135)

        Rectangle {
            anchors.fill: parent
            color: "#333"
            visible: image.status !== Image.Ready

            Text {
                text: modelData.title

                // define the text area
                anchors.fill: parent
                anchors.margins: vpx(12)

                // align to the center
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                wrapMode: Text.Wrap

                // set the font
                color: "white"
                font.pixelSize: vpx(16)
                font.family: globalFonts.sans
            }
        }

        Image {
            id: image

            // ...
        }
    }
}

And we're done with the game boxes!

Looping the axes

It'd be nice if all of the lists would loop around. You can do two kinds of loop:

The first one can be done either by simply setting keyNavigationWraps: true for a ListView (and other Views) or using the API's default index increase/decrease functions. In our case though, the carousel option would look the best.

I won't lie, making a carousel-like looping list is annoying and overly complex for this use case; the situation might improve later by creating some easier-to-use custom types in Pegasus.

Vertically

So the problem is, ListView can't do carousels: the only type that can is PathView. As such, we'll turn our ListViews into PathViews next. Again, let's start with the vertical axis; here's a before-after comparison, with some comments after the code:

Before

ListView {
    id: collectionAxis

    anchors.left: parent.left
    anchors.right: parent.right
    anchors.top: parent.verticalCenter
    anchors.bottom: parent.bottom

    model: api.collections
    delegate: collectionAxisDelegate

    snapMode: ListView.SnapOneItem
    highlightRangeMode: ListView.StrictlyEnforceRange
    clip: true

    focus: true
    Keys.onLeftPressed: currentItem.axis.decrementCurrentIndex()
    Keys.onRightPressed: currentItem.axis.incrementCurrentIndex()
    Keys.onPressed: {
        if (api.keys.isAccept(event))
            currentItem.axis.currentGame.launch();
    }
}

After

PathView {
    id: collectionAxis

    anchors.left: parent.left
    anchors.right: parent.right
    anchors.top: parent.verticalCenter
    anchors.bottom: parent.bottom

    model: api.collections
    delegate: collectionAxisDelegate


    // changed ListView to PathView
    snapMode: PathView.SnapOneItem
    highlightRangeMode: PathView.StrictlyEnforceRange
    clip: true

    // brand new: path definitions
    pathItemCount: 1 + Math.ceil(height / vpx(180))
    path: Path {
        startX: collectionAxis.width * 0.5
        startY: vpx(180) * -0.5
        PathLine {
            x: collectionAxis.path.startX
            y: collectionAxis.path.startY + collectionAxis.pathItemCount * vpx(180)
        }
    }
    preferredHighlightBegin: 1 / pathItemCount
    preferredHighlightEnd: preferredHighlightBegin


    focus: true
    // added up/down navigation
    Keys.onUpPressed: decrementCurrentIndex()
    Keys.onDownPressed: incrementCurrentIndex()
    Keys.onLeftPressed: currentItem.axis.decrementCurrentIndex()
    Keys.onRightPressed: currentItem.axis.incrementCurrentIndex()
    Keys.onPressed: {
        if (api.keys.isAccept(event))
            currentItem.axis.currentGame.launch();
    }
}

Warning

Don't forget to change ListView to PathView in the delegate (collectionAxisDelegate's width prop) too!

Structure of the vertical PathView. The red line marks the path, with red dots at positions 0/4 (top), 1/4, 2/4, 3/4 and 4/4 (bottom). The centers of the delegates are marked with blue.

Unlike ListView that goes to one direction only, PathView can be used to create arbitrary paths on which the items will travel (curves, circles, all kinds of shapes). Because of that, some properties have to be provided in percentage or need manual calculations.

Horizontally

The horizontal scrolling works similarly, with one important difference: there is a margin on the left of the currently selected item, where the previous one is halfway in the screen. We'll have to shift the whole path horizontally, and add 1 to the maximum number of visible items, and another one to account for scrolling, just like at the vertical axis.

I've set the left margin previously to 100 px and the width of a game box to be 240x135. In addition, there's a 10px spacing between the elements, giving the full width of a box to 250. The center of the current-item would be at 100 + 250/2 = 225 on the path, but to make it align with the collection label, I'll shift it 5px (half of the spacing) to the left, making the X center to be 220px. Then counting backwards, the previous-item will be at 220 - 250, and the one before that (the leftmost position, where the new elements will appear when scrolling) at 220 - 250 * 2.

All right, let's change the horizontal ListView into a PathView:

Before:

ListView {
    id: gameAxis

    anchors.left: parent.left
    anchors.right: parent.right
    anchors.top: label.bottom
    anchors.bottom: parent.bottom

    orientation: ListView.Horizontal

    model: modelData.gameList.model
    currentIndex: modelData.gameList.index
    delegate: gameAxisDelegate
    spacing: vpx(10)

    snapMode: ListView.SnapOneItem
    highlightRangeMode: ListView.StrictlyEnforceRange

    preferredHighlightBegin: vpx(100)
    preferredHighlightEnd: preferredHighlightBegin + vpx(240)
}

After:

PathView {
    id: gameAxis

    anchors.left: parent.left
    anchors.right: parent.right
    anchors.top: label.bottom
    anchors.bottom: parent.bottom

    // removed orientation

    // removed spacing
    model: modelData.gameList.model
    currentIndex: modelData.gameList.index
    delegate: gameAxisDelegate

    // changed ListView to PathView
    snapMode: PathView.SnapOneItem
    highlightRangeMode: PathView.StrictlyEnforceRange


    // brand new: path definitions
    pathItemCount: 2 + Math.ceil(width / vpx(250)) // note the '2'!
    path: Path {
        startX: vpx(220) - vpx(250) * 2
        startY: vpx(135) * 0.5
        PathLine {
            x: gameAxis.path.startX + gameAxis.pathItemCount * vpx(250)
            y: gameAxis.path.startY
        }
    }
    // changed highlight range
    preferredHighlightBegin: 2 / pathItemCount
    preferredHighlightEnd: preferredHighlightBegin
}

And now both the horizontal and vertical axis loops as intended!

Tip

Typing out fixed values in pixels every time can be tedious and error prone. I'd recommend defining them as properties at the top of the object they're used in (eg. property real boxHeight: vpx(135)).