スマホアプリでよく見かける生年月日や時刻等を回転させながら選択するピッカーを作ります。
ピッカーの見た目を作る
ではまず、ピッカーの見た目を作ります。今回は生年月日を選択するピッカーと仮定して進めていきます。
はじめに、WheelPickerコンポーネントを作成します。WheelPickerコンポーネントはピッカーを描画する役割を持ちます。
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コンポーネントを使ってピッカーをブラウザに表示します。
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
ブラウザには下記のようなピッカーが表示されます。この時点ではまだ何も動きません。
ダイヤルをマウスホイールで回転させる
ここからはピッカーに動きを加えていきます。まずは、ダイヤルの上でマウスホイールを回転させると、その方向へダイヤルが回転するようにします。
はじめに、各ダイヤルの選択中の状態を保存するステート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
選択中の値を表示してみる
では、各ダイヤルで選択中の値をブラウザに表示してみます。
// 省略
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
ブラウザには下記のように表示されます。
以上です。
Demo
下記で実際の動作を確認できます。