Open4

横に自動無限スクロールするやつのいくつかの実現方法

みつきみつき

ネット上に転がってるいくつかの方法を試したみて、完璧な解決法を見つかってない故に各方法のメリットと実現方法をメモするもの。

主はchakra uiとframer motionを使ってるので、主にreactでの実現方法をメモしていく。

みつきみつき

まず一番はよく使われている方法で、CSSを使って実現していく方法。見た目がとても良き、且つ不要なrerenderingもない。欠点は スクロールに対応していない とこかな。

import { Flex, keyframes, usePrefersReducedMotion } from "@chakra-ui/react";

const loopOne = keyframes`
  from { transform: translateX(100%); }
  to { transform: translateX(-100%); }
`;
const loopTwo = keyframes`
  from { transform: translateX(0); }
  to { transform: translateX(-200%); }
`;

type ThisProps = {
  imgs: string[];
};
export default function ImgBarCss({ imgs }: ThisProps) {
  const imgNodes = imgs.map((src) => (
    <img ... /> // 何らかのElementを書く
  ));

  const prefersReducedMotion = usePrefersReducedMotion();
  const animationOne = prefersReducedMotion
    ? undefined
    : `${loopOne} infinite 40s -20s linear`;
  const animationTwo = prefersReducedMotion
    ? undefined
    : `${loopTwo} infinite 40s linear`;

  const animateTargetOneRef = useRef<HTMLDivElement>(null);
  const animateTargetTwoRef = useRef<HTMLDivElement>(null);

  return (
    <Flex
      overflowX="hidden"
      onPointerEnter={(e) => {
        e.stopPropagation();
        animateTargetOneRef.current?.getAnimations()[0].pause();
        animateTargetTwoRef.current?.getAnimations()[0].pause();
      }}
      onPointerLeave={(e) => {
        e.stopPropagation();
        animateTargetOneRef.current?.getAnimations()[0].play();
        animateTargetTwoRef.current?.getAnimations()[0].play();
      }}
    >
      <Flex
        animation={animationOne}
        ref={animateTargetOneRef}
        pl={2}
        py={2}
        gap={2}
        flexShrink="0"
        flexGrow="0"
      >
        {imgNodes}
      </Flex>
      <Flex
        animation={animationTwo}
        ref={animateTargetTwoRef}
        pl={2}
        py={2}
        gap={2}
        flexShrink="0"
        flexGrow="0"
      >
        {imgNodes}
      </Flex>
    </Flex>
  );
}

原理は2回レンダリングさせて繋げてループさせているだけ。
pauseのところはgetAnimations()を使っているが、

        // @ts-ignore
        animateTargetOneRef.current?.style.animationPlayState = "paused";
        // @ts-ignore
        animateTargetTwoRef.current?.style.animationPlayState = "paused";

直接cssを変更するような書き方も可能です。今回は1つしかアニメーションがないので、簡潔にgetAnimations()を使っている。

みつきみつき

あとはスクロール対応するための2つの書き方何ですが、どっちもreactのパフォーマンス上rerenderする時フラッシュしたりするので、何とも言えない。。。もしかするとsolidjsや自分でvanillaで書いて実現した方がいいかもしれない。

とにかく載せとく、まず<motion.div>を使ったもの。pauseに対応していないのですが、スクロールは可能です。

import { Box, Flex } from "@chakra-ui/react";
import { motion } from "framer-motion";
import { useRef, useState } from "react";

type ThisProps = {
  imgs: string[];
};
export default function ImgBarMotion({ imgs }: ThisProps) {
  const [imgNodes, setNodeImgs] = useState(() => {
    return imgs.map((src) => (
      <img ... /> // 何らかのElementを書く
    ));
  })

  // onUpdate を使って判断しているので、2回fireさせないようrefを使って判定
  const isOrderingRef = useRef(false);

  return (
    <Box overflowX="hidden">
      <motion.div
        initial={{ x: -(384 + 2 + 8) }}
        animate={{
          x: 0,
        }}
        transition={{
          repeat: Infinity,
          duration: 5,
          ease: "linear",
        }}
        onUpdate={(latest: { x: number }) => {
          if (latest.x < -2 && isOrderingRef.current) {
            isOrderingRef.current = false;
          }
          if (latest.x > -2 && !isOrderingRef.current) {
            isOrderingRef.current = true;
            const newNodes = [...imgNodes];
            newNodes.unshift(newNodes[newNodes.length - 1]);
            newNodes.pop();
            setNodeImgs(newNodes);
          }
        }}
      >
        <Flex
          px={2}
          py={2}
          gap={2}
          // 100%より1個長くする、384pxは今回定義した画像のwidthで、自分が表示したい画像の大きさに変更してください。8 + 8は chakra ui の gap と px が2になっているから
          w="calc( 100% + 384px + 8px + 8px)"
          overflow="scroll"
          // 見た目のためにscrollbarを隠す
          sx={{
            "&::-webkit-scrollbar": {
              display: "none",
            },
            scrollbarWidth: "none",
            msOverflowStyle: "none",
          }}
        >
          {imgNodes}
        </Flex>
      </motion.div>
    </Box>
  );
}

原理は右(もしくは左)にスクロールさせるが、widthはvwよりさらに1個を多くさせて、ちょうど1個がスクロールされたら、右に隠れたものを切り取って左に追加する(つまり配列操作のunshiftpop)。
しかし性質上、毎回videoNodes全体をrerenderingしているので、動作の遅いパソコンではよくフラッシュする(一瞬白くなる)
framer motionの motion components もpauseに対応してないので、hover pauseにも対応していないが、スクロールは可能です。

みつきみつき

最後は同じくframer motion使っている例ですが、今回は web animation api を使って実装する例です。
結果としてはスクロール、hover pauseにも対応できているが、reactの性質上rerenderingのフラッシュは一向改善できていないのが痛手。

import { Box, Flex } from "@chakra-ui/react";
import {
  AnimationPlaybackControls,
  useAnimate,
  useInView,
} from "framer-motion";
import {
  useEffect,
  useRef,
  useState
} from "react";

type ThisProps = {
  imgs: string[];
};
export default function VideoBarPause({ imgs }: ThisProps) {
  const [imgs, setImgs] = useState(imgs);
  const lastImgsRef = useRef<string[]>(imgs);
  // useAnimate を使う
  const [scope, animate] = useAnimate();
  const isInView = useInView(scope);
  const animationRef = useRef<AnimationPlaybackControls | undefined>();
  const isOrderingRef = useRef(false);

  useEffect(() => {
    if (isInView) {
      console.log("binded once.");
      animationRef.current = animate(
        scope.current,
        {
          // keyframes の設定
          x: [-(384 + 8 + 8), 0],
        },
        {
          repeat: Infinity,
          duration: 5,
          ease: "linear",
          onUpdate(latest: number) {
            if (latest < -2 && isOrderingRef.current) {
              isOrderingRef.current = false; // eslint-disable-line
            }
            if (latest > -2 && !isOrderingRef.current) {
              isOrderingRef.current = true;
              const newImgs = [...lastImgsRef.current];
              newImgs.unshift(newImgs[newImgs.length - 1]);
              newImgs.pop();
              setImgs(newImgs);
              lastVideosRef.current = newImgs;
            }
          },
        }
      );
      animationRef.current.play();
    }
  }, [isInView]); // eslint-disable-line

  return (
    <Box overflowX="hidden">
      <Flex
        ref={scope}
        px={2}
        py={2}
        gap={2}
        // 前と同じ演算
        w="calc( 100% + 384px + 8px + 8px)"
        overflow="scroll"
        onPointerEnter={(e) => {
          e.stopPropagation();
          animationRef.current?.pause();
        }}
        onPointerLeave={(e) => {
          console.log("pointer leave");
          e.stopPropagation();
          animationRef.current?.play();
        }}
        sx={{
          "&::-webkit-scrollbar": {
            display: "none",
          },
          scrollbarWidth: "none",
          msOverflowStyle: "none",
        }}
      >
        {imgs.map((src) => (
          <img ... />
        ))}
      </Flex>
    </Box>
  );
}

概ねの理屈は motion components を使ったものと同じで、pause 対応するために web api を使っただけ。
もしreactの更新がlistごとじゃなくて、粒度の高い1個1個に更新できたらいいなって思った。
やはり自分で書くべきですかね...