ツクログネット

【React】時間や生年月日などをダイヤル南京錠のように回転させながら選択するピッカーを作る

eye catch

スマホアプリでよく見かける生年月日や時刻等を回転させながら選択するピッカーを作ります。

ピッカーの見た目を作る

ではまず、ピッカーの見た目を作ります。今回は生年月日を選択するピッカーと仮定して進めていきます。

はじめに、WheelPickerコンポーネントを作成します。WheelPickerコンポーネントはピッカーを描画する役割を持ちます。

WheelPicker.js
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components'

const StyledWheelPicker = styled.div`
	display: grid;
	grid-template-rows: repeat(2, auto);
  grid-template-columns: repeat(auto-fill, auto);	
	place-items: center; 
	align-items: center;
	justify-content: center;
	gap: .5em;

	& .delimiter {
		font-size: .75em;

		&-1 {
			grid-column: 2 / 3;
		}
		&-2 {
			grid-column: 4 / 5;
		}
		&-3 {
			grid-column: 6 / 7;
		}
	}

	& .dial {
		&-display {
			border-top: .1em solid #333;
			border-bottom: .1em solid #333;
			height: 1em;
			overflow-y: hidden;
			padding: .4em;

			&-1 {
				grid-column: 1 / 2;
			}
			&-2 {
				grid-column: 3 / 4;
			}
			&-3 {
				grid-column: 5 / 6;
			}
		}
		&-title {
			font-size: .6em;

			&-1 {
				grid-column: 1 / 2;
			}
			&-2 {
				grid-column: 3 / 4;
			}
			&-3 {
				grid-column: 5 / 6;
			}
		}
		&-nums {
			cursor: pointer;
			position: relative;
			top: 0em;
			transition: .2s linear;
			user-select: none;
		}

		&-num {
			height: 1em;
		}
	}
`

const WheelPicker = ({ className, dials, delimiters }) => {

  // dialsオブジェクトを配列に変換
	const dialNames = Object.keys(dials)

	return (
		<StyledWheelPicker className={className}>
			{
				dialNames.map((name, i, arr) => (
					<div className={`dial-title dial-title-${i + 1}`}>
								{name}
							</div>
				))
			}
			{
				dialNames.map((name, i, arr) => (
					<>
						<div className={`dial-display dial-display-${i + 1}`}>
							<div
								className={`dial-nums dial-nums-${i}`}
								key={i}
								id={name}
							>
								{
									dials[name].map((num, i, array) => (
										<div className="dial-num" key={i}>{String(num).padStart(2, "0")}</div>
									))
								}
							</div>
						</div>
						{delimiters[i] && <div className={`delimiter delimiter-${i + 1}`}>{delimiters[i]}</div>}
					</>
				))
			}	
			<Spacer size=".5em" horizontal={true} />
		</StyledWheelPicker>
	)
}

export default WheelPicker

上記のSpacerコンポーネントはコンポーネント間の余白を加える役割を持ちます。SpacerコンポーネントについてはReactで余白をどうスタイリングするかを参考にしました。

このコンポーネントのポイントは下記の通りです。

見た目の装飾

見た目の装飾はstyled-cmponentsで作成したStyledWheelPickerコンポーネントが行います。

レイアウトはCSS Gridで行っています。

ダイヤルは1文字分ずつ回転させるため、ダイヤル内の項目.dial-numの高さを1emに指定しています。

受け取るプロパティ

WheelPickerコンポーネントは下記のプロパティを受け取ります。

プロパティ名
className クラス名
dials 各ダイヤルを示すオブジェクト
delimiters ダイヤル間の区切り文字を要素に持つ配列
handleWheel ダイヤル上でマウスホイールを回転させたときに実行する関数
handleDown マウス等でダイヤルを押し込んだ時に実行する関数

dialsには、下記のようなダイヤル毎のダイヤル名とその値で構成されたオブジェクトを設定します。

{
  Days: [1, 2, 3, ..., 30, 31]
  Months: [1, 2, 3, ..., 11, 12]
  Years: [2022, 2021, 2020, ..., 1904, 1903]
}

delimitersには、下記のようなダイヤル間の区切り文字を要素に持つ配列を設定します。

['/', '/']

delimitersを配列にしている理由は、例えば2000年1月28日の「年・月・日」ように、ダイヤル間で区切る文字がすべて同じとは限らないからです。

ダイヤル名の追加

ダイヤル名は、dialsオブジェクトのプロパティ名をそのままダイヤル名として使います。mapメソッドでdialsオブジェクトを展開し、プロパティ名のみを取り出せば良さそうですが、mapメソッドは配列のメソッドであるため、オブジェクトであるdialsからは呼び出せません。

なので、Object.keysメソッドでdialsオブジェクトを配列に変換しています。

Object.keys(dials)

変換されたdialsオブジェクトは下記のような配列になります。

['Days', 'Months', 'Years']

あとは、この配列の中身をmapメソッドで展開すれば、ダイヤル名が追加できます。

{
				dialNames.map((name, i, arr) => (
					<div className={`dial-title dial-title-${i + 1}`}>
								{name}
							</div>
				))
			}

ダイヤルの追加

先ほど作成した配列dialNamesをmapメソッドで展開し、ダイヤル名の数だけビューにダイヤル.dial-numsを追加しています。

そして、各ダイヤル内に値を縦に羅列していきます。羅列する値はdials[name]内にセットされており、mapメソッドで展開して得られます。

{
				dialNames.map((name, i, arr) => (
					<>
						<div className={`dial-display dial-display-${i + 1}`}>
							<div
								className={`dial-nums dial-nums-${i}`}
								key={i}
								id={name}
							>
								{
									dials[name].map((num, i, array) => (
										<div className="dial-num" key={i}>{String(num).padStart(2, "0")}</div>
									))
								}
							</div>
						</div>
						{delimiters[i] && <div className={`delimiter delimiter-${i + 1}`}>{delimiters[i]}</div>}
					</>
				))
			}

ダイヤル間の区切り文字の追加

ダイヤル間の区切り文字は、ダイヤルを追加する過程で合わせて追加しています。

下記の部分です。

{delimiters[i] && <div className={`delimiter delimiter-${i + 1}`}>{delimiters[i]}</div>}

では、このWheelPickerコンポーネントを使ってピッカーをブラウザに表示します。

App.js
import React, {
  useState,
  useRef,
  useCallback,
  useEffect
} from 'react';
import styled, { createGlobalStyle } from 'styled-components'
import WheelPicker from './components/WheelPicker'

const GlobalStyle = createGlobalStyle`
  body {
  	background-color: var(--bg-color);
	  color: var(--text-color);
	  display: flex;
	  align-items: center;
	  justify-content: center;
    font-family: 'Orelega One', cursive;
	  font-size: 4vmin;
	  margin: 0;
	  height: 100vh;
	  width: 100%;
  }
`;

const StyledApp = styled.div`
	width: 16em;
`

const App = () => {
  const date = new Date()
  const year = date.getFullYear()

  const DATE = {
	  Days: [...Array(31)].map((u, i) => i + 1),
	  Months: [...Array(12)].map((u, i) => i + 1),
	  Years: [...Array(120)].map((u, i) => year - i),
  }

  const DELIMITERS = ['/', '/']

	return (
		<>
			<GlobalStyle />
			<StyledApp>
				<WheelPicker
					className="wheel-picker"
					items={DATE}
					delimiters={DELIMITERS}
				/>
			</StyledApp>
		</>
	);
};

export default App

ブラウザには下記のようなピッカーが表示されます。この時点ではまだ何も動きません。

react-wheel-number-picker-post1

ダイヤルをマウスホイールで回転させる

ここからはピッカーに動きを加えていきます。まずは、ダイヤルの上でマウスホイールを回転させると、その方向へダイヤルが回転するようにします。

はじめに、各ダイヤルの選択中の状態を保存するステートselectNumsを定義します。

// 省略

const App = () => {
	// 省略

  const [selectNums, setSelectNums] = useState(() => {
		const newNums = {}

		Object.keys(nums).forEach((key) => {
			newNums[key] = nums[key][0]
		})

		return newNums
	})

  return (
		// 省略
	);
}

export default App

selectNumsステートの初期値は、DATEオブジェクトの各プロパティの値である、羅列する値が入る配列の先頭の値をプロパティ値に持つオブジェクトです。

{
  Days: 1,
  Months: 1,
  Years: 2022
}

続いて、マウスホイールを回転させたときに実行するhandleWheel関数を定義し、それをWheelPickerコンポーネントのhandleWheelプロパティに設定します。

これにより、ダイヤル上でマウスホイールを回転させると、その方向へダイヤルが1文字分進むようになり、同時に現在のピッカーの状態を示すステートも更新されます。

// 省略

const App = () => {
	// 省略

  const handleWheel = useCallback((e) => {

    // 下へホイールすればプラスの値、上はマイナスの値
		const deltaY = e.deltaY;

    // ホイールしたダイヤル
		const elm = e.currentTarget;

    // ホイールしたダイヤルの元になっている配列と一致するDATE内の配列
		const targetNums = DATE[elm.id]

    // ホイールしたダイヤルの現在の値を選択したダイヤル値に更新する
		setSelectNums(p => {

      // ホイールしたダイヤルの現在選択中の値
			const currentSelectNum = Math.abs(p[elm.id])

      // currentSelectNumがtargetNums配列内の何番目に位置するか
			const currentSelectNumIndex = targetNums.indexOf(currentSelectNum)

      // targetNums配列内の先頭要素の番号
			const firstIndex = 0

      // targetNums配列内の最後の要素の番号
			const lastIndex = targetNums.length - 1

      // targetNums配列内の先頭要素
			const firstNum = targetNums[firstIndex]

      // targetNums配列内の最後の要素
			const lastNum = targetNums[lastIndex]

			if (deltaY > firstIndex) {
        // 下にホイールしたらダイヤルを一つ先に進める

        // currentSelectNumIndexの一つ先の番号
				const nextIndex = currentSelectNumIndex + 1

        // targetNums配列におけるnextIndex番目の値
				const nextNum = targetNums[nextIndex];

				if(nextIndex > lastIndex){
          // 一つ先のダイヤル値が最後のダイヤル値を超えていれば、選択中のダイヤル値を先頭に戻す

          // ダイヤルの位置を先頭に移動する
					elm.style.transform = `translate3d(0, ${firstIndex}em, 0)`

          // 回転させたダイヤルの値を先頭のダイヤル値に更新する
					return {
						...p,
						[elm.id]: firstNum
					}
				}else{
          // そうでなければ一つ先にダイヤルを進める

					elm.style.transform = `translate3d(0, -${nextIndex}em, 0)`

          // ホイールしたダイヤルの選択中の値を
					return {
						...p,
						[elm.id]: nextNum
					}
				}
			}else{
        // 上にホイールしたらダイヤルを一つ前に戻す

				const prevIndex = currentSelectNumIndex - 1
				const prevNum = targetNums[prevIndex];

				if(prevIndex < firstIndex) {
					elm.style.transform = `translate3d(0, -${lastIndex}em, 0)`

					return {
						...p,
						[elm.id]: lastNum
					}
				}else{
					elm.style.transform = `translate3d(0, -${prevIndex}em, 0)`

					return {
						...p,
						[elm.id]: prevNum
					}
				}
			}
		})
	});

	return (
		<>
			{/* 省略 */}

			<StyledApp>
				<WheelPicker
					// 省略

					handleWheel={handleWheel}
				/>
			</StyledApp>
		</>
	);
};

export default App

ダイヤルをドラッグで回転させる

マウスホイールに加え、ダイヤル上でドラッグすると、その方向へダイヤルが回転するようにします。

はじめに、useDragElementフックを呼び出し、handleDown関数をWheelPickerコンポーネントのonMouseDown属性にセットします。これにより、ダイヤルのドラッグが可能になります。

useDragElementフックについては【React】要素のドラッグした方向を得るをご覧ください。

// 省略

import useDragElement from './hooks/useDragElement'

const App = () => {
	// 省略

  const [
		draggingElement,
		dragAmount,
		elementPosition,
		verticalDirection,
		horizontalDirection,
		handleDown
	] = useDragElement()

  const handleWheel = useCallback((e) => {
		// 省略
	});

	return (
		<>
			{/* 省略 */}

			<StyledApp>
				<WheelPicker
					// 省略

					handleDown={handleDown}
				/>
			</StyledApp>
		</>
	);
};

export default App

次に、ダイヤルをドラッグするとダイヤルがその方向へ1文字分進むようにします。この処理はuseEffectフックで行います。

// 省略

const App = () => {
  // 省略

  useEffect(() => {
    // ドラッグしていなければここで処理を終える
		if(!draggingElement) return;

    // ダイヤルの滑り度合を調節
		if(Math.abs(dragAmount.y) % 8 !== 0) return

    // ドラッグしているダイヤルと一致するDATE内の配列
		const targetNums = DATE[draggingElement.id]

    // ダイヤル値を選択した値に更新する
		setSelectNum(p => {
      // 現在選択中のダイヤル値
			const currentSelectNum = Math.abs(p[draggingElement.id])

      // targetNums配列内におけるcurrentSelectNumのインデックス
			const currentSelectNumIndex = targetNums.indexOf(currentSelectNum)

      // targetNums配列内の先頭の要素のインデックス
			const firstIndex = 0

      // targetNums配列内の最後の要素のインデックス
			const lastIndex = targetNums.length - 1

      // targetNums配列内の先頭の要素
			const firstNum = targetNums[firstIndex]

      // targetNums配列内の最後の要素
			const lastNum = targetNums[lastIndex]

			if(verticalDirection === 'top') {
				// 上へドラッグしたときはダイヤルを一つ前に戻す

        // currentSelectNumIndexの一つ前のインデックス
				const prevIndex = currentSelectNumIndex - 1

        // targetNums配列におけるcurrentSelectNumIndexの一つ前のインデックスに位置する値
				const prevNum = targetNums[prevIndex];

				if(prevIndex < firstIndex) {
           // ダイヤルを先頭より前に回したらダイヤルを最後の位置に移動する

					draggingElement.style.transform = `translate3d(0, -${lastIndex}em, 0)`

          // ドラッグしているダイヤルの選択中のダイヤル値を最後のダイヤル値に更新する
					return {
						...p,
						[draggingElement.id]: lastNum
					}
				}else{
          // そうでなければダイヤルを一つ前に進める

					draggingElement.style.transform = `translate3d(0, -${prevIndex}em, 0)`

          // ドラッグしているダイヤルの選択中のダイヤル値を一つ前のダイヤル値に更新する
					return {
						...p,
						[draggingElement.id]: prevNum
					}
				}
			}
			if(verticalDirection === 'bottom') {
				// 下へドラッグしたときはダイヤルを一つ先に進める

				const nextIndex = currentSelectNumIndex + 1
				const nextNum = targetNums[nextIndex];

				if(nextIndex > lastIndex) {
					draggingElement.style.transform = `translate3d(0, -${firstIndex}em, 0)`//`0em`

					return {
						...p,
						[draggingElement.id]: firstNum
					}
				}else{
					draggingElement.style.transform = `translate3d(0, -${nextIndex}em, 0)`

					return {
						...p,
						[draggingElement.id]: nextNum
					}
				}
			}				
		})	
	}, [dragAmount])

	return (
		<>
			{/* 省略 */}

			<StyledApp>
				<WheelPicker
					// 省略
				/>
			</StyledApp>
		</>
	);
};

export default App

選択中の値を表示してみる

では、各ダイヤルで選択中の値をブラウザに表示してみます。

App.js
// 省略

import DigitalDisplay from './components/DigitalDisplay'

// 省略

const StyledApp = styled.div`
  width: 16em;

  & .date-of-birth {
    color: '#333';
	  font-weight: bold;
	  font-size: '2em';
	  text-align: center;

	  & .delimiter {
		  margin: 0 .2em;
	  }
  }
`

const App = () => {
  // 省略

	return (
		<>
			<GlobalStyle />
			<StyledApp>
				<WheelPicker
					// 省略
			  />
        <div className="date-of-birth">
			    {
				    Object.values(selectNums)
				      .map((n, i, array) => (
					      <>
						      <span>{String(n).padStart(2, "0")}</span>
						      {
                    DELIMITERS[i] && 
                      <span className="delimiter">      
                        {DELIMITERS[i]}
                      </span>
                  }
					      </>
				     ))
			     }
		    </div>
			</StyledApp>
		</>
	);
};

export default App

ブラウザには下記のように表示されます。

react-wheel-number-picker2

以上です。

Demo

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