はじめに
SwiftUI の座標と角度を完全に理解した私は前回、 24 時間ゲージを作りました。
今回は、黒電話ダイヤルによる数値入力 UI を SwiftUI で自作してみます。
こんな感じです。ドラッグで 0 ~ 9 の数値を入力できます。
使い方
使い方はこんな感じで入力値を受け取る 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)
}
}
実装方針
- 画面中心から見た「角度」で 0 ~ 9 を表現する
- 3 が 270 度、0 が 90 度の位置に設定する
- 数値間の角度は 180.0 / 7.0 として角度を決める
あとはこれを元に下記を実装します。
- 数値のリングを描く
- ダイヤルを描く
- ピンを描く
- タッチ位置から入力値を計算
実装全体
まずはコード全文です。
コード全文
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 と座標位置から入力値とダイヤルを回せる最大角度を取得して selectedDigit と maxDialDegrees に設定します。
ドラッグ中は最大角度に達しているかどうかを見つつ 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 に戻してやれば完成です![]()
.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
}
おわりに
音を鳴らすようにすればもっとそれっぽい感じにもできるはずです!
リストを渡すようにすれば数値だけでなく色々な値も入力できるようになりまだまだ発展させる余地はありそうです![]()

