アナログ時計型のタイムピッカーをTailwind CSSで実装してみた

アナログ時計のアイコンと、「アナログ時計型のタイムピッカーをTailwind CSSで実装してみた」というブログ記事タイトル プログラミング

以前開発したサービスにおいて、予定入力機能があり、ユーザーに時間選択操作を求めていたのですが、これがドラムロール式の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に切替できるようなボタンを用意してあげれば、良いかなと思います。

コメント

タイトルとURLをコピーしました