はじめに
前回 SwiftUI の座標と角度について完全に理解しました!これを使えば色々なものが作れます。
ということで今回は、開始時刻と終了時刻をドラッグで直感的に設定できる「24時間ゲージ」を SwiftUI で自作してみます。
こんな感じです。ドラッグで 15 分単位の開始時刻と終了時刻が設定できます。
使い方
使い方はこんな感じで 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)
}
}
実装方針
- 画面中心から見た「角度」で時刻を表現する
- 0 時の角度を基準にする
- 1 分 = 360 / (24 * 60) 度として角度を決める
あとはこれを元に下記を実装します。
- ベースのリングを描く
- 0 ~ 23 の時刻配置
- 開始時刻・終了時刻のハンドルを描く
- 開始時刻と終了時刻をつなぐ弧を描く
- タッチ位置から開始時刻・終了時刻を計算
実装全体
まずはコード全文です。
コード全文
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. タッチ位置から開始時刻・終了時刻を計算
あとはドラッグジェスチャをすれば完成![]()
タッチ位置から開始時刻と終了時刻どちらのハンドルが近いか比較してどちらの編集か判定します。
その後はタッチ位置から時刻を計算して 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
}
おわりに
座標と角度さえわかればこんなのもサクッと作れちゃいます![]()
