diff --git a/shell/qml/Main.qml b/shell/qml/Main.qml index 4211f68..32a70e0 100644 --- a/shell/qml/Main.qml +++ b/shell/qml/Main.qml @@ -100,17 +100,111 @@ QtObject { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - // TODO: Tune this stuff !!! it feels just slightly janky and off for now, game cards don't align when scrolling, they go all the way out - // to the edge with no spacing at all when on the leftmost or rightmost cards, and vertical navigation from the dock feels *wrong*, idk, - // fix it sometime. - height: 140 + // these properties are primarily for the gamesList snapping + readonly property int cardWidth: 120 + readonly property int cardSpacing: 16 + readonly property int cardPitch: cardWidth + cardSpacing + readonly property int safeLeft: 90 + readonly property int safeRight: width - 90 + readonly property int edgePeek: 98 + readonly property int snapOffset: cardWidth - edgePeek + + property real scrollTarget: contentX + + height: 200 orientation: ListView.Horizontal - preferredHighlightBegin: 200 - preferredHighlightEnd: 420 - highlightRangeMode: ListView.ApplyRange - spacing: 20 + spacing: 16 + leftMargin: 16 + rightMargin: 16 model: 20 // this is a placeholder for now, I don't know how I really want to handle max game carousel size yet. another time. + + // provides the smoothing animation for game selection + NumberAnimation { + id: scrollAnimation + target: gamesList + property: "contentX" + duration: 200 + easing.type: Easing.OutCubic + } + + // used to scroll to a new game on the game list + function scrollTo(targetX) { + if (Math.abs(targetX - gamesList.contentX) < 0.5) { + return + } + + scrollAnimation.stop() + scrollAnimation.from = gamesList.contentX + scrollAnimation.to = targetX + scrollAnimation.start() + } + + // provides snapping logic so that the games list is snapped to a card pitch + function snappedContentX(snapTargetX) { + // set up a relative coord for our snapping logic, so we line up right + let relativeTarget = snapTargetX - gamesList.originX + + // finds the nearest point on the snap grid + // it's pretty obtuse, so as an explanation: we subtract snapOffset first so the grid becomes a + // simple 0, cardPitch, 2*cardPitch, etc grid. Divide by cardPitch to get the nearest grid index, + // round it, then multiply by cardPitch to convert that index back to a position. Finally we + // wanna add snapOffset (and originX) back to line it up with our true grid. + let snappedTargetX = gamesList.originX + Math.round((relativeTarget - gamesList.snapOffset) / gamesList.cardPitch) * gamesList.cardPitch + gamesList.snapOffset + + // calculate the furthest possible ends of the carousel + // Math.max is for clamping it to make sure even if there are very few games on the board that it + // wont return something stupid + let minimum = gamesList.originX - gamesList.leftMargin + let maximum = gamesList.originX + Math.max(0, gamesList.contentWidth + gamesList.leftMargin + gamesList.rightMargin - gamesList.width) - gamesList.leftMargin + + // this section of code is here to make sure that when the game carousel is flicked all the way + // to the leftmost or rightmost ends, it doesn't snap and cutoff the leftmost or rightmost + // card in a way that feels janky. it works by calculating the distance from the current target + // to the very edges, and if it's closer to one of the edges, just snap to the edge. + let bestTarget = snappedTargetX + let bestDistance = Math.abs(snapTargetX - snappedTargetX) + + let distanceToMinimum = Math.abs(snapTargetX - minimum) + + if (distanceToMinimum < bestDistance) { + bestTarget = minimum + bestDistance = distanceToMinimum + } + + let distanceToMaximum = Math.abs(snapTargetX - maximum) + + if (distanceToMaximum < bestDistance) { + bestTarget = maximum + } + + return Math.max(minimum, Math.min(bestTarget, maximum)) + } + + // this is a function to make sure the games list scrolls, but in a predictable, nice-feeling way. + // specifically, it scrolls but makes sure the games list is snapped to a specific card pitch. + function ensureCurrentInSafeZone() { + if (!gamesList.currentItem) { + return + } + + let item = gamesList.currentItem + + let itemLeft = item.x - gamesList.contentX + let itemRight = itemLeft + item.width + + let targetContentX = gamesList.contentX + + if (itemLeft < gamesList.safeLeft) { + targetContentX -= gamesList.safeLeft - itemLeft + } else if (itemRight > gamesList.safeRight) { + targetContentX += itemRight - gamesList.safeRight + } else { + return + } + + gamesList.scrollTo(gamesList.snappedContentX(targetContentX)) + } Component.onCompleted: { // on startup, this should select the first game in the list. if (currentItem) { @@ -118,11 +212,21 @@ QtObject { } } + // every time the index changes, we wanna re-calculate the game carousel's position + onCurrentIndexChanged: { + ensureCurrentInSafeZone() + } + + // after flicking, we re-snap the carousel wherever it landed + onMovementEnded: { + gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX)) + } + delegate: Rectangle { // TODO: design a real game card QML to replace this placeholder id: gameTile - width: 110 - height: 110 + width: 120 + height: 180 color: navigationManager.currentItem === gameTile ? "blue" : "white"