Cross-fade images in SpeciesFacesPicker

Wowie, this was hard to get right, but I'm very pleased with where it ended up!! React `key` stuff was a total brainwave, and even though it depends on kinda obscure knowledge, it made this whole thing WAAAY easier, omg
This commit is contained in:
Emi Matchu 2021-02-03 01:16:19 -08:00
parent a42e696955
commit 27d4bed172

View file

@ -1030,7 +1030,7 @@ function SpeciesFaceOption({
} }
`} `}
> >
<img <CrossFadeImage
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`} src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
srcSet={ srcSet={
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` + `https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
@ -1039,7 +1039,7 @@ function SpeciesFaceOption({
alt={speciesName} alt={speciesName}
width={50} width={50}
height={50} height={50}
data-is-compatible={isCompatible} data-is-compatible={!isLoading && isCompatible}
className={css` className={css`
filter: saturate(90%); filter: saturate(90%);
opacity: 0.9; opacity: 0.9;
@ -1058,7 +1058,6 @@ function SpeciesFaceOption({
opacity: 1; opacity: 1;
filter: saturate(110%); filter: saturate(110%);
} }
/* Alt text for when the image fails to load! We hide it /* Alt text for when the image fails to load! We hide it
* while still loading though! */ * while still loading though! */
font-size: 0.75rem; font-size: 0.75rem;
@ -1079,6 +1078,132 @@ function SpeciesFaceOption({
); );
} }
/**
* CrossFadeImage is like <img>, but listens for successful load events, and
* fades from the previous image to the new image once it loads.
*
* We treat `src` as a unique key representing the image's identity, but we
* also carry along the rest of the props during the fade, like `srcSet` and
* `className`.
*/
function CrossFadeImage(incomingImageProps) {
const [prevImageProps, setPrevImageProps] = React.useState(null);
const [currentImageProps, setCurrentImageProps] = React.useState(null);
const incomingImageIsCurrentImage =
incomingImageProps.src === currentImageProps?.src;
const onLoadNextImage = () => {
setPrevImageProps(currentImageProps);
setCurrentImageProps(incomingImageProps);
};
// The main trick to this component is using React's `key` feature! When
// diffing the rendered tree, if React sees two nodes with the same `key`, it
// treats them as the same node and makes the prop changes to match.
//
// We usually use this in `.map`, to make sure that adds/removes in a list
// don't cause our children to shift around and swap their React state or DOM
// nodes with each other.
//
// But here, we use `key` to get React to transition the same <img> DOM node
// between 3 different states!
//
// The image starts its life as the last in the list, from
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
// as the `key`.
//
// When it loads, we update the state so that this `key` now belongs to the
// _second_ node, from `currentImageProps`. React will see this and make the
// correct transition for us: it sets opacity to 0, sets z-index to 2,
// removes aria-hidden, and removes the `onLoad` handler.
//
// Then, when another image is ready to show, we update the state so that
// this key now belongs to the _first_ node, from `prevImageProps` (and the
// second node is showing something new). React sees this, and makes the
// transition back to invisibility, but without the `onLoad` handler this
// time! (And transitions the current image into view, like it did for this
// one.)
//
// Finally, when yet _another_ image is ready to show, we stop rendering any
// images with this key anymore, and so React unmounts the image entirely.
//
// Thanks, React, for handling our multiple overlapping transitions through
// this little state machine! This could have been a LOT harder to write,
// whew!
return (
<ClassNames>
{({ css }) => (
<div
className={css`
display: grid;
grid-template-areas: "shared-overlapping-area";
isolation: isolate; /* Avoid z-index conflicts with parent! */
> div {
grid-area: shared-overlapping-area;
transition: opacity 0.2s;
}
`}
>
{prevImageProps && (
<div
key={prevImageProps.src}
className={css`
z-index: 3;
opacity: 0;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img {...prevImageProps} aria-hidden />
</div>
)}
{currentImageProps && (
<div
key={currentImageProps.src}
className={css`
z-index: 2;
opacity: 1;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
{...currentImageProps}
// If the current image _is_ the incoming image, we'll allow
// new props to come in and affect it. But if it's a new image
// incoming, we want to stick to the last props the current
// image had! (This matters for e.g. `isCompatible` becoming
// true in `SpeciesFaceOption` and restoring color, before
// the new color's image loads in.)
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
/>
</div>
)}
{!incomingImageIsCurrentImage && (
<div
key={incomingImageProps.src}
className={css`
z-index: 1;
opacity: 0;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
{...incomingImageProps}
aria-hidden
onLoad={onLoadNextImage}
/>
</div>
)}
</div>
)}
</ClassNames>
);
}
/** /**
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is * DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
* true before mounting it, and unmounts it after closing. * true before mounting it, and unmounts it after closing.