🎞️

ReactでNetflixの映画一覧のような上下左右スクロールのUIを作る

2024/01/22に公開

できあがりイメージ

react-carousel-list

展開/折りたたみ

展開/折りたたみ

参考:Netflixの映画一覧

Netflix

UIのポイント

以下の機能を実現するUIを作成する。
見た目ではreact-multi-carouselが近いが、以下のポイントを実現したいため自作する。

  1. 垂直方向・水平方向の仮想スクロール
    • メモリ使用量を抑え、レンダリング速度を上げる
  2. 垂直方向の無限スクロール
    • ユーザの操作負荷を減らす
  3. 水平方向のスクロール位置の記憶
    • (仮想スクロールでDOMが再生成されても)スクロール位置がリセットされない
    • 要:スクロールの初期位置指定
  4. 水平方向リストのグリッド展開
    • 「展開ボタン」でリストアイテムをグリッド表示する
    • 要:動的に要素の高さを変更、グリッド表示

また、本質とは違うが以下も工夫する。

  1. いい感じの画像を使ってデモページを作る
  2. ジェネリクス等を使って汎用的に作る

ライブラリ選定

下記の記事を参考に、react-virtuosoをベースに開発しようとしたが、水平方向のスクロールには対応していなかったので、水平方向のスクロール部分はreact-windowを使用することにした。

feature react-virtuoso react-window
垂直方向仮想スクロール
水平方向仮想スクロール ×
Feature request: horizontal list
無限スクロール
Endless Scrolling

react-window-infinite-loader
スクロールの初期位置指定
Start from a certain item
動的に要素の高さを変更
Auto Resizing Virtual List
×
グリッド表示

実装

全体像

import React, { useState, useCallback, useEffect, LegacyRef, forwardRef, useRef, CSSProperties } from 'react'
import { Virtuoso } from 'react-virtuoso'
import { FixedSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

type LaneProps = {
  title: string;
  items: ItemProps[];
};

type ItemProps = {
  name: string;
};

type LaneState = {
  expand: boolean;
  scroll: number;
};

const mockLaneLoader = (skip: number) => new Promise<LaneProps[]>((resolve) => {
  const generateLanes = (size: number, skip: number) => [...Array(size)].map((_, index) => {
    const size = (index + skip) % 10 + 2;
    return {
      title: `Lane ${index + skip + 1}`,
      items: [...Array(size)].map((_, index) => ({ name: `Item ${index + 1}` }))
    }
  });
  setTimeout(() => {
    const addSize = 10;
    const result = generateLanes(addSize, skip);
    resolve(result);
  }, 2000);
});

export default function App() {
  const [lanes, setLanes] = useState<LaneProps[]>(() => []);
  const laneStateRefs = useRef<LaneState[]>([]);

  const loadMore = useCallback(() => {
    return setTimeout(async () => {
      const newLanes = await mockLaneLoader(lanes.length);
      setLanes((lanes) => [...lanes, ...newLanes]);
      [...Array(newLanes.length)].forEach((_) => laneStateRefs.current.push({ expand: false, scroll: 0 }));
    }, 200)
  }, [lanes.length]);

  useEffect(() => {
    const timeout = loadMore();
    return () => clearTimeout(timeout)
  }, []);

  return (
    <div style={{ position: "fixed", bottom: 0, width: "100%", height: "100%" }}>
      <Virtuoso
        data={lanes}
        endReached={loadMore}
        overscan={5}
        itemContent={(index, lane) => <CarouselListLane
          lane={lane}
          onScroll={(scroll: number) => {
            if (laneStateRefs?.current[index]) {
              laneStateRefs.current[index].scroll = scroll
            }
          }}
          onExpandClick={(expand: boolean) => {
            if (laneStateRefs?.current[index]) {
              laneStateRefs.current[index].expand = expand
            }
          }}
          {...laneStateRefs.current[index]}
        />}
        components={{ Footer }}
      />
    </div>
  );
}

const LaneHeader = (props: {
  lane: LaneProps;
  expand: boolean;
  hasScrollBar: boolean;
  onExpandClick: () => void;
}) => {
  const { lane, expand, hasScrollBar, onExpandClick } = props;
  const ExpandButton = () =>
    <svg onClick={onExpandClick} style={{ cursor: "pointer" }} height="48" viewBox="0 -960 960 960" width="48" fill='#757575'>
      <path d="M480-80 240-320l57-57 183 183 183-183 57 57L480-80ZM298-584l-58-56 240-240 240 240-58 56-182-182-182 182Z" />
    </svg>;
  const CollapseButton = () =>
    <svg onClick={onExpandClick} style={{ cursor: "pointer" }} height="48" viewBox="0 -960 960 960" width="48" fill='#757575'>
      <path d="m296-80-56-56 240-240 240 240-56 56-184-184L296-80Zm184-504L240-824l56-56 184 184 184-184 56 56-240 240Z" />
    </svg>;
  return (
    <h2 style={{ fontSize: 64, paddingLeft: 16, color: '#757575', display: "flex", justifyContent: "start", alignItems: "center", gap: 8, paddingTop: 8 }}>
      {lane.title}{(hasScrollBar || expand) && (expand ? <CollapseButton /> : <ExpandButton />)}
    </h2>
  );
};

const CarouselListLane = (props: {
  lane: LaneProps;
  onExpandClick: (expand: boolean) => void;
  onScroll: (scroll: number) => void;
} & LaneState) => {
  const { lane, expand, scroll, onExpandClick, onScroll } = props;
  const gridContainerRef = useRef<any>(null);
  const [expandState, setExpandState] = useState<boolean>(expand);
  const [hasScrollBar, setHasScrollBar] = useState<boolean>(false);

  const checkScrollBar = useCallback(() => {
    const el = gridContainerRef?.current?._outerRef;
    if (!el) {
      return;
    }
    setHasScrollBar(el?.clientWidth < el?.scrollWidth);
  }, []);

  const toggleExpand = useCallback((expandState: boolean) => {
    setExpandState(!expandState);
    onExpandClick(!expandState);
  }, [onExpandClick]);

  useEffect(() => {
    checkScrollBar();
    window.addEventListener('resize', checkScrollBar);
    return () => window.removeEventListener('resize', checkScrollBar);
  }, [gridContainerRef, checkScrollBar, expandState]);

  if (lane.items.length === 0) {
    return <React.Fragment />;
  }

  return (
    <div>
      <LaneHeader
        lane={lane}
        expand={expandState}
        hasScrollBar={hasScrollBar}
        onExpandClick={() => toggleExpand(expandState)}
      />
      <AutoSizer disableHeight>
        {({ width }) => <CarouselListItemsWithRef
          items={lane.items}
          width={width!}
          expand={expandState}
          scroll={scroll}
          ref={gridContainerRef}
          onLoad={checkScrollBar}
          onScroll={onScroll} />
        }
      </AutoSizer>
    </div>
  );
};

const CarouselListItems = (props: {
  items: ItemProps[], width: number;
  expand: boolean, scroll: number;
  onLoad: () => void;
  onScroll: (scroll: number) => void;
}, ref: LegacyRef<FixedSizeGrid>) => {
  const { items, width, expand, scroll, onLoad, onScroll } = props;

  useEffect(() => onLoad(), [onLoad]);

  const cardWidth = 320;
  const cardHeight = 240;
  const columnCount = expand ? Math.floor(width / cardWidth) : items.length;
  const rowCount = expand ? Math.ceil(items.length / columnCount) : 1;
  const height = expand ? rowCount * cardHeight : 280;
  const initialScrollLeft = !expand && scroll ? Math.min(scroll || 0, columnCount * cardWidth) : 0;

  return <FixedSizeGrid
    onScroll={(event) => {
      if (scroll === event.scrollLeft) {
        return;
      }
      onScroll(event.scrollLeft)
    }}
    initialScrollLeft={initialScrollLeft}
    ref={ref}
    style={{ overflowY: "hidden" }}
    itemData={items}
    columnCount={columnCount}
    rowCount={rowCount}
    width={width}
    height={height}
    columnWidth={cardWidth}
    rowHeight={cardHeight}
  >{({ rowIndex, columnIndex, style, data }) =>
    <CarouselListCell width={320} height={240} style={style} items={data} index={rowIndex * columnCount + columnIndex} />}
  </FixedSizeGrid>
};

const CarouselListItemsWithRef = forwardRef(CarouselListItems);

const CarouselListCell = (props: {
  width: number;
  height: number;
  style: CSSProperties;
  items: ItemProps[];
  index: number;
}) => {
  const { width, height, style, items, index } = props;
  if (!items[index]) {
    return <></>;
  }
  return (
    <div style={style}>
      <div style={{ width: (width - 24), height: (height - 24), border: 'solid', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: 64 }} >
        {items[index].name}
      </div>
    </div>
  );
};

const Footer = () => {
  return (
    <div style={{ padding: '2rem', display: 'flex', justifyContent: 'center' }} >
      Loading...
    </div>
  );
};

※ この後抽象化します

実装のポイント

水平方向のスクロール位置と展開状態はuseRef<LaneState[]>で管理し、スクロール毎に再レンダリングしないようにする

  const laneStateRefs = useRef<LaneState[]>([]);

  const loadMore = useCallback(() => {
     ...
      [...Array(newLanes.length)].forEach((_) => laneStateRefs.current.push({ expand: false, scroll: 0 }));
     ...
  }, [lanes.length]);
  
  ...
 
  return (
    <div style={{ position: "fixed", bottom: 0, width: "100%", height: "100%" }}>
      <Virtuoso
        data={lanes}
        endReached={loadMore}
        overscan={5}
        itemContent={(index, lane) => <CarouselListLane
          lane={lane}
          onScroll={(scroll: number) => {
            if (laneStateRefs?.current[index]) {
              laneStateRefs.current[index].scroll = scroll
            }
          }}
          onExpandClick={(expand: boolean) => {
            if (laneStateRefs?.current[index]) {
              laneStateRefs.current[index].expand = expand
            }
          }}
          {...laneStateRefs.current[index]}
        />}
        components={{ Footer }}
      />
    </div>
  );

windowサイズに応じて展開ボタンの表示・非表示を切り替える

  const [hasScrollBar, setHasScrollBar] = useState<boolean>(false);

  const checkScrollBar = useCallback(() => {
    const el = gridContainerRef?.current?._outerRef;
    if (!el) {
      return;
    }
    setHasScrollBar(el?.clientWidth < el?.scrollWidth);
  }, []);

  useEffect(() => {
    checkScrollBar();
    window.addEventListener('resize', checkScrollBar);
    return () => window.removeEventListener('resize', checkScrollBar);
  }, [gridContainerRef, checkScrollBar, expandState]);

いい感じの画像を使ってデモページを作る

Lorem Picsumを使用してサンプル画像を埋め込む。

ジェネリクス等を使って汎用的に作る

sjnyag/react-carousel-listで公開しています。

以上です!

Discussion