-
Notifications
You must be signed in to change notification settings - Fork 0
Circular Time picker
Ravi Sawlani edited this page Jan 18, 2026
·
1 revision
//
// CircularBedTimePicker.swift
// ZzzMate
//
// Created by Ravi on 20/10/25.
//
import SwiftUI
extension Date {
var hourDecimal: Double {
let components = Calendar.current.dateComponents([.hour, .minute, .second], from: self)
let hour = Double(components.hour ?? 0)
let minute = Double(components.minute ?? 0)
let second = Double(components.second ?? 0)
return hour + (minute / 60.0) + (second / 3600.0)
}
}
struct CircularSleepPicker: View {
var lengthOfArcInSecs: TimeInterval
@Binding var wakeUpTime: Date
var bedTimeHour: Double {
let wakeUpHour = wakeUpTime.hourDecimal
var bedTimeHour = wakeUpHour - (lengthOfArcInSecs / 3600)
if bedTimeHour < 0 {
bedTimeHour += 24.0
}
return bedTimeHour
}
var wakeUpHour: Double {
get {
wakeUpTime.hourDecimal
}
set {
let hours = Int(newValue)
let minutes = Int((newValue - Double(hours)) * 60)
let seconds = Int((((newValue - Double(hours)) * 60) - Double(minutes)) * 60)
let calendar = Calendar.current
let now = Date()
if let newDate = calendar.date(
bySettingHour: hours,
minute: minutes,
second: seconds,
of: now
) {
wakeUpTime = newDate
}
}
}
@State private var dragStartAngle: Double? = nil
@State private var dragStartHour: Double? = nil
func setWakeUpTime(hour: Double) {
let hours = Int(hour)
let minutes = Int((hour - Double(hours)) * 60)
let seconds = Int((((hour - Double(hours)) * 60) - Double(minutes)) * 60)
let calendar = Calendar.current
if hours < 0 || minutes < 0 || seconds < 0 {
print("hour is \(hour), minutes is \(minutes), seconds is \(seconds)");
}
let now = Date()
wakeUpTime = calendar.date(bySettingHour: hours, minute: minutes, second: seconds, of: now)!
}
var body: some View {
GeometryReader { geo in
// Sizing
let diameter = min(geo.size.width, geo.size.height)
let controllerRingStrokeBorder: CGFloat = diameter * 0.12
let arcStrokeWidth: CGFloat = controllerRingStrokeBorder * 0.7
let ringRadius: CGFloat = diameter / 2 - controllerRingStrokeBorder / 2
let arcRadius: CGFloat = ringRadius
let handleRadius: CGFloat = controllerRingStrokeBorder / 2 - 5
let tickLengthMajor: CGFloat = diameter * 0.08
let tickLengthMinor: CGFloat = diameter * 0.035
let tickBaseRadius: CGFloat = ringRadius - controllerRingStrokeBorder/2
let labelRadius: CGFloat = 0.75 * tickBaseRadius
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
// Fixed arc length in hours
let arcLength: Double = {
let length = lengthOfArcInSecs / 60 / 60
return length
}()
// Drag gesture for whole arc
let arcGesture = DragGesture()
.onChanged { value in
let newAngle = angleFrom(center: center, point: value.location)
print("new angle \(newAngle)")
// while dragging the first time the value is set here
if dragStartAngle == nil || dragStartHour == nil {
dragStartAngle = newAngle
dragStartHour = bedTimeHour
return
}
let angleDelta = newAngle - (dragStartAngle ?? 0)
// Convert angle delta to hours
let hourDelta = angleDelta / 360 * 24
let newStart = (((dragStartHour ?? 0) + hourDelta).truncatingRemainder(dividingBy: 24))
let setHour = (newStart + arcLength).truncatingRemainder(dividingBy: 24)
print("hour delta \(hourDelta) new start \(newStart), new hour \(setHour) DragStartANgle \(dragStartAngle ?? 0)")
setWakeUpTime(hour: setHour)
}
.onEnded { _ in
dragStartAngle = nil
dragStartHour = nil
}
ZStack {
// Clock ring
Circle()
.fill(.white)
.strokeBorder(Color(.systemGray4), lineWidth: controllerRingStrokeBorder)
// Sleep range arc (fully inside via .strokeBorder)
SleepArc(
startHour: bedTimeHour,
endHour: wakeUpHour,
radius: arcRadius
)
.stroke(Color.accentColor, lineWidth: arcStrokeWidth)
.contentShape(Circle().inset(by: controllerRingStrokeBorder / 2))
.gesture(arcGesture)
// Animate on length changes if parent provides animations
.animation(.easeInOut(duration: 0.35), value: wakeUpHour)
// Tick marks
ForEach(0..<24) { hour in
let angle = Angle(degrees: Double(hour) * 360 / 24 - 90)
let tickLength = hour % 6 == 0 ? tickLengthMajor : tickLengthMinor
Capsule()
.fill(Color.secondary.opacity(hour % 6 == 0 ? 0.7 : 0.3))
.frame(width: tickLength, height: 2)
.offset(y: -tickBaseRadius + tickLengthMinor / 2)
.rotationEffect(angle)
}
// Hour Labels (now INSIDE the gray circle)
ForEach([0, 3, 6, 9, 12, 15, 18, 21], id: \.self) { hour in
let angle = Angle(degrees: Double(hour) * 360 / 24 - 90)
let label: String = {
if hour == 0 {
return "12AM"
} else if hour == 12 {
return "12PM"
} else if hour < 12 {
return "\(hour)AM"
} else {
return "\(hour - 12)PM"
}
}()
Text(label)
.font(.system(size: diameter * 0.052, weight: hour % 6 == 0 ? .bold : .semibold))
.foregroundColor(hour % 6 == 0 ? .text : .secondary)
.position(
x: center.x + cos(angle.radians) * labelRadius,
y: center.y + sin(angle.radians) * labelRadius
)
}
let startAngle = (bedTimeHour / 24) * 360 - 90
let startHandlePos = polarPosition(angle: startAngle, radius: arcRadius, center: center)
HandleView(size: handleRadius * 2)
.position(startHandlePos)
.animation(.easeInOut(duration: 0.35), value: startHandlePos)
let endAngle = (wakeUpHour / 24) * 360 - 90
let endHandlePos = polarPosition(angle: endAngle, radius: arcRadius, center: center)
HandleView(icon: "alarm", size: handleRadius * 2)
.position(endHandlePos)
.animation(.easeInOut(duration: 0.35), value: endHandlePos)
}
.frame(width: geo.size.width, height: geo.size.height)
}
.aspectRatio(1, contentMode: .fit)
.frame(minWidth: 240, minHeight: 240)
.padding()
}
// Helper: Polar coordinates for handle/label
func polarPosition(angle: Double, radius: CGFloat, center: CGPoint) -> CGPoint {
let radians = Angle(degrees: angle).radians
return CGPoint(
x: center.x + cos(radians) * radius,
y: center.y + sin(radians) * radius
)
}
func angleFrom(center: CGPoint, point: CGPoint) -> Double {
let dx = point.x - center.x
let dy = point.y - center.y
var angle = atan2(dy, dx) * 180 / .pi // degrees
angle = angle < -90 ? angle + 450 : angle + 90
return angle.truncatingRemainder(dividingBy: 360)
}
func normalizedHour(from angle: Double) -> Double {
let hour = (angle / 360 * 24)
let normalized = hour >= 0 ? hour : hour + 24
return normalized.truncatingRemainder(dividingBy: 24)
}
}
struct HandleView: View {
var icon: String? = nil
var size: CGFloat = 36
var body: some View {
ZStack {
Circle()
.fill(Color.white)
.shadow(radius: size * 0.11)
.frame(width: size, height: size)
Circle()
.stroke(Color.accentColor, lineWidth: size * 0.10)
.frame(width: size, height: size)
if let icon = icon {
Image(systemName: icon).foregroundColor(.accentColor)
} else {
Image(systemName: "moon.fill").foregroundColor(.accentColor)
}
}
}
}
// Arc segment for sleep range
struct SleepArc: Shape {
var startHour: Double
var endHour: Double
var radius: CGFloat
// Make the shape animatable by exposing the changing values
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(startHour, endHour) }
set {
startHour = newValue.first
endHour = newValue.second
}
}
func path(in rect: CGRect) -> Path {
var p = Path()
let startAngle = Angle(degrees: (startHour / 24) * 360 - 90)
let endAngle = Angle(degrees: (endHour / 24) * 360 - 90)
p.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
return p
}
}
#Preview {
struct PreviewWrapper: View {
@State private var wakeUpTime: Date = Date() // Current time, current time zone
private var arcLength: TimeInterval = 8 * 60 * 60 // 12 hours
var bedTime: Date {
wakeUpTime - arcLength
}
var body: some View {
let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a" // e.g. "6:45 AM"
return formatter
}()
VStack {
CircularSleepPicker(lengthOfArcInSecs: arcLength, wakeUpTime: $wakeUpTime)
Text("BedTime: \(dateFormatter.string(from: bedTime))")
.font(.headline)
Text("Wake: \(dateFormatter.string(from: wakeUpTime))")
.font(.headline)
}
.padding()
}
}
return PreviewWrapper()
}