1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

独自UIを作ろう!ー24時間ゲージ偏ー

Posted at

はじめに

前回 SwiftUI の座標と角度について完全に理解しました!これを使えば色々なものが作れます。

ということで今回は、開始時刻と終了時刻をドラッグで直感的に設定できる「24時間ゲージ」を SwiftUI で自作してみます。
こんな感じです。ドラッグで 15 分単位の開始時刻と終了時刻が設定できます。

gauge.gif

使い方

使い方はこんな感じで 0 ~ 1439 分までの値で開始時刻と終了時刻を渡してやるだけです。

import SwiftUI

struct ContentView: View {

    @State private var startMinutes = 10 * 60
    @State private var endMinutes = 22 * 60
    
    var body: some View {
        VStack(spacing: 16) {
            AMTimeRange24Picker(startMinutes: $startMinutes,
                                endMinutes: $endMinutes)
            .frame(width: 300, height: 300)
            
            Text("start: \(formattedTime(startMinutes))")
            Text("end  : \(formattedTime(endMinutes))")
        }
        .padding(16)
    }
    
    private func formattedTime(_ minutes: Int) -> String {
        let h = minutes / 60
        let m = minutes % 60
        return String(format: "%02d:%02d", h, m)
    }
}

実装方針

  1. 画面中心から見た「角度」で時刻を表現する
  2. 0 時の角度を基準にする
  3. 1 分 = 360 / (24 * 60) 度として角度を決める

あとはこれを元に下記を実装します。

  1. ベースのリングを描く
  2. 0 ~ 23 の時刻配置
  3. 開始時刻・終了時刻のハンドルを描く
  4. 開始時刻と終了時刻をつなぐ弧を描く
  5. タッチ位置から開始時刻・終了時刻を計算

実装全体

まずはコード全文です。

コード全文
import SwiftUI

/// 0〜1440分のレンジをぐるっと指定する 24h ピッカー
struct AMTimeRange24Picker: View {

    private enum ActiveHandle {
        case start
        case end
    }

    // 0...1439(15分単位)
    @Binding var startMinutes: Int
    // 0...1439(15分単位)
    @Binding var endMinutes: Int

    private let ringThickness: CGFloat = 20
    private let handleRadius: CGFloat = 12
    private let stepMinutes = 15
    private let minutesPerDay = 24 * 60

    @State private var activeHandle: ActiveHandle? = nil

    var body: some View {
        GeometryReader { geo in
            let length = min(geo.size.width, geo.size.height)
            let center = CGPoint(x: length / 2, y: length / 2)
            let radius = length / 2 - handleRadius

            ZStack {
                // ベースリング
                Circle()
                    .stroke(.gray.opacity(0.3), lineWidth: ringThickness)

                // 選択中レンジのアーク
                rangeArc(center: center, radius: radius)
                    .stroke(
                        .green.opacity(0.4),
                        style: StrokeStyle(lineWidth: ringThickness, lineCap: .round)
                    )

                // 開始ハンドル
                handle(center: center, radius: radius, minutes: startMinutes)
                    .fill(Color.blue)
                // 終了ハンドル
                handle(center: center, radius: radius, minutes: endMinutes)
                    .fill(Color.red)
                // 文字盤
                timeTexts(radius: radius, center: center)
            }
            .frame(width: length, height: length)
            .contentShape(Circle())
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in
                        handleDragChanged(location: value.location,
                                          center: center,
                                          radius: radius)
                    }
                    .onEnded { _ in
                        activeHandle = nil
                    }
            )
        }
    }
}

// MARK: - 描画系
extension AMTimeRange24Picker {

    // 文字盤描画
    private func timeTexts(radius: CGFloat, center: CGPoint) -> some View {
        let fontSize = radius / 10
        let start = Angle.degrees(270).radians
        let step = Angle.degrees(360 / 24).radians
        let textsRadius = radius - 24
        
        return ZStack {
            ForEach(0..<24, id: \.self) { i in
                let angle = start + step * CGFloat(i)
                let x = center.x + textsRadius * cos(angle)
                let y = center.y + textsRadius * sin(angle)
                Text("\(i)")
                    .font(.system(size: fontSize))
                    .position(x: x, y: y)
            }
        }
    }

    /// 開始or終了ハンドル
    private func handle(center: CGPoint,
                        radius: CGFloat,
                        minutes: Int) -> Path {
        var path = Path()
        let angle = angle(for: minutes)
        let point = pointOnCircle(center: center, radius: radius, angle: angle)
        path.addEllipse(in: CGRect(x: point.x - handleRadius,
                                   y: point.y - handleRadius,
                                   width: handleRadius * 2,
                                   height: handleRadius * 2))
        return path
    }

    /// 選択中レンジのアーク
    private func rangeArc(center: CGPoint, radius: CGFloat) -> Path {
        var path = Path()

        let start = startMinutes
        let end = endMinutes
        if start == end {
            // 全く同じなら何も描かない
            return path
        }

        // 0 ~ 1440に補正
        let normalizedStart = (start % minutesPerDay + minutesPerDay) % minutesPerDay
        let normalizedEnd = (end % minutesPerDay + minutesPerDay) % minutesPerDay

        func _addSegment(from m1: Int, to m2: Int) {
            let startAngle = angle(for: m1)
            let endAngle = angle(for: m2)
            path.addArc(center: center,
                        radius: radius,
                        startAngle: startAngle,
                        endAngle: endAngle,
                        clockwise: false)
        }

        if normalizedStart < normalizedEnd {
            _addSegment(from: normalizedStart, to: normalizedEnd)
        } else {
            // 日付跨ぎ: start → 24:00 と 0:00 → end に分割
            _addSegment(from: normalizedStart, to: minutesPerDay)
            _addSegment(from: 0, to: normalizedEnd)
        }

        return path
    }
}

// MARK: - 角度/座標変換
extension AMTimeRange24Picker {

    /// 0時(上)を基準として、時計回りに分を角度に変換
    private func angle(for minutes: Int) -> Angle {
        let clamped = max(0, min(minutesPerDay - 1, minutes))
        let fraction = Double(clamped) / Double(minutesPerDay)
        // 0時(上)を基準にしたいので -90° からスタート
        return .degrees(fraction * 360.0 - 90.0)
    }

    private func pointOnCircle(center: CGPoint,
                               radius: CGFloat,
                               angle: Angle) -> CGPoint {
        let rad = CGFloat(angle.radians)
        return CGPoint(
            x: center.x + radius * cos(rad),
            y: center.y + radius * sin(rad)
        )
    }

    /// 画面上のタッチ位置 → 分(0〜1440)
    private func minutes(from point: CGPoint, center: CGPoint) -> Int {
        let dx = point.x - center.x
        let dy = point.y - center.y

        let rad = atan2(dy, dx)
        var deg = Angle(radians: rad).degrees

        // 0時(上)を基準にする
        deg += 90
        if deg < 0 {
            // 0 ~ 360にする
            deg += 360
        }

        let fraction = deg / 360
        let minutes = Int(round(fraction * CGFloat(minutesPerDay)))
        return snappedMinutes(minutes)
    }

    // 15分単位に補正
    private func snappedMinutes(_ minutes: Int) -> Int {
        let step = stepMinutes
        let rounded = Int(round(Double(minutes) / Double(step))) * step
        return (rounded % minutesPerDay + minutesPerDay) % minutesPerDay
    }
}

// MARK: - ジェスチャ処理
extension AMTimeRange24Picker {

    private func handleDragChanged(location: CGPoint,
                                   center: CGPoint,
                                   radius: CGFloat) {
        if activeHandle == nil {
            // タッチ位置から近い方を設定
            let startPoint = pointOnCircle(center: center,
                                           radius: radius,
                                           angle: angle(for: startMinutes))
            let endPoint = pointOnCircle(center: center,
                                         radius: radius,
                                         angle: angle(for: endMinutes))

            let distToStart = hypot(location.x - startPoint.x,
                                    location.y - startPoint.y)
            let distToEnd = hypot(location.x - endPoint.x,
                                  location.y - endPoint.y)

            activeHandle = distToStart < distToEnd ? .start : .end
        }

        let newMinutes = minutes(from: location, center: center)
        switch activeHandle {
        case .start:
            startMinutes = newMinutes
        case .end:
            endMinutes = newMinutes
        case .none:
            break
        }
    }
}

実装ステップ(ざっくり解説)

上の全文から、要点だけ抜き出して簡単に解説します。

1. ベースのリングを描く

これは単純に lineWidth 決めて円を描くだけです。

Circle()
    .stroke(.gray.opacity(0.3), lineWidth: ringThickness)

2. 0 ~ 23 の時刻配置

これもそんな考えることはなく 270 度(0 時)の位置から 0 ~ 23 の Text を配置するだけです。

private func timeTexts(radius: CGFloat, center: CGPoint) -> some View {
    let fontSize = radius / 10
    let start = Angle.degrees(270).radians
    let step = Angle.degrees(360 / 24).radians
    let textsRadius = radius - 24
    
    return ZStack {
        ForEach(0..<24, id: \.self) { i in
            let angle = start + step * CGFloat(i)
            let x = center.x + textsRadius * cos(angle)
            let y = center.y + textsRadius * sin(angle)
            Text("\(i)")
                .font(.system(size: fontSize))
                .position(x: x, y: y)
        }
    }
}

3. 開始時刻・終了時刻のハンドルを描く

指定の分から角度を計算してそれを座標に変換して円を描きます。
angle(for:) で 0 ~ 1439 に収まるように補正して角度から -90 することで 0 時を基準にした角度にしているのがポイントです。

private func handle(center: CGPoint,
                    radius: CGFloat,
                    minutes: Int) -> Path {
    var path = Path()
    let angle = angle(for: minutes)
    let point = pointOnCircle(center: center, radius: radius, angle: angle)
    path.addEllipse(in: CGRect(x: point.x - handleRadius,
                               y: point.y - handleRadius,
                               width: handleRadius * 2,
                               height: handleRadius * 2))
    return path
}

/// 0時(上)を基準として、時計回りに分を角度に変換
private func angle(for minutes: Int) -> Angle {
    let clamped = max(0, min(minutesPerDay - 1, minutes))
    let fraction = Double(clamped) / Double(minutesPerDay)
    // 0時(上)を基準にしたいので -90° からスタート
    return .degrees(fraction * 360.0 - 90.0)
}

private func pointOnCircle(center: CGPoint,
                           radius: CGFloat,
                           angle: Angle) -> CGPoint {
    let rad = CGFloat(angle.radians)
    return CGPoint(
        x: center.x + radius * cos(rad),
        y: center.y + radius * sin(rad)
    )
}

4. 開始時刻と終了時刻をつなぐ弧を描く

あとは開始時刻と終了時刻をつなぐ弧を描けば見た目は完成です。
ポイントは日付跨ぐ場合、開始時刻 → 24:00 と 0:00 → 終了時刻に分割して描くことです。

private func rangeArc(center: CGPoint, radius: CGFloat) -> Path {
    var path = Path()

    let start = startMinutes
    let end = endMinutes
    if start == end {
        // 全く同じなら何も描かない
        return path
    }

    // 0 ~ 1440に補正
    let normalizedStart = (start % minutesPerDay + minutesPerDay) % minutesPerDay
    let normalizedEnd = (end % minutesPerDay + minutesPerDay) % minutesPerDay

    func _addSegment(from m1: Int, to m2: Int) {
        let startAngle = angle(for: m1)
        let endAngle = angle(for: m2)
        path.addArc(center: center,
                    radius: radius,
                    startAngle: startAngle,
                    endAngle: endAngle,
                    clockwise: false)
    }

    if normalizedStart < normalizedEnd {
        _addSegment(from: normalizedStart, to: normalizedEnd)
    } else {
        // 日付跨ぎ: start → 24:00 と 0:00 → end に分割
        _addSegment(from: normalizedStart, to: minutesPerDay)
        _addSegment(from: 0, to: normalizedEnd)
    }

    return path
}

5. タッチ位置から開始時刻・終了時刻を計算

あとはドラッグジェスチャをすれば完成:tada:

タッチ位置から開始時刻と終了時刻どちらのハンドルが近いか比較してどちらの編集か判定します。
その後はタッチ位置から時刻を計算して 15 分単位に補正するだけです。

private func handleDragChanged(location: CGPoint,
                               center: CGPoint,
                               radius: CGFloat) {
    if activeHandle == nil {
        // タッチ位置から近い方を設定
        let startPoint = pointOnCircle(center: center,
                                       radius: radius,
                                       angle: angle(for: startMinutes))
        let endPoint = pointOnCircle(center: center,
                                     radius: radius,
                                     angle: angle(for: endMinutes))

        let distToStart = hypot(location.x - startPoint.x,
                                location.y - startPoint.y)
        let distToEnd = hypot(location.x - endPoint.x,
                              location.y - endPoint.y)

        activeHandle = distToStart < distToEnd ? .start : .end
    }

    let newMinutes = minutes(from: location, center: center)
    switch activeHandle {
    case .start:
        startMinutes = newMinutes
    case .end:
        endMinutes = newMinutes
    case .none:
        break
    }
}

/// 画面上のタッチ位置 → 分(0〜1440)
private func minutes(from point: CGPoint, center: CGPoint) -> Int {
    let dx = point.x - center.x
    let dy = point.y - center.y

    let rad = atan2(dy, dx)
    var deg = Angle(radians: rad).degrees

    // 0時(上)を基準にする
    deg += 90
    if deg < 0 {
        // 0 ~ 360にする
        deg += 360
    }

    let fraction = deg / 360
    let minutes = Int(round(fraction * CGFloat(minutesPerDay)))
    return snappedMinutes(minutes)
}

// 15分単位に補正
private func snappedMinutes(_ minutes: Int) -> Int {
    let step = stepMinutes
    let rounded = Int(round(Double(minutes) / Double(step))) * step
    return (rounded % minutesPerDay + minutesPerDay) % minutesPerDay
}

おわりに

座標と角度さえわかればこんなのもサクッと作れちゃいます:tada:

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?