🎞️
ReactでNetflixの映画一覧のような上下左右スクロールのUIを作る
できあがりイメージ
react-carousel-list
展開/折りたたみ
参考:Netflixの映画一覧
UIのポイント
以下の機能を実現するUIを作成する。
見た目ではreact-multi-carouselが近いが、以下のポイントを実現したいため自作する。
- 垂直方向・水平方向の仮想スクロール
- メモリ使用量を抑え、レンダリング速度を上げる
- 垂直方向の無限スクロール
- ユーザの操作負荷を減らす
- 水平方向のスクロール位置の記憶
- (仮想スクロールでDOMが再生成されても)スクロール位置がリセットされない
- 要:スクロールの初期位置指定
- 水平方向リストのグリッド展開
- 「展開ボタン」でリストアイテムをグリッド表示する
- 要:動的に要素の高さを変更、グリッド表示
また、本質とは違うが以下も工夫する。
- いい感じの画像を使ってデモページを作る
- ジェネリクス等を使って汎用的に作る
ライブラリ選定
下記の記事を参考に、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