ReactベースでつくるInstagramライクな要素位置インジケータ
この記事はエアークローゼット Advent Calendar 2022 21日目の記事です。
株式会社エアークローゼットでエンジニアをしています、小林です。
今回はUIづくりの小ネタをご紹介。
インスタっぽい要素位置インジケータの作り方を置いときます。
作り方
わりとシンプルな構造をしていて、やっていることは
- 現在フォーカスされている要素がどこかを変数に格納する(以下、説明用コードに合わせて
focusedId
と呼びます) - それぞれの要素に「
focusedId
から自身がいくつ離れているか」を持たせ、それによってサイズや色を管理するクラスを当てかえる -
focusedId
の動きに応じて、インジケータのwrapper divをtransform属性で動かす
の3つ。classの動きやstyle属性の書き換えによって動かしているので、transition属性で変化速度を指定しておくことできれいにトランジションが動くようになります。
それぞれ実際のコードを分解しつつ解説します。
1. 現在フォーカスされている要素がどこかを変数に格納する
これについては実装するUIがどんなものかによって実装も変わってくると思いますので、今回の実装でどう取ったかだけ置いておきます。
React.refは、current属性を掘っていくことで自身のスクロール位置を取得する事ができます。
お洋服の画像が並ぶカードUIのwrapperに対してこれを行い、スクロール位置を取得します。
export const useWatchScrollX = <T extends HTMLElement>(ref: RefObject<T>) => {
const [x, setX] = useState<number | undefined>(0);
const set = () => {
setX(ref.current?.scrollLeft);
};
const useEffectInEvent = (event: 'scroll', useCapture?: boolean) => {
useEffect(() => {
set();
if (ref.current) {
const _set = throttle(set, 100);
ref.current.addEventListener(event, _set, useCapture);
return () => {
ref.current?.removeEventListener(event, _set, useCapture);
};
}
}, []);
};
useEffectInEvent('scroll', true);
return x;
};
const ref = useRef<HTMLDivElement>(null);
const x = useWatchScrollX(ref);
// cardのサイズ + 10pxごとにフォーカス切り替え点を配置。しきい値を超えるたびフォーカスが切り替わります
const cardSize = 315;
const cardGapSize = 10;
const eachScrollLength = cardSize + cardGapSize;
const getFocusedId = (xRect?: number) => {
return (xRect && Math.round(xRect / eachScrollLength) + 1) || 1;
};
const [focusedId, setFocusedId] = useState(getFocusedId(x));
useEffect(() => {
setFocusedId(getFocusedId(x));
}, [x]);
// ~~~
<div ref={ref} className={styles.cards}>
{itemList.map((item, index, self) => (
// カードUI
))}
</div>
focusedId
から自身がいくつ離れているか」を持たせ、それによってサイズや色を管理するクラスを当てかえる
2. それぞれの要素に「 const getSelfSizeClass = (index: number) => {
const distanceFromCurrentCard = focusedId ? focusedId - index : -1;
const isFocused = distanceFromCurrentCard === 0;
const plus1 = distanceFromCurrentCard === -1;
const plus2 = distanceFromCurrentCard === -2;
const plus3 = distanceFromCurrentCard === -3;
const plus4 = distanceFromCurrentCard === -4;
const minus1 = distanceFromCurrentCard === 1;
const minus2 = distanceFromCurrentCard === 2;
const minus3 = distanceFromCurrentCard === 3;
const minus4 = distanceFromCurrentCard === 4;
if (isFocused) return styles.isFocused;
if (plus1 || minus1) return styles.isNormal;
if (plus2 || minus2) return styles.isSmall;
if (plus3 || minus3) return styles.isMinimum;
return styles.isInvisible;
};
// ~~~
<div className={styles.indicatorContainer} style={{ transform: containerTranslatePosition }}>
{itemList.map((item, index) => (
<div
className={`
${styles.indicator}
${/* ↓この返り値のクラス名に当たっているスタイルでサイズを切り替える↓ */}
${getSelfSizeClass(index + 1)}
`}
/>
))}
</div>
focusedIdが変わるたび、各インジケータに付与されるclassが切り替わります。
フォーカスされている要素に近いほど大きく、遠いほど小さく、4つ以上離れると基本的に見えないレベルまで小さくなるようになっています。
focusedId
の動きに応じて、インジケータのwrapper divをtransform属性で動かす
3. wrapperを動かし、かつ2で行うにインジケータのサイズ変更が加わると、冒頭のgifのようにインスタの記事をスワイプしたときの操作にだいぶ近いインタラクションを実現できます。
男は黙って style属性直接操作
const [containerTranslatePosition, setContainerTranslatePosition] = useState('');
const getContainerTranslatePosition = () => {
return setContainerTranslatePosition(`translateX(${-10 * focusedId}px)`);
};
useEffect(() => {
getContainerTranslatePosition();
}, [focusedId]);
// ~~~
<div className={styles.indicatorContainer} style={{ transform: containerTranslatePosition }}>
{itemList.map((item, index) => (
// インジケータのHTMLが要素の数だけ産まれる ○ ○ ○
))}
</div>
3問目くらいではこのくらいの位置にいて、一問ずつ左にずれていく要領です。
終盤になると
このへんまで動きます。
以上、基本的なインジケータの動きの実装でした。
最初と最後だけインジケータの大きさを調整したいなどの調整もgetSelfSizeClass
の分岐を増やすなどで対応でき、意外にも汎用性のある実装になっていると思います。
おわりに
作るために意外と発想力が問われたぶん作りきったあとの達成感が結構ありました。やはりUIは楽しい。
けっこう再現度も高くできたので満足。ぜひお手元のインスタアプリで見比べてみてください!
エアークローゼット Advent Calendar 2022もあと4記事。ぜひご覧ください!
Discussion