0
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を作ろうー黒電話ダイヤル編ー

0
Posted at

はじめに

SwiftUI の座標と角度を完全に理解した私は前回、 24 時間ゲージを作りました。

今回は、黒電話ダイヤルによる数値入力 UI を SwiftUI で自作してみます。
こんな感じです。ドラッグで 0 ~ 9 の数値を入力できます。

dial.gif

使い方

使い方はこんな感じで入力値を受け取る value: String を渡してやるだけです。

import SwiftUI

struct ContentView: View {

    @State private var value = ""
    
    var body: some View {
        VStack(spacing: 16) {
            AMRotaryDialInput(value: $value)
                .frame(width: 320, height: 320)
            
            Text("入力値:\(value)")
        }
        .padding(16)
    }
}

実装方針

  1. 画面中心から見た「角度」で 0 ~ 9 を表現する
  2. 3 が 270 度、0 が 90 度の位置に設定する
  3. 数値間の角度は 180.0 / 7.0 として角度を決める

angle.jpg

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

  1. 数値のリングを描く
  2. ダイヤルを描く
  3. ピンを描く
  4. タッチ位置から入力値を計算

実装全体

まずはコード全文です。

コード全文
import SwiftUI

struct AMRotaryDialInput: View {

    @Binding var value: String

    @State private var isDragging = false
    @State private var selectedDigit: Int? = nil
    @State private var touchedPin = false
    @State private var dialAngle: Angle = .degrees(0)
    @State private var maxDialDegrees: Double? = nil
    @State private var digitsRect = [CGRect]()

    private static let step = 180.0 / 7.0
    private static let start = 90.0

    private let pinAngle: Angle = .degrees(45)
    private let digits = [0, 9, 8, 7, 6, 5, 4, 3, 2, 1]
    
    private let digitRingThickness: CGFloat = 24
    private let digitDotSize: CGFloat = 6

    var body: some View {
        GeometryReader { geo in
            let size = min(geo.size.width, geo.size.height)
            let center = CGPoint(x: size / 2, y: size / 2)
            let radius = size / 2 - 30
            let dialRadius = radius - digitRingThickness / 2 - 2
            let centerHoleRadius = dialRadius / 2
            let digitDotRadius = centerHoleRadius + (dialRadius - centerHoleRadius) / 2
            let digitHoleRadius = (dialRadius - centerHoleRadius) / 2 - 12
            let pinSize = dialRadius - centerHoleRadius - 16

            ZStack {
                Circle()
                    .fill(Color(.systemGray6))
                    .overlay(
                        Circle()
                            .stroke(Color.primary, lineWidth: 4)
                    )
                
                Circle()
                    .fill(Color(.systemGray))
                    .frame(width: centerHoleRadius * 2, height: centerHoleRadius * 2)
                    
                Circle()
                    .stroke(Color.primary.opacity(0.3), lineWidth: digitRingThickness)
                    .frame(width: radius * 2, height: radius * 2)
            
                digitLabels(center: center, radius: radius)

                holeDots(center: center, radius: digitDotRadius)
                dial(
                    center: center, radius: dialRadius,
                    centerHoleRadius: centerHoleRadius,
                    digitDotRadius: digitDotRadius,
                    digitHoleRadius: digitHoleRadius
                )
                    .fill(Color(.systemGray4), style: FillStyle(eoFill: true))
                    .shadow(color: .black.opacity(0.06), radius: 1, y: 1)
                    .rotationEffect(dialAngle)
                
                dialPin(
                    center: center,
                    radius: dialRadius - digitRingThickness / 2,
                    pinSize: pinSize
                )
            }
            .frame(width: size, height: size)
            .contentShape(Circle())
            .onAppear {
                digitsRect.removeAll()
                (0..<digits.count).forEach { i in
                    let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
                    let x = center.x + digitDotRadius * cos(rad)
                    let y = center.y + digitDotRadius * sin(rad)
                    let rect = CGRect(
                        x: x - digitHoleRadius,
                        y: y - digitHoleRadius,
                        width: digitHoleRadius * 2,
                        height: digitHoleRadius * 2
                    )
                    digitsRect.append(rect)
                }
            }
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in
                        if isDragging {
                            if touchedPin, selectedDigit == nil {
                                return
                            }
                            let angle = calculateAngle(angleFrom(center: center, location: value.location))
                            var deg = angle.degrees
                            if let max = maxDialDegrees {
                                if deg >= max {
                                    deg = max
                                    touchedPin = true
                                }
                            }
                            dialAngle = .degrees(deg)
                        } else {
                            if let index = digitsRect.firstIndex(where: { $0.contains(value.location) }) {
                                selectedDigit = digits[index]
                                let digitDeg = Self.start + Self.step * Double(index)
                                let pinDeg = pinAngle.degrees
                                var diff = pinDeg - digitDeg
                                if diff < 0 {
                                    diff += 360
                                }
                                maxDialDegrees = diff
                            }
                            isDragging = true
                        }
                    }
                    .onEnded { _ in
                        if touchedPin, let digit = selectedDigit {
                            value.append(String(digit))
                        }
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            dialAngle = .degrees(0)
                        }
                        isDragging = false
                        selectedDigit = nil
                        maxDialDegrees = nil
                        touchedPin = false
                    }
            )
        }
    }

    private func angleFrom(center: CGPoint, location: CGPoint) -> Angle {
        let dx = location.x - center.x
        let dy = location.y - center.y
        let rad = atan2(dy, dx)

        var deg = Angle(radians: rad).degrees
        if deg < 0 {
            deg += 360
        }
        return .degrees(deg)
    }

    private func calculateAngle(_ angle: Angle) -> Angle {
        if let digit = selectedDigit,
           let index = digits.firstIndex(of: digit) {
            let digitDeg = Self.start + Self.step * Double(index)
            var deg = angle.degrees - digitDeg
            if deg < 0 {
                deg += 360
            }
            return .degrees(deg)
        }
        return angle
    }
}

extension AMRotaryDialInput {
    
    // 数字ラベル
    private func digitLabels(center: CGPoint, radius: CGFloat) -> some View {
        return ZStack {
            ForEach(Array(digits.enumerated()), id: \.offset) { (i, d) in
                let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
                let x = center.x + radius * cos(rad)
                let y = center.y + radius * sin(rad)

                Text("\(d)")
                    .font(.system(size: 18, weight: .medium))
                    .position(x: x, y: y)
            }
        }
    }

    private func dial(center: CGPoint,
                      radius: CGFloat,
                      centerHoleRadius: CGFloat,
                      digitDotRadius: CGFloat,
                      digitHoleRadius: CGFloat) -> Path {
        var path = Path()

        let rect = CGRect(x: center.x - radius,
                          y: center.y - radius,
                          width: radius * 2,
                          height: radius * 2)
        path.addEllipse(in: rect)

        path.addEllipse(in: CGRect(
            x: rect.midX - centerHoleRadius,
            y: rect.midY - centerHoleRadius,
            width: centerHoleRadius * 2,
            height: centerHoleRadius * 2
        ))

        (0..<digits.count).forEach { i in
            let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
            let x = center.x + digitDotRadius * cos(rad)
            let y = center.y + digitDotRadius * sin(rad)
            path.addEllipse(in: CGRect(
                x: x - digitHoleRadius,
                y: y - digitHoleRadius,
                width: digitHoleRadius * 2,
                height: digitHoleRadius * 2
            ))
        }
        return path
    }

    private func holeDots(center: CGPoint, radius: CGFloat) -> some View {
        return ZStack {
            ForEach((0..<digits.count), id: \.self) { i in
                let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
                let x = center.x + radius * cos(rad)
                let y = center.y + radius * sin(rad)
                
                Circle()
                    .fill(Color.primary)
                    .frame(width: digitDotSize, height: digitDotSize)
                    .position(x: x, y: y)
            }
        }
    }

    private func dialPin(center: CGPoint, radius: CGFloat, pinSize: CGFloat) -> some View {
        let rad = CGFloat(Angle(degrees: pinAngle.degrees).radians)
        let x = center.x + radius * cos(rad)
        let y = center.y + radius * sin(rad)
        
        return Capsule()
            .fill(Color.black)
            .frame(width: 6, height: pinSize)
            .rotationEffect(.degrees(-45))
            .position(x: x, y: y)
    }
}

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

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

1. 数値のリングを描く

0 が 90 の位置なので 0, 9, 8, 7, 6, 5, 4, 3, 2, 1 の順で並べていきます。

private static let step = 180.0 / 7.0
private static let start = 90.0
private let digits = [0, 9, 8, 7, 6, 5, 4, 3, 2, 1]

Circle()
    .stroke(Color.primary.opacity(0.3), lineWidth: digitRingThickness)
    .frame(width: radius * 2, height: radius * 2)

digitLabels(center: center, radius: radius)

private func digitLabels(center: CGPoint, radius: CGFloat) -> some View {
    return ZStack {
        ForEach(Array(digits.enumerated()), id: \.offset) { (i, d) in
            let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
            let x = center.x + radius * cos(rad)
            let y = center.y + radius * sin(rad)

            Text("\(d)")
                .font(.system(size: 18, weight: .medium))
                .position(x: x, y: y)
        }
    }
}

2. ダイヤルを描く

この UI の肝の回すダイヤルを描きます。ダイヤルの穴のドットは回らないようにしたいので分けて描きます。

数値と同じ順序で 0, 9, 8, 7, 6, 5, 4, 3, 2, 1 の順に描いていきます。ポイントは FillStyle(eoFill: true) これでくり抜いてるところです。

これは「偶奇ルール」といって、

  • 外側の大きな円 = 塗る
  • 中心の穴や数字の穴 = 重なりが偶数回になるので塗らない(くり抜かれる)

という塗り方になります。これを使うことで、1つの Path だけで「穴だらけのダイヤル」を描けます。

holeDots(center: center, radius: digitDotRadius)
dial(
    center: center, radius: dialRadius,
    centerHoleRadius: centerHoleRadius,
    digitDotRadius: digitDotRadius,
    digitHoleRadius: digitHoleRadius
)
    .fill(Color(.systemGray4), style: FillStyle(eoFill: true))
    .shadow(color: .black.opacity(0.06), radius: 1, y: 1)
    .rotationEffect(dialAngle)

private func dial(center: CGPoint,
                  radius: CGFloat,
                  centerHoleRadius: CGFloat,
                  digitDotRadius: CGFloat,
                  digitHoleRadius: CGFloat) -> Path {
    var path = Path()

    let rect = CGRect(x: center.x - radius,
                      y: center.y - radius,
                      width: radius * 2,
                      height: radius * 2)
    path.addEllipse(in: rect)

    path.addEllipse(in: CGRect(
        x: rect.midX - centerHoleRadius,
        y: rect.midY - centerHoleRadius,
        width: centerHoleRadius * 2,
        height: centerHoleRadius * 2
    ))

    (0..<digits.count).forEach { i in
        let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
        let x = center.x + digitDotRadius * cos(rad)
        let y = center.y + digitDotRadius * sin(rad)
        path.addEllipse(in: CGRect(
            x: x - digitHoleRadius,
            y: y - digitHoleRadius,
            width: digitHoleRadius * 2,
            height: digitHoleRadius * 2
        ))
    }
    return path
}

private func holeDots(center: CGPoint, radius: CGFloat) -> some View {
    return ZStack {
        ForEach((0..<digits.count), id: \.self) { i in
            let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
            let x = center.x + radius * cos(rad)
            let y = center.y + radius * sin(rad)
            
            Circle()
                .fill(Color.primary)
                .frame(width: digitDotSize, height: digitDotSize)
                .position(x: x, y: y)
        }
    }
}

3. ピンを描く

これは好きな位置にピンを描くだけです。今回は 45 度の位置にしました。

private let pinAngle: Angle = .degrees(45)

private func dialPin(center: CGPoint, radius: CGFloat, pinSize: CGFloat) -> some View {
        let rad = CGFloat(Angle(degrees: pinAngle.degrees).radians)
        let x = center.x + radius * cos(rad)
        let y = center.y + radius * sin(rad)
        
        return Capsule()
            .fill(Color.black)
            .frame(width: 6, height: pinSize)
            .rotationEffect(.degrees(-45))
            .position(x: x, y: y)
    }

4. タッチ位置から入力値を計算

ここが一番複雑なところです。まずダイヤルの穴の CGRect を保持します。

@State private var digitsRect = [CGRect]()

.onAppear {
    digitsRect.removeAll()
    (0..<digits.count).forEach { i in
        let rad = CGFloat(Angle(degrees: Self.start + Self.step * Double(i)).radians)
        let x = center.x + digitDotRadius * cos(rad)
        let y = center.y + digitDotRadius * sin(rad)
        let rect = CGRect(
            x: x - digitHoleRadius,
            y: y - digitHoleRadius,
            width: digitHoleRadius * 2,
            height: digitHoleRadius * 2
        )
        digitsRect.append(rect)
    }
}

ドラッグ中かどうかを判定するために isDragging を使います。false の場合はドラッグ開始と判定し digitsRect と座標位置から入力値とダイヤルを回せる最大角度を取得して selectedDigitmaxDialDegrees に設定します。

ドラッグ中は最大角度に達しているかどうかを見つつ dialAngle を設定しダイヤルを回します。最大角度に達していれば touchedPin を true にしておきます。

@State private var isDragging = false
@State private var selectedDigit: Int? = nil
@State private var touchedPin = false
@State private var maxDialDegrees: Double? = nil

.onChanged { value in
    if isDragging {
        if touchedPin, selectedDigit == nil {
            return
        }
        let angle = calculateAngle(angleFrom(center: center, location: value.location))
        var deg = angle.degrees
        if let max = maxDialDegrees {
            if deg >= max {
                deg = max
                touchedPin = true
            }
        }
        dialAngle = .degrees(deg)
    } else {
        if let index = digitsRect.firstIndex(where: { $0.contains(value.location) }) {
            selectedDigit = digits[index]
            let digitDeg = Self.start + Self.step * Double(index)
            let pinDeg = pinAngle.degrees
            var diff = pinDeg - digitDeg
            if diff < 0 {
                diff += 360
            }
            maxDialDegrees = diff
        }
        isDragging = true
    }
}

ドラッグ終了時にピンまで到達していれば(touchedPin が true)入力値を設定しアニメーションでダイヤルの角度を 0 に戻してやれば完成です:tada:

.onEnded { _ in
    if touchedPin, let digit = selectedDigit {
        value.append(String(digit))
    }
    withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
        dialAngle = .degrees(0)
    }
    isDragging = false
    selectedDigit = nil
    maxDialDegrees = nil
    touchedPin = false
}

おわりに

音を鳴らすようにすればもっとそれっぽい感じにもできるはずです!
リストを渡すようにすれば数値だけでなく色々な値も入力できるようになりまだまだ発展させる余地はありそうです:muscle:

0
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
0
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?