Last active
November 4, 2024 16:49
-
-
Save waynedahlberg/bc231bf0ff6cf09b19e4252a2107d1dc to your computer and use it in GitHub Desktop.
Analog Clock with sweeping second hand
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // ContentView.swift | |
| // Analog Clock with sweeping second hand | |
| // | |
| // Created by Wayne Dahlberg on 11/3/24. | |
| // | |
| import SwiftUI | |
| struct ContentView: View { | |
| @State private var currentTime = Date() | |
| let timer = Timer.publish(every: 0.125, on: .main, in: .common).autoconnect() | |
| var body: some View { | |
| ZStack { | |
| Color(.init(white: 0.925, alpha: 1.0)) | |
| .edgesIgnoringSafeArea(.all) | |
| ClockFace() | |
| .onReceive(timer) { input in | |
| currentTime = input | |
| } | |
| .environmentObject(TimeState(date: currentTime)) | |
| } | |
| } | |
| } | |
| class TimeState: ObservableObject { | |
| @Published var date: Date | |
| init(date: Date) { | |
| self.date = date | |
| } | |
| } | |
| struct ClockFace: View { | |
| @EnvironmentObject var timeState: TimeState | |
| var body: some View { | |
| GeometryReader { geometry in | |
| let diameter = min(geometry.size.width, geometry.size.height) | |
| let radius = diameter / 2 | |
| let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2) | |
| ZStack { | |
| // Clock face background | |
| Circle() | |
| .fill(.white) | |
| .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 8) | |
| .padding(16) | |
| // Minute markers | |
| ForEach(0..<60) { minute in | |
| let angle = Double(minute) * .pi * 2 / 60 | |
| let markerStart = CGPoint( | |
| x: center.x + CGFloat(sin(angle)) * (radius * 0.85), | |
| y: center.y - CGFloat(cos(angle)) * (radius * 0.85) | |
| ) | |
| let markerEnd = CGPoint( | |
| x: center.x + CGFloat(sin(angle)) * (radius * 0.9), | |
| y: center.y - CGFloat(cos(angle)) * (radius * 0.9) | |
| ) | |
| Path { path in | |
| path.move(to: markerStart) | |
| path.addLine(to: markerEnd) | |
| } | |
| .stroke(Color.black, style: StrokeStyle(lineWidth: 1, lineCap: .round)) | |
| } | |
| // Hour markers | |
| ForEach(0..<12) { hour in | |
| let angle = Double(hour) * .pi * 2 / 12 | |
| let markerStart = CGPoint( | |
| x: center.x + CGFloat(sin(angle)) * (radius * 0.8), | |
| y: center.y - CGFloat(cos(angle)) * (radius * 0.8) | |
| ) | |
| let markerEnd = CGPoint( | |
| x: center.x + CGFloat(sin(angle)) * (radius * 0.9), | |
| y: center.y - CGFloat(cos(angle)) * (radius * 0.9) | |
| ) | |
| Path { path in | |
| path.move(to: markerStart) | |
| path.addLine(to: markerEnd) | |
| } | |
| .stroke(Color.black, lineWidth: 2) | |
| } | |
| // Clock hands | |
| ClockHands(center: center, radius: radius) | |
| } | |
| .frame(width: diameter, height: diameter) | |
| .position(center) | |
| } | |
| .aspectRatio(1, contentMode: .fit) | |
| } | |
| } | |
| struct ClockHands: View { | |
| @EnvironmentObject var timeState: TimeState | |
| let center: CGPoint | |
| let radius: CGFloat | |
| var calendar: Calendar { | |
| var calendar = Calendar.current | |
| calendar.locale = Locale(identifier: "en_US_POSIX") | |
| return calendar | |
| } | |
| var hourAngle: Double { | |
| let hour = Double(calendar.component(.hour, from: timeState.date)) | |
| let minute = Double(calendar.component(.minute, from: timeState.date)) | |
| return (hour + minute / 60) * .pi * 2 / 12 | |
| } | |
| var minuteAngle: Double { | |
| let minute = Double(calendar.component(.minute, from: timeState.date)) | |
| let second = Double(calendar.component(.second, from: timeState.date)) | |
| let millisecond = Double(calendar.component(.nanosecond, from: timeState.date)) / 1_000_000_000 | |
| return (minute + (second + millisecond) / 60) * .pi * 2 / 60 | |
| } | |
| var secondAngle: Double { | |
| let second = Double(calendar.component(.second, from: timeState.date)) | |
| let millisecond = Double(calendar.component(.nanosecond, from: timeState.date)) / 1_000_000_000 | |
| return (second + millisecond) * .pi * 2 / 60 | |
| } | |
| var body: some View { | |
| ZStack { | |
| // Hour hand | |
| Path { path in | |
| path.move(to: center) | |
| path.addLine(to: CGPoint( | |
| x: center.x + CGFloat(sin(hourAngle)) * (radius * 0.5), | |
| y: center.y - CGFloat(cos(hourAngle)) * (radius * 0.5) | |
| )) | |
| } | |
| .stroke(style: StrokeStyle(lineWidth: radius * 0.04, lineCap: .round)) | |
| .shadow(color: .black.opacity(0.15), radius: 1, x: 0, y: 3) | |
| // Minute hand | |
| Path { path in | |
| path.move(to: center) | |
| path.addLine(to: CGPoint( | |
| x: center.x + CGFloat(sin(minuteAngle)) * (radius * 0.7), | |
| y: center.y - CGFloat(cos(minuteAngle)) * (radius * 0.7) | |
| )) | |
| } | |
| .stroke(style: StrokeStyle(lineWidth: radius * 0.025, lineCap: .round)) | |
| .shadow(color: .black.opacity(0.15), radius: 1, x: 0, y: 3) | |
| // Second hand | |
| Path { path in | |
| path.move(to: CGPoint( | |
| x: center.x - CGFloat(sin(secondAngle)) * (radius * 0.2), | |
| y: center.y + CGFloat(cos(secondAngle)) * (radius * 0.2) | |
| )) | |
| path.addLine(to: CGPoint( | |
| x: center.x + CGFloat(sin(secondAngle)) * (radius * 0.8), | |
| y: center.y - CGFloat(cos(secondAngle)) * (radius * 0.8) | |
| )) | |
| } | |
| .stroke(Color.red, style: StrokeStyle(lineWidth: radius * 0.01, lineCap: .round)) | |
| .shadow(color: .black.opacity(0.15), radius: 1, x: 0, y: 3) | |
| // Center pin | |
| Circle() | |
| .fill(Color.red.gradient) | |
| .frame(width: radius * 0.05, height: radius * 0.05) | |
| .shadow(color: .black.opacity(0.15), radius: 1, x: 0, y: 3) | |
| .position(center) | |
| } | |
| } | |
| } | |
| #Preview { | |
| ContentView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment