以前開発したサービスにおいて、予定入力機能があり、ユーザーに時間選択操作を求めていたのですが、これがドラムロール式のUIで、操作している際に滑ってしまったり、入力に時間がかかってしまうなど、改善したいなと前々から考えていました。
そのサービス自体、スマートフォンから操作されることが多かったため、直感的に入力ができるアナログ時計型のUIのタイムピッカーを採用することにしました。
Googleカレンダーとかのをまさにイメージしていました。イメージを実現するプラグインは探せば色々ありそうな雰囲気でしたが、今回、勉強も兼ねてtailwindcssで実装してみました。
イメージ
See the Pen Untitled by Yusuke (@Yusuke-the-flexboxer) on CodePen.
コード
import { useState, useEffect } from "react";
const ClockTimePicker = () => {
const [selectedHour, setSelectedHour] = useState(0); // 0-23
const [selectedMinute, setSelectedMinute] = useState(0);
const [isSelectingHour, setIsSelectingHour] = useState(true);
const [isAnimating, setIsAnimating] = useState(false);
const [handAngles, setHandAngles] = useState({ hour: 0, minute: 0 });
const amHours = Array.from({ length: 12 }, (_, i) => i); // 0-11
const pmHours = Array.from({ length: 12 }, (_, i) => i + 12); // 12-23
const minutes = Array.from({ length: 12 }, (_, i) => i * 5);
useEffect(() => {
const hourAngle = ((selectedHour % 12) / 12) * 360;
const minuteAngle = (selectedMinute / 60) * 360;
setHandAngles({ hour: hourAngle, minute: minuteAngle });
}, [selectedHour, selectedMinute]);
useEffect(() => {
if (isAnimating) {
// アニメーションの持続時間に合わせて遅延処理
const animationDuration = 500; // ms
const timer = setTimeout(() => {
setIsSelectingHour(false);
setIsAnimating(false);
}, animationDuration);
return () => clearTimeout(timer);
}
}, [isAnimating]);
const handleHourClick = (value: number) => {
if (isAnimating) return; // アニメーション中はクリックを無効化
setSelectedHour(value);
setIsAnimating(true);
};
const handleMinuteClick = (value: number) => {
if (isAnimating) return;
setSelectedMinute(value);
};
const getTransform = (value: number, total: number, radius: number) => {
const angleInDegrees = (value / total) * 360 - 90;
const angleInRadians = (angleInDegrees * Math.PI) / 180;
const x = radius * Math.cos(angleInRadians);
const y = radius * Math.sin(angleInRadians);
return `translate(-50%, -50%) translate(${x}px, ${y}px)`;
};
return (
<div className="w-80 bg-transparent rounded-lg shadow-lg p-4">
<div className="text-center mb-4">
{/* Control Buttons */}
<div className="mt-4 flex justify-center items-center space-x-2">
<button
className={`px-4 py-2 rounded ${isSelectingHour ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-800"}`}
onClick={() => {
if (!isAnimating) setIsSelectingHour(true);
}}
disabled={isAnimating}
>
{selectedHour.toString().padStart(2, "0")}
</button>
<span>:</span>
<button
className={`px-4 py-2 rounded ${!isSelectingHour ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-800"}`}
onClick={() => {
if (!isAnimating) setIsSelectingHour(false);
}}
disabled={isAnimating}
>
{selectedMinute.toString().padStart(2, "0")}
</button>
</div>
</div>
<div className="relative w-64 h-64 mx-auto">
<div className="absolute inset-0 rounded-full border-4 border-gray-200">
{/* Hour Selection */}
{isSelectingHour ? (
<>
{/* AM Hours */}
{amHours.map((hour) => (
<button
key={hour}
className={`absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold
${selectedHour === hour ? "bg-blue-500 text-white" : "bg-transparent text-gray-800 hover:bg-gray-200"}`}
style={{
left: "50%",
top: "50%",
transform: getTransform(hour, 12, 100),
}}
onClick={() => handleHourClick(hour)}
disabled={isAnimating}
>
{hour}
</button>
))}
{/* PM Hours */}
{pmHours.map((hour) => (
<button
key={hour}
className={`absolute w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold
${selectedHour === hour ? "bg-blue-500 text-white" : "bg-transparent text-gray-800 hover:bg-gray-200"}`}
style={{
left: "50%",
top: "50%",
transform: getTransform(hour, 12, 63),
}}
onClick={() => handleHourClick(hour)}
disabled={isAnimating}
>
{hour}
</button>
))}
{/* Hour Hand */}
<div
className="absolute bg-blue-500 origin-bottom transform left-1/2 top-1/2 transition-transform duration-300 ease-in-out"
style={{
width: "2px",
height: selectedHour < 12 ? "35%" : "20%",
transform: `translate(-50%, -100%) rotate(${handAngles.hour}deg)`,
}}
></div>
</>
) : (
<>
{/* Minute Selection */}
{minutes.map((minute) => (
<button
key={minute}
className={`absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold
${selectedMinute === minute ? "bg-blue-500 text-white" : "bg-transparent text-gray-800 hover:bg-gray-200"}`}
style={{
left: "50%",
top: "50%",
transform: getTransform(minute / 5, 12, 100),
}}
onClick={() => handleMinuteClick(minute)}
>
{minute.toString().padStart(2, "0")}
</button>
))}
{/* Minute Hand */}
<div
className="absolute bg-blue-500 origin-bottom transform left-1/2 top-1/2 transition-transform duration-300 ease-in-out"
style={{
width: "2px",
height: "35%",
transform: `translate(-50%, -100%) rotate(${handAngles.minute}deg)`,
}}
></div>
</>
)}
</div>
{/* Center Dot */}
<div className="absolute top-1/2 left-1/2 w-2 h-2 bg-blue-500 rounded-full transform -translate-x-1/2 -translate-y-1/2"></div>
</div>
</div>
);
};
export default ClockTimePicker;
あとがき
あとは必要に応じて、従来のドラムロール式の入力UIに切替できるようなボタンを用意してあげれば、良いかなと思います。
コメント