【力技】スクロールスナップで、現在主に表示している要素を取得し、そのデザインを変える

# 成果物
![画面収録.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527059/8a8732c5-29c7-4651-ae58-681f5b97087b.gif)

スクロールスナップを実装した時、主に表示されている要素（今回は左端に留められている要素）は普通に表示して、それ以外は薄く表示する。

# 実装

やっていることはかなり力技です。

```tsx
// useCenterClosestElement.ts

import { Dispatch, MutableRefObject, SetStateAction } from "react";

type Ref = MutableRefObject<HTMLDivElement | null>;
type Setter = Dispatch<SetStateAction<HTMLDivElement | null>>;

export const useCenterClosestElement = (
	containerRef: Ref, // 横スライドさせる要素をラップしているdiv要素
	setter: Setter // set関数
): void => {
	const container = containerRef.current; // CardWrapper要素のこと
	if (!container) return;

	// container内の子要素を配列として取得
	const items = Array.from(container.children) as HTMLDivElement[];
	// 最終的に、最も中心に近い要素を入れる変数
	let closestItem: HTMLDivElement | null = null;
	// 最も中心に近いアイテムまでの距離を保存する
	let closestDistance = Infinity;

	items.forEach((item) => {
		// itemのビューポードに対する相対位置を取得
		const rect = item.getBoundingClientRect();
		// CardWrapperのビューポードに対する相対位置を取得
		const containerRect = container.getBoundingClientRect();

		// itemの中央がどこにあるのか
		const itemCenter = rect.left + rect.width / 2;
		// CardWrapperの中央がどこにあるのか
		const containerCenter = containerRect.left + containerRect.width / 2;

		// itemの中心 - CardWrapperの中心
		const distance = Math.abs(itemCenter - containerCenter);

		// distanceが一番小さいitemが、一番中央に存在する
		if (distance < closestDistance) {
			closestDistance = distance;
			closestItem = item;
		}
	});

	setter(closestItem);
};

```

```tsx
// App.tsx
import { useRef, useState, useEffect, useCallback } from "react";
import { useCenterClosestElement } from "./hooks/useCenterClosestElement";
import styled from "styled-components";

const CardWrapper = styled.div`
	overflow: auto;
	scroll-snap-type: x mandatory;
	display: flex;
`;

const Card = styled.div<{ isFocused: boolean }>`
	scroll-snap-align: start;
	flex: none;
	width: 70vw;
	height: 50vh;

	// 以下は見やすくするためのcss
	background-color: red;
	border: 2px solid black;
	margin-right: 5px;
	${({ isFocused }) => !isFocused && "opacity: 0.2"};
`;

export const App = () => {
	const containerRef = useRef<HTMLDivElement | null>(null);
	const [focusedItem, setFocusedItem] = useState<HTMLDivElement | null>(null);

	const handleScroll = useCallback(() => {
		useCenterClosestElement(containerRef, setFocusedItem);
	}, []);

	useEffect(() => {
		const container = containerRef.current;
		if (!container) return;

		// 初期表示アイテム設定
		const initialItem = container.children[0] as HTMLDivElement;
		setFocusedItem(initialItem);
		container.addEventListener("scroll", handleScroll);

		// クリーンアップ
		return () => {
			container.removeEventListener("scroll", handleScroll);
		};
	}, []);

	return (
		<CardWrapper ref={containerRef}>
			<Card isFocused={focusedItem === containerRef.current?.children[0]}>
				カード１
			</Card>
			<Card isFocused={focusedItem === containerRef.current?.children[1]}>
				カード２
			</Card>
			<Card isFocused={focusedItem === containerRef.current?.children[2]}>
				カード３
			</Card>
		</CardWrapper>
	);
};

```

# hooksの解説

`useCenterClosestElement`は、`containerRef`と`setter`という2つの引数を受け取ります。

`containerRef`にはスクロールスナップでスライドさせる要素をラップしているdiv要素を渡します。今回の例では`CardWrapper`がこれに当たります。

`setter`には、現在主として表示している要素を入れるローカルstateを変更するset関数が入ります。今回の例では`setFocusedItem`がこれに当たります。

hook内でやっているのは、`containerRef`内にある全ての子要素に対して、画面の中央との距離を計測して、距離が最も小さいものを`setter`の引数に渡しています。

改善の余地はとってもありそうです。

# まとめ

今回は力技で実装しましたが、世の中には便利なライブラリがたくさんあるので、よほどの理由がない限りはそれらを使う方がいいでしょう。