🎞️

Web で TikTok やショート動画のような縦スワイプ UI を実装する

2024/07/19に公開

はじめに

近年,TikTok や YouTube Shorts,Instagram のリール等で散見される縦動画が流行しています.これらのアプリケーションでは,縦にスワイプをすることで動画が次から次へと流れるようになっており,なかなかに UX が良いです.多くの場合,この UI はネイティブアプリとして実装されますが,Web においても scroll-snap-type プロパティおよび Interaction Observer API を用いることで,簡単に実装することができます.

https://note.com/shingo2000/n/nf3d065851a50

実装

Vite,TypeScript,React,Emotion を用いて実装します.

縦スワイプで動画を切り替えるスクリーンショット

縦スワイプでスナップさせる

CSS

複数の Content を包含する Wrapper を想定します.全画面で表示させるため,どちらの要素もサイズは 100vw, 100dvh に設定します.Wrapper は overflow: scroll に指定し,この際 scroll-snap-type を用いることで,y 軸方向にスナップするようにします.Content には scroll-snap-align および scroll-snap-stop を指定し,Content 上端でのスクロールの停止を強制します.

const ScrollView = styled.div`
  width: 100vw;
  height: 100dvh;
  overflow: scroll;
  scroll-snap-type: y mandatory;
`;

const Content = styled.div`
  width: 100vw;
  height: 100dvh;
  scroll-snap-stop: always;
  background: #111;
`;

const Video = styled.video`
  width: 100%;
  height: 100%;
`;

TSX

TSX は次の通りになります.Chrome の動画再生ポリシーでは,動画の音を流す際にユーザによるインタラクションを必要とします.したがって,ユーザが動画をクリックした時点で muted = false に設定し,その時点で初めて音声が再生されるようにします[1]

// links は動画リンクの配列を示す
const View = ( { links }: { links: string[] } ) => {
  const [muted, setMuted] = useState(true);
  const scrollViewRef = useRef<HTMLDivElement>(null);
  const videoRefs = useRef<RefObject<HTMLVideoElement>[]>([]);
  for (let i = 0; i < links.length; i++) {
    videoRefs.current[i] = createRef<HTMLVideoElement>();
  }

  return (
    <ScrollView ref={scrollViewRef}>
      {links.map((link, i) => (
        <Content key={i}>
          <Video
            src={link}
            muted={muted}
            autoPlay
            playsInline
            ref={videoRefs.current[i]}
            onClick={() => setMuted(false)}
          />
        </Content>
      ))}
    </ScrollView>
  );
};

ページ遷移時

続いて,動画切り替え時の実装を以下に示します.音声付きの動画を複数流すと,動画同士が干渉してしまうため,IntersectionObserver を用いて,Video が ScrollView に収まったタイミングで,当該の動画を再生し,その他の動画を停止します.

useEffect(() => {
  const scrollView = scrollViewRef.current;
  if (!scrollView) return;
  const observers: IntersectionObserver[] = [];

  for (let i = 0; i < links.length; i++) {
    const video = videoRefs.current[i].current;
    if (!video) continue;

    const callback: IntersectionObserverCallback = (entries) => {
      for (const entry of entries) {
        // Video が ScrollView に収まったタイミング
        if (entry.intersectionRatio === 1.0) {
          for (let i2 = 0; i2 < links.length; i2++) {
            const video2 = videoRefs.current[i2].current;
            video2 === entry.target ? video2.play() : video2?.pause();
          }
        }
      }
    };
    const options = { root: scrollView, threshold: 1 };
    const observer = new IntersectionObserver(callback, options);
    observer.observe(video);
    observers.push(observer);
  }

  return () => observers.forEach((observer) => observer.disconnect());
}, [links]);
脚注
  1. あるいは,ページ遷移時にモーダル等にボタンを表示し,ユーザにそのボタンをクリックさせることでも制限を回避できます. ↩︎

Discussion