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:
- if there is an available image for a game, the box should show that
- if there is none, or the image has not loaded yet, the box should show a gray rectangle, with the game's title in the center
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:
- I've set
asynchronous: true
: Loading image files takes some time depending on the device Pegasus runs on. If this property is set to false (default), the program will not react to input until the image is loaded (or noticed that it failed to load). If it's false, the image is loaded "in the background", and input is not blocked; hovewer depending on your theme, you might want to show something in its place for the users during this time (eg. a loading spinner or progress bar). - I've set
sourceSize
: This sets the maximum size the image should occupy in the memory. The official documentation describes this in detail. - I've set
visible: source
, that is, if thesource
is empty (neitherbanner
,steam
orboxFront
is available), then ignore this whole object: no input will be ever handled here and there's nothing to see either.
With these changes, here's how it looks:
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:
- make the list finite and when the last item is reached, jump back to the first one (and also in reverse direction)
- make the list infinite and loop around (carousel style)
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.
For PathViews,
pathItemCount
must be set (the default behaviour is to show all items). We should show as many rows as it fits into lower half or the screen (one row's height is 180px). The number of visible items thus will be [area height] / [row height], which I've rounded up usingMath.ceil
, a standard JavaScript function. However, when there's a scrolling going on, there'll be actually one more row visible on the screen: the topmost row will gradually go out on the top of the lower area, while a new line is on its way in to appear on the bottom (see the animation below).The
path
defines the trail the elements will follow by their center point. Because there'll be one item that slides out, and one that slides in, the path extends above and below the PathView's area. The starting point of the axis (the center point of the item that will slide out) is horizontally (startX
) the center of the screen (as the rows fill the width), and vertically (startY
) above the top edge of the PathView (which would be 0) by 50% of the row height (where values are in pixels). From the start point, a linear path is created withPathLine
: I've set it so the end point is the same as the start except theY
coordinate, which is increased by the length ot the path, [number of max. visible items] * [item height].The preferred highlight positions are in percentage for the PathView (as it can have any kind of shape, pixels don't always make sense). Again, the values define the range for the center point of the selected item. It defaults to 0 (start of the line), which in our case would be the center of the sliding out element, out of the visible area. I've set it to [1] / [item count], which will produce the center point of the second element on the path. Since I'm not planning to add any additional effects and such, just select one item, I've set the end of the range to the same as the beginning.
Because Paths are not necessary straight, it can't be decided automatically which direction are logical to move in, so there's no built-in key navigation. In our case we're just moving vertically, so I've made the View react to the "up" and "down" keys.
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)
).