Skip to content

Render an empty div for game cards until they inter the viewport #2460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/frontend/screens/Library/components/GameCard/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
flex-direction: column;
border-radius: var(--space-3xs);
animation: fade-in 0.2s ease-in;
aspect-ratio: 173/275;
}

.gameCard.gamepad {
aspect-ratio: 3/4;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are the current ratios, but I'm hardcoding them so the empty divs use the correct space

}

.gameCard:focus-within {
Expand Down Expand Up @@ -101,7 +106,7 @@
padding: var(--space-xs-fixed);
}

.gameCard > .icons.gamepad {
.gameCard.gamepad > .icons {
display: none;
}

Expand Down
44 changes: 34 additions & 10 deletions src/frontend/screens/Library/components/GameCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ const GameCard = ({
isRecent = false,
gameInfo: gameInfoFromProps
}: Card) => {
const [visible, setVisible] = useState(false)

useEffect(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start as invisible, make visible when the visible-cards event is fired and this card is in the list of visible cards

// render an empty div until the card enters the viewport
// check GameList for the other side of this detection
const callback = (e: CustomEvent<{ appNames: string[] }>) => {
if (e.detail.appNames.includes(gameInfoFromProps.app_name)) {
setVisible(true)
}
}

window.addEventListener('visible-cards', callback)

return () => {
window.removeEventListener('visible-cards', callback)
}
}, [])

const [gameInfo, setGameInfo] = useState<GameInfo | SideloadGame>(
gameInfoFromProps
)
Expand All @@ -80,7 +98,8 @@ const GameCard = ({
favouriteGames,
allTilesInColor,
showDialogModal,
setIsSettingsModalOpen
setIsSettingsModalOpen,
activeController
} = useContext(ContextProvider)

const {
Expand Down Expand Up @@ -318,6 +337,7 @@ const GameCard = ({
const instClass = isInstalled ? 'installed' : ''
const hiddenClass = isHiddenGame ? 'hidden' : ''
const notAvailableClass = notAvailable ? 'notAvailable' : ''
const gamepadClass = activeController ? 'gamepad' : ''
const imgClasses = `gameImg ${isInstalled ? 'installed' : ''} ${
allTilesInColor ? 'allTilesInColor' : ''
}`
Expand All @@ -327,13 +347,21 @@ const GameCard = ({

const wrapperClasses = `${
grid ? 'gameCard' : 'gameListItem'
} ${instClass} ${hiddenClass} ${notAvailableClass}`

const { activeController } = useContext(ContextProvider)
} ${instClass} ${hiddenClass} ${notAvailableClass} ${gamepadClass}`

const showUpdateButton =
hasUpdate && !isUpdating && !isQueued && !notAvailable

if (!visible) {
return (
<div
className={wrapperClasses}
data-app-name={appName}
data-invisible={true}
></div>
)
}

return (
<div>
{showUninstallModal && (
Expand All @@ -344,7 +372,7 @@ const GameCard = ({
/>
)}
<ContextMenu items={items}>
<div className={wrapperClasses}>
<div className={wrapperClasses} data-app-name={appName}>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using this data attribute for the intersection observer

{haveStatus && <span className="gameCardStatus">{label}</span>}
<Link
to={`/gamepage/${runner}/${appName}`}
Expand Down Expand Up @@ -394,11 +422,7 @@ const GameCard = ({
</span>
</Link>
<>
<span
className={classNames('icons', {
gamepad: activeController
})}
>
<span className="icons">
{showUpdateButton && (
<SvgButton
className="updateIcon"
Expand Down
111 changes: 65 additions & 46 deletions src/frontend/screens/Library/components/GamesList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react'
import React, { useContext, useEffect } from 'react'
import { GameInfo, Runner, SideloadGame } from 'common/types'
import cx from 'classnames'
import GameCard from '../GameCard'
Expand Down Expand Up @@ -26,62 +26,51 @@ const GamesList = ({
onlyInstalled = false,
isRecent = false
}: Props): JSX.Element => {
const { gameUpdates, showNonAvailable } = useContext(ContextProvider)
const { gameUpdates } = useContext(ContextProvider)
const { t } = useTranslation()
const [gameCards, setGameCards] = useState<JSX.Element[]>([])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this useState and the useEffect changing it was causing extra re-renders

I moved this back to the return jsx instead of using a useState+useEffect


useEffect(() => {
let mounted = true

const createGameCards = async () => {
if (!library.length) {
return
if (library.length) {
const options = {
root: document.querySelector('.listing'),
rootMargin: '500px',
threshold: 0
}
const resolvedLibrary = library.map(async (gameInfo) => {
const { app_name, is_installed, runner } = gameInfo

let is_dlc = false
if (gameInfo.runner !== 'sideload') {
is_dlc = gameInfo.install.is_dlc ?? false
}
const callback: IntersectionObserverCallback = (entries, observer) => {
const entered: string[] = []
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
// when a card is intersecting the viewport
const appName = (entry.target as HTMLDivElement).dataset
.appName as string

if (is_dlc) {
return null
}
if (!is_installed && onlyInstalled) {
return null
}
// store this appName for later
entered.push(appName)
// stop observing this element
observer.unobserve(entry.target)
}
})

const hasUpdate = is_installed && gameUpdates?.includes(app_name)
return (
<GameCard
key={app_name}
hasUpdate={hasUpdate}
buttonClick={() => {
if (gameInfo.runner !== 'sideload')
handleGameCardClick(app_name, runner, gameInfo)
}}
forceCard={layout === 'grid'}
isRecent={isRecent}
gameInfo={gameInfo}
/>
// dispatch an event with the newley visible cards
// check GameCard for the other side of this detection
window.dispatchEvent(
new CustomEvent('visible-cards', { detail: { appNames: entered } })
)
})
const gameCardElements = (await Promise.all(
resolvedLibrary
)) as JSX.Element[]

if (mounted) {
setGameCards(gameCardElements)
}
}

createGameCards()
const observer = new IntersectionObserver(callback, options)

document.querySelectorAll('[data-invisible]').forEach((card) => {
observer.observe(card)
})

return () => {
mounted = false
return () => {
observer.disconnect()
}
}
}, [library, onlyInstalled, layout, gameUpdates, isRecent, showNonAvailable])
return () => ({})
}, [library])

return (
<div
Expand All @@ -100,7 +89,37 @@ const GamesList = ({
<span>{t('wine.actions', 'Action')}</span>
</div>
)}
{!!library.length && gameCards}
{!!library.length &&
library.map((gameInfo) => {
const { app_name, is_installed, runner } = gameInfo

let is_dlc = false
if (gameInfo.runner !== 'sideload') {
is_dlc = gameInfo.install.is_dlc ?? false
}

if (is_dlc) {
return null
}
if (!is_installed && onlyInstalled) {
return null
}

const hasUpdate = is_installed && gameUpdates?.includes(app_name)
return (
<GameCard
key={app_name}
hasUpdate={hasUpdate}
buttonClick={() => {
if (gameInfo.runner !== 'sideload')
handleGameCardClick(app_name, runner, gameInfo)
}}
forceCard={layout === 'grid'}
isRecent={isRecent}
gameInfo={gameInfo}
/>
)
})}
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/frontend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ declare global {
}

interface WindowEventMap {
'visible-cards': CustomEvent<{ appNames: string[] }>
'controller-changed': CustomEvent<{ controllerId: string }>
}
}
Expand Down