From 27d4bed17271cb86117aa98ad9d714252730c6d3 Mon Sep 17 00:00:00 2001 From: Matchu Date: Wed, 3 Feb 2021 01:16:19 -0800 Subject: [PATCH] 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 --- src/app/ItemPage.js | 133 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 4 deletions(-) diff --git a/src/app/ItemPage.js b/src/app/ItemPage.js index 4fe2bf4..c40b848 100644 --- a/src/app/ItemPage.js +++ b/src/app/ItemPage.js @@ -1030,7 +1030,7 @@ function SpeciesFaceOption({ } `} > - {speciesName}, 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 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 ( + + {({ css }) => ( +
div { + grid-area: shared-overlapping-area; + transition: opacity 0.2s; + } + `} + > + {prevImageProps && ( +
+ {/* eslint-disable-next-line jsx-a11y/alt-text */} + +
+ )} + + {currentImageProps && ( +
+ {/* eslint-disable-next-line jsx-a11y/alt-text */} + +
+ )} + + {!incomingImageIsCurrentImage && ( +
+ {/* eslint-disable-next-line jsx-a11y/alt-text */} + +
+ )} +
+ )} +
+ ); +} + /** * DeferredTooltip is like Chakra's , but it waits until `isOpen` is * true before mounting it, and unmounts it after closing.