1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIでアナログ時計を表示する

1
Posted at

はじめに

SwiftUI で「時刻を選択する UI」といえば、まず思い浮かぶのは DatePicker だと思います。

@State private var date = Date()

DatePicker("時間", selection: $date, displayedComponents: .hourAndMinute)

date_picker.gif

これはこれで便利なのですが、「もう少し遊びのある時刻選択 UI が欲しい…」と思うことはないでしょうか?
ということで、アナログ時計風に時刻を選べる SwiftUI コンポーネントを作ってみました。

こんな感じです:watch:
内側の円をドラッグすると「時針」、外側をドラッグすると「分針」を動かせます。

clock.gif

使い方

まずは完成形の使い方イメージから。

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 が使えます。

実装方針

実装方針はこんな感じです。

  1. 画面中心から見た「角度」で時刻を表現する
  2. 12時の方向を基準の角度として決める
  3. そこから以下のルールで角度を決める
    • 1時間 = 30°(= 2π / 12)
    • 1分 = 6°(= 2π / 60)

あとはこれをもとに下記をそれぞれ実装していきます。

  1. 円(文字盤)
  2. 目盛り(分・時)
  3. 文字(1〜12)
  4. 針(時針・分針)
  5. ドラッグ位置からの角度計算

実装全体

まずはコード全文です。

コード全文
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(分針モード)に切り替えつつ、ドラッグ位置から角度 → 時刻を逆算しています。

完成!

clock.gif

おわりに

業務系の画面だと DatePicker のほうが無難な場面も多いですが、ちょっと遊びのあるアプリや、設定画面にこだわりたいときには、こういったアナログ時計 UI も選択肢としてアリかなと思います:watch:

@Binding<Date> で呼び出せるようにしてあるので、そのままコンポーネントとしてプロジェクトに組み込めます:v:

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?