成果物
スクロールスナップを実装した時、主に表示されている要素(今回は左端に留められている要素)は普通に表示して、それ以外は薄く表示する。
実装
やっていることはかなり力技です。
// 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);
};
// 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]}>
カード1
</Card>
<Card isFocused={focusedItem === containerRef.current?.children[1]}>
カード2
</Card>
<Card isFocused={focusedItem === containerRef.current?.children[2]}>
カード3
</Card>
</CardWrapper>
);
};
hooksの解説
useCenterClosestElement
は、containerRef
とsetter
という2つの引数を受け取ります。
containerRef
にはスクロールスナップでスライドさせる要素をラップしているdiv要素を渡します。今回の例ではCardWrapper
がこれに当たります。
setter
には、現在主として表示している要素を入れるローカルstateを変更するset関数が入ります。今回の例ではsetFocusedItem
がこれに当たります。
hook内でやっているのは、containerRef
内にある全ての子要素に対して、画面の中央との距離を計測して、距離が最も小さいものをsetter
の引数に渡しています。
改善の余地はとってもありそうです。
まとめ
今回は力技で実装しましたが、世の中には便利なライブラリがたくさんあるので、よほどの理由がない限りはそれらを使う方がいいでしょう。