はじめに
SwiftUI で「時刻を選択する UI」といえば、まず思い浮かぶのは DatePicker だと思います。
@State private var date = Date()
DatePicker("時間", selection: $date, displayedComponents: .hourAndMinute)
これはこれで便利なのですが、「もう少し遊びのある時刻選択 UI が欲しい…」と思うことはないでしょうか?
ということで、アナログ時計風に時刻を選べる SwiftUI コンポーネントを作ってみました。
こんな感じです![]()
内側の円をドラッグすると「時針」、外側をドラッグすると「分針」を動かせます。
使い方
まずは完成形の使い方イメージから。
struct ContentView: View {
@State private var date = Date()
var body: some View {
VStack {
AMClockView(date: $date)
.frame(width: 300, height: 300)
Text("選択中: \(date.formatted(date: .omitted, time: .shortened))")
}
}
}
AMClockView に Binding<Date> を渡すだけで、ドラッグ操作で時刻を調整できるアナログ時計 UI が使えます。
実装方針
実装方針はこんな感じです。
- 画面中心から見た「角度」で時刻を表現する
- 12時の方向を基準の角度として決める
- そこから以下のルールで角度を決める
- 1時間 = 30°(= 2π / 12)
- 1分 = 6°(= 2π / 60)
あとはこれをもとに下記をそれぞれ実装していきます。
- 円(文字盤)
- 目盛り(分・時)
- 文字(1〜12)
- 針(時針・分針)
- ドラッグ位置からの角度計算
実装全体
まずはコード全文です。
コード全文
import SwiftUI
struct AMClockView: View {
enum EditType {
case none
case hour
case minute
}
@Binding var date: Date
@State private var editType: EditType = .none
@State private var startAngle: CGFloat = 0.0
private let borderWidth: CGFloat = 5.0
private let smallTickWidth: CGFloat = 1.0
private let largeTickWidth: CGFloat = 2.0
private let baseColor = Color.primary
private let calendar = Calendar(identifier: .gregorian)
// 12時の方向(3π/2)
private let twelveOClockAngle = CGFloat.pi + CGFloat.pi/2
// 1時間あたりの角度(30°)
private let radiansPerHour = CGFloat.pi * 2 / 12
// 1分あたりの角度(6°)
private let radiansPerMinute = CGFloat.pi * 2 / 60
private var currentHourAngle: CGFloat {
return calculateHourAngle(hour: currentHour)
}
private var currentMinuteAngle: CGFloat {
return twelveOClockAngle + radiansPerMinute * CGFloat(currentMinute)
}
private var currentMinute: Int {
currentComponents.minute ?? 0
}
private var currentHour: Int {
currentComponents.hour ?? 0
}
private var currentComponents: DateComponents {
calendar.dateComponents([.year, .month, .day, .hour, .minute, .second],
from: date)
}
var body: some View {
GeometryReader { geo in
let length = min(geo.size.width, geo.size.height)
let radius = length / 2
let center = CGPoint(x: radius, y: radius)
ZStack {
Circle()
.stroke(baseColor, lineWidth: borderWidth)
.frame(width: length, height: length)
Circle()
.stroke(.gray, lineWidth: 2)
.frame(width: length / 2, height: length / 2)
smallIndexLayer(radius: radius)
.stroke(baseColor, lineWidth: smallTickWidth)
.frame(width: length, height: length)
clockIndexLayer(radius: radius)
.stroke(baseColor, lineWidth: largeTickWidth)
.frame(width: length, height: length)
timeTexts(radius: radius, center: center)
.frame(width: length, height: length)
hourHand(radius: radius)
.stroke(baseColor, style: StrokeStyle(lineWidth: 5.0, lineCap: .round))
.frame(width: length, height: length)
minuteHand(radius: radius)
.stroke(baseColor, style: StrokeStyle(lineWidth: 3.0, lineCap: .round))
.frame(width: length, height: length)
}
.frame(width: length, height: length)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
handleDragChanged(point: value.location, radius: radius)
}
.onEnded { _ in
editType = .none
}
)
}
}
private func calculateHourAngle(hour: Int) -> CGFloat {
let hourIn12 = hour > 12 ? hour - 12 : hour
let base = twelveOClockAngle + radiansPerHour * CGFloat(hourIn12)
// 分に応じて時針を少し進める(例: 3時30分なら 3.5 時間ぶん進める)
let minuteOffset = (CGFloat(currentMinute) / 60.0) * radiansPerHour
return base + minuteOffset
}
}
// 目盛り描画
extension AMClockView {
// 1分ごとの細い目盛り
private func smallIndexLayer(radius: CGFloat) -> Path {
var path = Path()
let smallRadius = radius - (radius/20 + borderWidth)
let step = CGFloat.pi / 30
for i in 0..<60 where i % 5 != 0 {
let angle = twelveOClockAngle + step * CGFloat(i)
let start = CGPoint(
x: radius + radius * cos(angle),
y: radius + radius * sin(angle)
)
let end = CGPoint(
x: radius + smallRadius * cos(angle),
y: radius + smallRadius * sin(angle)
)
path.move(to: start)
path.addLine(to: end)
}
return path
}
// 1時間ごとの太い目盛り
private func clockIndexLayer(radius: CGFloat) -> Path {
var path = Path()
let smallRadius = radius - (radius / 10 + borderWidth)
let step = radiansPerHour
for i in 0..<12 {
let angle = twelveOClockAngle + step * CGFloat(i)
let start = CGPoint(
x: radius + radius * cos(angle),
y: radius + radius * sin(angle)
)
let end = CGPoint(
x: radius + smallRadius * cos(angle),
y: radius + smallRadius * sin(angle)
)
path.move(to: start)
path.addLine(to: end)
}
return path
}
}
// 文字盤描画
extension AMClockView {
private func timeTexts(radius: CGFloat, center: CGPoint) -> some View {
let length = radius / 4
let step = radiansPerHour
let smallRadius = radius - (radius/10 + borderWidth) - length/2
let times = ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]
return ZStack {
ForEach(0..<12, id: \.self) { i in
let angle = twelveOClockAngle + step * CGFloat(i)
let x = center.x + smallRadius * cos(angle)
let y = center.y + smallRadius * sin(angle)
Text(times[i])
.font(.system(size: length * 0.8))
.minimumScaleFactor(0.5)
.foregroundColor(baseColor)
.frame(width: length, height: length, alignment: .center)
.position(x: x, y: y)
}
}
}
}
// 短針・長針の描画
extension AMClockView {
private func hourHand(radius: CGFloat) -> Path {
handPath(radius: radius, ratio: 0.6, angle: currentHourAngle)
}
private func minuteHand(radius: CGFloat) -> Path {
handPath(radius: radius, ratio: 0.8, angle: currentMinuteAngle)
}
private func handPath(radius: CGFloat, ratio: CGFloat, angle: CGFloat) -> Path {
var path = Path()
let center = CGPoint(x: radius, y: radius)
let length = radius * ratio
let end = CGPoint(
x: center.x + length * cos(angle),
y: center.y + length * sin(angle)
)
path.move(to: center)
path.addLine(to: end)
return path
}
}
extension AMClockView {
private func handleDragChanged(point: CGPoint, radius: CGFloat) {
let center = CGPoint(x: radius, y: radius)
let dx = point.x - center.x
let dy = point.y - center.y
let distance = sqrt(dx*dx + dy*dy)
let hourRadius = radius / 2
if editType == .none {
// 内側なら時針、外側なら分針の編集モードに入る
if distance <= hourRadius {
editType = .hour
startAngle = currentHourAngle
} else if distance <= radius {
editType = .minute
startAngle = currentMinuteAngle
} else {
editType = .none
}
}
switch editType {
case .none:
break
case .hour:
editTimeHour(point: point, radius: radius)
case .minute:
editTimeMinute(point: point, radius: radius)
}
}
private func editTimeHour(point: CGPoint, radius: CGFloat) {
let newAngle = hourAngleFromPoint(point, radius: radius)
if startAngle == newAngle {
return
}
let hour = calculateElapsedTime(startAngle: startAngle,
endAngle: newAngle)
appendHour(hour)
startAngle = newAngle
}
private func hourAngleFromPoint(_ point: CGPoint, radius: CGFloat) -> CGFloat {
let radian = radiansFromPoint(point, radius: radius)
// 12時基準の 0〜2π に正規化(右回り)
var relative = radian - twelveOClockAngle
if relative < 0 {
relative += (.pi * 2)
}
// どの「時間帯(0〜11)」に属しているか
let hourIndex = Int(relative / radiansPerHour)
return calculateHourAngle(hour: hourIndex)
}
private func radiansFromPoint(_ point: CGPoint, radius: CGFloat) -> CGFloat {
let center = CGPoint(x: radius, y: radius)
// 下が+Yなので、角度計算のためにYだけ反転
let dx = point.x - center.x
let dy = -(point.y - center.y)
// atan2: 右向きが0、反時計回りが正
var angle = atan2(dy, dx)
// 時計用に「12時を基準 & 時計回り増加」に合わせる
angle = -angle + (.pi / 2)
// 0未満を 0〜2π に補正
if angle < 0 {
angle += (.pi * 2)
}
// 「12時 = twelveOClockAngle」の描画座標系に補正
angle += twelveOClockAngle
if angle >= .pi * 2 {
angle -= .pi * 2
}
return angle
}
private func calculateElapsedTime(startAngle: CGFloat, endAngle: CGFloat) -> Int {
var delta = endAngle - startAngle
// -2π ~ 2π の範囲に収める
if delta > .pi * 2 {
delta.formTruncatingRemainder(dividingBy: .pi * 2)
} else if delta < -.pi * 2 {
delta.formTruncatingRemainder(dividingBy: .pi * 2)
}
// +π を超えていたら逆回転のほうが近い
if delta > .pi {
delta -= .pi * 2
} else if delta < -.pi {
delta += .pi * 2
}
let rawHours = delta / radiansPerHour
// ±0.5 時間くらいを丸めるために round して Int 化
return Int(rawHours.rounded())
}
private func appendHour(_ hour: Int) {
date = date.addingTimeInterval(60 * 60 * Double(hour))
}
private func editTimeMinute(point: CGPoint, radius: CGFloat) {
let newMinute = minuteFromPoint(point, radius: radius)
if newMinute == currentMinute {
return
}
let oldAngle = currentMinuteAngle
updateCurrentDate(minute: newMinute)
let newAngle = currentMinuteAngle
adjustHourIfCrossedNoon(from: oldAngle, to: newAngle)
startAngle = newAngle
}
private func minuteFromPoint(_ point: CGPoint, radius: CGFloat) -> Int {
let radian = radiansFromPoint(point, radius: radius)
// twelveOClockAngle を基準に、0〜2π の相対角度に変換
var relative = radian - twelveOClockAngle
if relative < 0 {
relative += (.pi * 2)
}
let minute = Int(relative / radiansPerMinute)
// 念のため 0〜59 に収める
return (minute % 60 + 60) % 60
}
private func updateCurrentDate(minute: Int) {
var components = currentComponents
components.minute = minute
date = calendar.date(from: components) ?? date
}
/// 分針が 12時をまたぐように大きく動いたら、時を ±1 する
private func adjustHourIfCrossedNoon(from startAngle: CGFloat, to endAngle: CGFloat) {
// 角度差(0〜2π ベース)
let diff = endAngle - startAngle
let fullTurn = CGFloat.pi * 2
var normalized = diff
// [-2π, 2π] に正規化
if normalized > fullTurn {
normalized.formTruncatingRemainder(dividingBy: fullTurn)
} else if normalized < -fullTurn {
normalized.formTruncatingRemainder(dividingBy: fullTurn)
}
// 「大きく」回転していたら 12時跨ぎとみなす
// ここでは 270°(= twelveOClockAngle)以上動いた場合
if normalized > twelveOClockAngle {
// 大きく「時計回り」に回った → 1時間戻す
appendHour(-1)
} else if normalized < -twelveOClockAngle {
// 大きく「反時計回り」に回った → 1時間進める
appendHour(1)
}
}
}
実装ステップ(ざっくり解説)
上の全文から、要点だけ抜き出して簡単に解説します。
1. 円(外枠・内側の円)を描く
まずは土台となる円を 2 つ描きます。
private let borderWidth: CGFloat = 5.0
private let baseColor = Color.primary
var body: some View {
GeometryReader { geo in
let length = min(geo.size.width, geo.size.height)
let radius = length / 2
let center = CGPoint(x: radius, y: radius)
ZStack {
Circle()
.stroke(baseColor, lineWidth: borderWidth)
.frame(width: length, height: length)
Circle()
.stroke(.gray, lineWidth: 2)
.frame(width: length / 2, height: length / 2)
}
.frame(width: length, height: length)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
.contentShape(Circle())
}
}
2. 分・時の目盛りを描く
12時の角度を起点に、時計回りに目盛りを打っていきます。
// 12時の方向
private let twelveOClockAngle = CGFloat.pi + CGFloat.pi/2
// 30°
private let radiansPerHour = CGFloat.pi * 2 / 12
// body の ZStack に追加
smallIndexLayer(radius: radius)
.stroke(baseColor, lineWidth: smallTickWidth)
.frame(width: length, height: length)
clockIndexLayer(radius: radius)
.stroke(baseColor, lineWidth: largeTickWidth)
.frame(width: length, height: length)
// 目盛り描画
extension AMClockView {
// 1分ごとの細い目盛り
private func smallIndexLayer(radius: CGFloat) -> Path {
var path = Path()
let smallRadius = radius - (radius/20 + borderWidth)
let step = CGFloat.pi / 30
for i in 0..<60 where i % 5 != 0 {
let angle = twelveOClockAngle + step * CGFloat(i)
let start = CGPoint(
x: radius + radius * cos(angle),
y: radius + radius * sin(angle)
)
let end = CGPoint(
x: radius + smallRadius * cos(angle),
y: radius + smallRadius * sin(angle)
)
path.move(to: start)
path.addLine(to: end)
}
return path
}
// 1時間ごとの太い目盛り
private func clockIndexLayer(radius: CGFloat) -> Path {
var path = Path()
let smallRadius = radius - (radius / 10 + borderWidth)
let step = radiansPerHour
for i in 0..<12 {
let angle = twelveOClockAngle + step * CGFloat(i)
let start = CGPoint(
x: radius + radius * cos(angle),
y: radius + radius * sin(angle)
)
let end = CGPoint(
x: radius + smallRadius * cos(angle),
y: radius + smallRadius * sin(angle)
)
path.move(to: start)
path.addLine(to: end)
}
return path
}
}
3. 文字盤(数字)を配置する
続いて、1〜12 を角度位置に応じて配置します。
// body の ZStack に追加
timeTexts(radius: radius, center: center)
.frame(width: length, height: length)
extension AMClockView {
private func timeTexts(radius: CGFloat, center: CGPoint) -> some View {
let length = radius / 4
let step = radiansPerHour
let smallRadius = radius - (radius/10 + borderWidth) - length/2
let times = ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]
return ZStack {
ForEach(0..<12, id: \.self) { i in
let angle = twelveOClockAngle + step * CGFloat(i)
let x = center.x + smallRadius * cos(angle)
let y = center.y + smallRadius * sin(angle)
Text(times[i])
.font(.system(size: length * 0.8))
.minimumScaleFactor(0.5)
.foregroundColor(baseColor)
.frame(width: length, height: length, alignment: .center)
.position(x: x, y: y)
}
}
}
}
4. 時針・分針を描く
date から角度を計算して、線として描画します。
@Binding var date: Date
private let calendar = Calendar(identifier: .gregorian)
// 6°
private let radiansPerMinute = CGFloat.pi * 2 / 60
private var currentHourAngle: CGFloat {
return calculateHourAngle(hour: currentHour)
}
private var currentMinuteAngle: CGFloat {
return twelveOClockAngle + radiansPerMinute * CGFloat(currentMinute)
}
// body の ZStack に追加
hourHand(radius: radius)
.stroke(baseColor, style: StrokeStyle(lineWidth: 5.0, lineCap: .round))
.frame(width: length, height: length)
minuteHand(radius: radius)
.stroke(baseColor, style: StrokeStyle(lineWidth: 3.0, lineCap: .round))
.frame(width: length, height: length)
extension AMClockView {
private func hourHand(radius: CGFloat) -> Path {
handPath(radius: radius, ratio: 0.6, angle: currentHourAngle)
}
private func minuteHand(radius: CGFloat) -> Path {
handPath(radius: radius, ratio: 0.8, angle: currentMinuteAngle)
}
private func handPath(radius: CGFloat, ratio: CGFloat, angle: CGFloat) -> Path {
var path = Path()
let center = CGPoint(x: radius, y: radius)
let length = radius * ratio
let end = CGPoint(
x: center.x + length * cos(angle),
y: center.y + length * sin(angle)
)
path.move(to: center)
path.addLine(to: end)
return path
}
}
時針の角度計算だけ、分を考慮するのがポイントです。
private func calculateHourAngle(hour: Int) -> CGFloat {
let hourIn12 = hour > 12 ? hour - 12 : hour
let base = twelveOClockAngle + radiansPerHour * CGFloat(hourIn12)
// 分のぶんだけ時針を進める
let minuteOffset = (CGFloat(currentMinute) / 60.0) * radiansPerHour
return base + minuteOffset
}
5. DragGesture で針を動かす
最後に、ドラッグ操作で時刻を変更できるようにします。
内側:EditType.hour(時針モード)、外側:EditType.minute(分針モード)に切り替えつつ、ドラッグ位置から角度 → 時刻を逆算しています。
完成!
おわりに
業務系の画面だと DatePicker のほうが無難な場面も多いですが、ちょっと遊びのあるアプリや、設定画面にこだわりたいときには、こういったアナログ時計 UI も選択肢としてアリかなと思います![]()
@Binding<Date> で呼び出せるようにしてあるので、そのままコンポーネントとしてプロジェクトに組み込めます![]()

