ツクログネット

Reactでレンジスライダーを作る

eye catch

開発環境の構築

Reactアプリの開発環境の構築についてはReactの開発環境を構築するをご覧ください。

ディレクトリ構成

プロジェクトのディレクトリ構成を下記のようにします。「my-app」はプロジェクト名です。

my-app
 └── src
     ├── components
     │   └── RangeSlider.js
     ├── hooks
     │   ├── useDragAndDrop.js
     │   ├── useMutationObserver.js
     |   ├── useResizeObserver.js
     │   └── useRangeSlider.js
     ├── App.css
     ├── App.js
     ├── index.css
     ├── index.js
     └── utils.js

今回は、App.css、index.css、index.jsの内容はプロジェクト作成時のままです。

styled-componentsのインストール

ターミナルで以下のコマンドを実行してstyled-componentsをインストールします。

yarn add styled-components

ユーティリティ関数を定義する

src配下のutils.jsに下記のユーティリティ関数を定義します。

utils.js

export const clamp = (value, min, max) => {
	if (value < min)
		return min
	else if (value > max)
		return max
	return value
}

clamp関数は第一引数value、第二引数min、第三引数maxを受け取り、valueの変化をminからmaxまでの範囲に制限します。

カスタムフックを作成する

以下のカスタムフックを作成します。

useDragAndDrop

hooks/useDragAndDrop.jsに以下のコードを書き、useDragAndDropフックを作成します。

useDragAndDropフックについては【React hooks】要素のドラッグ&ドロップを可能にするカスタムフックを作成するをご覧ください。

useMutationObserver

hooks/useMutationObserver.jsに以下のコードを書き、useMutationObserverフックを作成します。

useMutationObserverフックについては【React hooks】要素の変化を検知して何かをするカスタムフックを作成するをご覧ください。

useResizeObserver

hooks/useResizeObserver.jsに以下のコードを書き、useResizeObserverフックを作成します。

useResizeObserverフックについては【React hooks】要素のリサイズを検知して何かをするカスタムフックを作成するをご覧ください。

useRangeSlider

hooks/useRangeSlider.jsに以下のコードを書き、useRangeSliderフックを作成します。

useRangeSlider.js

import { 
    useState,
    useCallback,
    useRef,
    useEffect
} from 'react'

import useDragAndDrop from '../hooks/useDragAndDrop'
import useMutationObserver from '../hooks/useMutationObserver'
import useResizeObserver from '../hooks/useResizeObserver'
import { clamp } from '../utils'

const useRangeSlider = () => {
	const [
		{ 
			currentDragElement,
			elementPosition,
			elementOffset
		},
		{ 
			pointerStartPosition,
			pointerMovePosition
		},
		handleMouseDown,
		setElementOffset,
		resetElementOffset,
		setElementPosition
	] = useDragAndDrop();

	const [rangeSliderHandleOffsetX, setRangeSliderHandleOffsetX] = useState(
		0
	);

	const [rangeSliderHandleWidth, setRangeSliderHandleWidth] = useState(0);
	const [rangeSliderBarWidth, setRangeSliderBarWidth] = useState(0);

	const [rangeSliderBarPosition, setRangeSliderBarPosition] = useState({
		left: 0,
		top: 0
	});
	const [rangeSliderHandlePosition, setRangeSliderHandlePosition] = useState(0);

	const rangeSliderBarScale = useRef(1)

	const rangeSliderHandleElement = useRef(null);
	const rangeSliderBarElement = useRef(null);

	const previousRangeSliderBarWidth = useRef(0)

	useResizeObserver(
		[rangeSliderBarElement],
		(entries) => {
			const entry = entries[0]
			const width = entry.contentRect.width;
			const height = entry.contentRect.height;

			setRangeSliderBarWidth(width);

			const rect = entry.target.getBoundingClientRect();
			setRangeSliderBarPosition({
				left: rect.left,
				top: rect.top
			});
		}
	);

	useResizeObserver(
		[rangeSliderHandleElement],
		(entries) => {
			const entry = entries[0]

			const width = entry.contentRect.width;
			const height = entry.contentRect.height;

			if(width === 0 || height === 0) return

			setRangeSliderHandleWidth(width);

			rangeSliderBarScale.current =
				previousRangeSliderBarWidth.current === 0
				? 1
			: width / previousRangeSliderBarWidth.current;

			previousRangeSliderBarWidth.current = width

			setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)

			const left = entry.target.getBoundingClientRect().left;
			setRangeSliderHandlePosition(left);
		}
	)

	useMutationObserver(
		[rangeSliderHandleElement],
		(mutations) => {
			const left = mutations[0].target.getBoundingClientRect().left;
			setRangeSliderHandlePosition(left);
		},
		{
            attributes: true,
            subtree: false,
            childList: false,
            attributeFilter: ["class"]
        }
	);

	useEffect(() => {
		if (!currentDragElement.current) return;

		const rect = rangeSliderBarElement.current.getBoundingClientRect()

		const startX = pointerStartPosition.current.x - rect.left;

		setRangeSliderHandleOffsetX(clamp(startX, 0, rangeSliderBarWidth));
	}, [elementPosition]);

	useEffect(() => {
		const rect = rangeSliderBarElement.current.getBoundingClientRect()

		const moveX = pointerMovePosition.current.x - rect.left

		setRangeSliderHandleOffsetX((p) => clamp(moveX, 0, rangeSliderBarWidth));
	}, [elementOffset]);

	return [
		{
			handle: {
				element: rangeSliderHandleElement,
				offsetX: rangeSliderHandleOffsetX,
				position: elementPosition,
				width: rangeSliderHandleWidth
			},
			bar: {
				element: rangeSliderBarElement,
				position: rangeSliderBarPosition,
				width: rangeSliderBarWidth,
				scale: rangeSliderBarScale
			},
			handleMouseDown
		},
		setRangeSliderHandleOffsetX
	];
};

useRangeSliderフックが呼び出されると、以下のものが配列で返ります。

返り値 説明
handle レンジスライダーのハンドルの情報で構成されたオブジェクト
bar レンジスライダーのバーの情報で構成されたオブジェクト
handleMouseDown レンジスライダーのハンドルとバーのDOM要素のonMouseDown属性に設定する関数
setRangeSliderHandleOffsetX レンジスライダーのハンドルのオフセット値を更新する関数

useRangeSliderフックが呼び出されると、以下の処理が行われます。

  • バーを押下するとハンドルが押下したところに移動するようにする
  • ハンドルをドラッグで移動するようにする
  • バーがリサイズされる度にサイズ・位置を更新する
  • ハンドルがリサイズされる度にサイズ・位置・オフセット値を更新する
  • ハンドルが移動する度に位置を更新する

バーを押下するとハンドルが押下したところに移動するようにする

バーを押下したときに、ハンドルを押下したところに移動させるには、useEffectフックを用います。

始めに、バーが押下されたときにコールバック関数が実行されるように、第二引数の配列に、押下したときに更新されるDOM要素の位置elementPositionをセットします。

そして、コールバック関数内で、押下されたときの処理を行います。

useEffect(() => {
  // ここ何かをする
}, [elementPosition])

始めに、バーが押下されたときに、ページ左からのバーの座標からの相対的なドラック開始時のカーソル座標startXを取得します。

const rect = rangeSliderBarElement.current.getBoundingClientRect()
const startX = pointerStartPosition.current.x - rect.left

そして、startXをハンドルのオフセット値に上書きしますが、このままではハンドルがどこまでも移動できてしまうため、clamp関数で移動範囲を0~バーの幅までに制限します。。

setRangeSliderHandleOffsetX(clamp(startX, 0, rangeSliderBarWidth));

ただ、これだけではいけません。elementPositionは、押下を止めたとき(mouseupイベントが発生したとき)も更新されるのです。

つまりこのままでは、押下を止めたときもuseEffectフックのコールバック関数が実行されるため、pointerStartPosition.current.xは0になります。結果、startXはマイナスの値となり、clamp関数によってオフセット値は0になるため、ハンドルが元の位置に戻ってしまいます。

これを防ぐために、以下のようにcurrentDragElement.currentがnull、つまり押下していないときはuseEffectフックのコールバック関数が実行されないようにします。

if (!currentDragElement.current) return;

ハンドルをドラッグで移動するようにする

ハンドルがドラッグされたときに、何らかの処理を行うには、useEffectフックの第二引数の配列にドラッグしているDOM要素のオフセット値elementOffsetをセットします。

useEffect(() => {
  // ここ何かをする
}, [elementOffset])

ハンドルがドラッグされると、まず、バーからの相対的なドラッグ中のカーソル座標moveXを取得します。

const rect = rangeSliderBarElement.current.getBoundingClientRect()
const moveX = pointerMovePosition.current.x - rect.left

そして、その値をハンドルのオフセット値に上書きしますが、このままではハンドルがどこまでも移動できてしまうため、clamp関数で移動範囲を0~バーの幅までに制限します。

setRangeSliderHandleOffsetX((p) => clamp(moveX, 0, rangeSliderBarWidth));
  • ハンドルが移動する度に位置を更新する

バーがリサイズされる度にサイズ・位置を更新する

バーのリサイズを検知するには、ResizeObserverでバーのDOM要素を監視します。

useResizeObserverフックを呼び出し、第一引数に、監視するDOM要素を配列にセットして与え、第二引数にコールバック関数を与えます。

useResizeObserver(
		[rangeSliderBarElement],
		(entries) => {...}
)

そして、バーがリサイズされたときに、第二引数のコールバック関数が実行されます。

なので、このコールバック関数で幅・位置を更新します。ResizeObserverのコールバック関数は引数としてentries配列を受け取ります。

この配列には、ResizeObserverEntryオブジェクトがセットされており、このオブジェクトから、監視しているDOM要素への参照のほか、そのDOM要素のサイズが取得できます。

(entries) => {
    const entry = entries[0]
      const width = entry.contentRect.width;
					const height = entry.contentRect.height;

  // 幅
  setRangeSliderBarWidth(width);

  // 位置
  const rect = entry.target.getBoundingClientRect();
					setRangeSliderBarPosition({
						left: rect.left,
						top: rect.top
					});
    }

ハンドルがリサイズされる度にサイズ・位置・オフセット値を更新する

ハンドルの更新もバーと同様に行いますが、オフセット値は、幅や位置とは違って、リサイズ後の値を直接取得する方法がないため、こちら側で算出する必要があります。

その方法は、前回のリサイズ後の幅に対するリサイズ後の幅の割合でオフセット値を拡大・縮小します。

const entry = entries[0]
      const width = entry.contentRect.width;
					const height = entry.contentRect.height;

rangeSliderBarScale.current =
						previousRangeSliderBarWidth.current === 0
							? 1
							: width / previousRangeSliderBarWidth.current;

// オフセット値を更新
setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)

// 前回のリサイズ後の幅
previousRangeSliderBarWidth.current = width

また、ウィンドウサイズの変化中にバーの幅が0になると、rangeSliderBarScaleが0 / 1 = 0となるため、ハンドルのオフセット値がp * 0 = 0となります。そのため、再びバーの幅が0以上になったときに、ハンドルは先頭に戻った状態になってしまいます。

バーの幅が0になった後に再び0以上になったときに、ハンドルの途中経過が維持されるように、以下のようにして、バーの幅か高さが0であれば、オフセット値を更新しないようにします。

if(width === 0 || height === 0) return

ハンドルが移動するたびに位置を更新する

ハンドルがドラッグで移動するたびに位置を更新するには、ハンドルのDOM要素を監視しているMutationObserverのコールバック関数内で更新します。

MutationObserverを用いるために、useMutationObserverフックを呼び出します。

useMutationObserver(
		[rangeSliderHandleElement],
		(mutations) => {
			const left = mutations[0].target.getBoundingClientRect().left;
			setRangeSliderHandlePosition(left);
		},
		{
            attributes: true,
            subtree: false,
            childList: false,
            attributeFilter: ["class"]
        }
	);

そもそも何故、MutationObserverのコールバック関数内で更新する必要があるのかというと、ドラッグ後に、useEffectでハンドルの位置を更新すると、マウスを素早く動かしたときに値を正確にキャッチできないためです。正確にキャッチできないと、ハンドルが滑らかに動かずにガタガタしてしまいます。

その反面、MutationObserverは、要素を監視するため、急なマウスの素早い動きにも対応できるのです。

コンポーネントを作成する

components/RangeSlider.jsに以下のコードを書き、RangeSliderコンポーネントを作成します。

RangeSlider.js

const StyledRangeSlider = styled.div`
	display: flex;
	align-items: center;
	height: 20vmin;
	position: relative;
	width: 100%;
`;

const Bar = styled.div`
	background-color: #f0f0f0;
	border-radius: 10vmin;
	cursor: pointer;
	height: 10vmin;
	position: absolute;
	width: 100%;
`;

const Handle = styled.div`
	background-color: #ffa44a;
	border-radius: 100%;
	cursor: pointer;
	height: 16vmin;
	position: absolute;
	left: -8vmin;
	transform: translate3d(
		${({ offsetX }) => offsetX}px,
		0,
		0
	);
	width: 16vmin;
`;

const RangeSlider = ({ rangeSlider }) => (
	<StyledRangeSlider className="range-slider">
		<Bar
			className="range-slider-bar"
			ref={rangeSlider.bar.element}
			onMouseDown={rangeSlider.handleMouseDown}
		/>
		<Handle
			className="range-slider-handle"
			ref={rangeSlider.handle.element}
			onMouseDown={rangeSlider.handleMouseDown}
			offsetX={rangeSlider.handle.offsetX}
		/>
	</StyledRangeSlider>
);

rangeSliderオブジェクトのプロパティをBarコンポーネントとHandleコンポーネントの属性にそれぞれ指定しています。

BarコンポーネントとHandleコンポーネントはstyled-componentsで作成しています。

Handleコンポーネントのtranslate3dのx値にoffsetXをセットすることでハンドルのドラッグ操作が可能になります。

使用例

RangeSliderコンポーネントをアプリ内で使うと以下のようになります。

App.js

const StyledApp = styled.div`
	background-color: #8e82bf;
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;
`;

const Container = styled.div`
	width: 80vmin;
`;

const App = () => {
	const [rangeSlider] = useRangeSlider();

	return (
		<StyledApp>
			<Container>
				<RangeSlider rangeSlider={rangeSlider} />
			</Container>
		</StyledApp>
	);
};

useRangeSliderフックを呼び出すときに上記の例ではrangeSliderオブジェクトのみ使用するため、rangeSliderオブジェクトのみ展開しています。

DEMO

下記で実際の動作を確認できます。

参考文献