├── .gitignore ├── docs └── assets │ ├── ClockViewClassic.png │ ├── ClockViewDrawing.png │ ├── ClockViewSteampunk.png │ ├── ClockViewArtNouveau.png │ └── ClockViewClassicAndColors.png ├── Sources └── SwiftClockUI │ ├── Extensions │ ├── Double+Time.swift │ ├── GeometryProxy+Diameter.swift │ ├── Color+Background.swift │ ├── Angle+Circle.swift │ ├── CGRect+Circle.swift │ ├── Path+Circle.swift │ ├── Path+VerticalMirror.swift │ ├── Date+Clock.swift │ ├── CGPoint+Circle.swift │ └── NSBezierPath+CGPath.swift │ ├── Face │ ├── ClockFaceEnvironment.swift │ ├── Mouth.swift │ ├── ClockFaceView.swift │ └── Eye.swift │ ├── Elements │ ├── Arm │ │ ├── SteampunkArm.swift │ │ ├── ArmType.swift │ │ ├── ClassicArm.swift │ │ ├── ArtNouveauArm.swift │ │ ├── ArmView.swift │ │ ├── ArmDragGesture.swift │ │ └── DrawnArm.swift │ ├── Borders │ │ ├── ClassicClockBorder.swift │ │ ├── ClockBorderView.swift │ │ ├── ArtNouveauClockBorder.swift │ │ ├── SteampunkClockBorder.swift │ │ └── DrawnClockBorder.swift │ ├── Indicators │ │ ├── IndicatorsView.swift │ │ ├── RomanNumber.swift │ │ ├── ArtNouveauIndicators.swift │ │ ├── ClassicIndicators.swift │ │ ├── SteampunkIndicators.swift │ │ └── DrawnIndicators.swift │ ├── Steampunk │ │ ├── Moon.swift │ │ ├── Plate.swift │ │ ├── SteampunkHourArm.swift │ │ ├── WindUpKey.swift │ │ ├── SteampunkMinuteArm.swift │ │ └── Cogwheel.swift │ └── Arms.swift │ ├── Environment │ ├── ClockDateEnvironment.swift │ ├── ClockBorderColorEnvironment.swift │ ├── ClockIndicatorsColorEnvironment.swift │ ├── ClockAnimationEnabled.swift │ ├── ClockIsAnimationEnabledEnvironment.swift │ ├── ClockArmColorsEnvironment.swift │ ├── ClockStyleEnvironment.swift │ ├── ClockConfigurationEnvironment.swift │ └── ClockRandomEnvironment.swift │ ├── ViewModifiers │ ├── PositionInCircle.swift │ └── FontProportional.swift │ └── ClockView.swift ├── Tests ├── LinuxMain.swift └── SwiftClockUITests │ ├── Face │ ├── __Snapshots__ │ │ ├── EyeTests │ │ │ └── testEyes.1.png │ │ ├── MouthTests │ │ │ └── testMouths.1.png │ │ └── ClockFaceTests │ │ │ └── testClockFaceSmiling.1.png │ ├── EyeTests.swift │ ├── MouthTests.swift │ └── ClockFaceTests.swift │ ├── Elements │ ├── __Snapshots__ │ │ └── ArmsTests │ │ │ └── testArms.1.png │ ├── Arm │ │ ├── __Snapshots__ │ │ │ ├── ArmTests │ │ │ │ ├── testHourArm.1.png │ │ │ │ ├── testDrawningArm.1.png │ │ │ │ ├── testMinuteArms.1.png │ │ │ │ ├── testArtNouveauArm.1.png │ │ │ │ └── testArmWith25MinuteAngle.1.png │ │ │ ├── ClassicArmTests │ │ │ │ └── testClassicArm.1.png │ │ │ └── ArtNouveauArmTests │ │ │ │ └── testArtNouveauArm.1.png │ │ ├── ClassicArmTests.swift │ │ ├── ArtNouveauArmTests.swift │ │ ├── ArmTypeTests.swift │ │ └── ArmTests.swift │ ├── Steampunk │ │ ├── __Snapshots__ │ │ │ ├── MoonTests │ │ │ │ └── testMoon.1.png │ │ │ ├── CogwheelTests │ │ │ │ ├── testCogwheel.1.png │ │ │ │ └── testCogwheels.1.png │ │ │ ├── PlateTests │ │ │ │ ├── testPlateSoftI.1.png │ │ │ │ └── testPlateHardXII.1.png │ │ │ ├── WindUpKeyTests │ │ │ │ └── testWindUpKey.1.png │ │ │ ├── SteampunkHourArmTests │ │ │ │ └── testSteampunkHourArm.1.png │ │ │ └── SteampunkMinuteArmTests │ │ │ │ └── testSteampunkMinuteArm.1.png │ │ ├── MoonTests.swift │ │ ├── WindUpKeyTests.swift │ │ ├── SteampunkHourArmTests.swift │ │ ├── SteampunkMinuteArmTests.swift │ │ ├── PlateTests.swift │ │ └── CogwheelTests.swift │ ├── Borders │ │ ├── __Snapshots__ │ │ │ ├── DrawnClockBorderTests │ │ │ │ └── testDrawnClockBorder.1.png │ │ │ ├── ClassicClockBorderTests │ │ │ │ ├── testClassicClockBorder.1.png │ │ │ │ └── testClassicClockBorderOnMac.1.png │ │ │ └── SteampunkClockBorderTests │ │ │ │ └── testSteampunkClockBorder.1.png │ │ ├── SteampunkClockBorderTests.swift │ │ ├── DrawnClockBorderTests.swift │ │ └── ClassicClockBorderTests.swift │ ├── Indicators │ │ ├── __Snapshots__ │ │ │ ├── ArtNouveauIndicatorsTests │ │ │ │ └── testArtNouveauIndicators.1.png │ │ │ └── SteampunkIndicatorsTests │ │ │ │ ├── testSteampunkIndicators.1.png │ │ │ │ └── testSteampunkIndicatorsWithLimitedHours.1.png │ │ ├── ArtNouveauIndicatorsTests.swift │ │ └── SteampunkIndicatorsTests.swift │ └── ArmsTests.swift │ ├── __Snapshots__ │ └── ClockViewTests │ │ ├── testDefaultClockView.1.png │ │ ├── testClockViewWithFace.1.png │ │ ├── testClockViewDrawingStyle.1.png │ │ ├── testClockViewSteampunkStyle.1.png │ │ ├── testClockViewArtNouveauStyle.1.png │ │ └── testClockViewDifferentColors.1.png │ ├── Extensions │ ├── __Snapshots__ │ │ └── Path+CircleTests │ │ │ └── testCenter.1.png │ ├── CGRect+CircleTests.swift │ ├── Path+CircleTests.swift │ ├── Date+ClockTests.swift │ └── CGPoint+CircleTests.swift │ ├── Calendar+Test.swift │ ├── Snapshotting+DefaultImage.swift │ ├── XCTestManifests.swift │ ├── EnvironmentTests.swift │ └── ClockViewTests.swift ├── .swiftlint.yml ├── Package.resolved ├── Package.swift ├── LICENSE ├── .github └── workflows │ └── xcodetest.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /docs/assets/ClockViewClassic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/docs/assets/ClockViewClassic.png -------------------------------------------------------------------------------- /docs/assets/ClockViewDrawing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/docs/assets/ClockViewDrawing.png -------------------------------------------------------------------------------- /docs/assets/ClockViewSteampunk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/docs/assets/ClockViewSteampunk.png -------------------------------------------------------------------------------- /docs/assets/ClockViewArtNouveau.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/docs/assets/ClockViewArtNouveau.png -------------------------------------------------------------------------------- /docs/assets/ClockViewClassicAndColors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/docs/assets/ClockViewClassicAndColors.png -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Double+Time.swift: -------------------------------------------------------------------------------- 1 | extension Double { 2 | static let hourInDegree: Double = 360/12 3 | static let minuteInDegree: Double = 360/60 4 | } 5 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftClockUITests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftClockUITests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/__Snapshots__/EyeTests/testEyes.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Face/__Snapshots__/EyeTests/testEyes.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/__Snapshots__/ArmsTests/testArms.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/__Snapshots__/ArmsTests/testArms.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/__Snapshots__/MouthTests/testMouths.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Face/__Snapshots__/MouthTests/testMouths.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testHourArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testHourArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testDefaultClockView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testDefaultClockView.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testDrawningArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testDrawningArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testMinuteArms.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testMinuteArms.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/MoonTests/testMoon.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/MoonTests/testMoon.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewWithFace.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewWithFace.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testArtNouveauArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testArtNouveauArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Extensions/__Snapshots__/Path+CircleTests/testCenter.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Extensions/__Snapshots__/Path+CircleTests/testCenter.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/__Snapshots__/ClockFaceTests/testClockFaceSmiling.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Face/__Snapshots__/ClockFaceTests/testClockFaceSmiling.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewDrawingStyle.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewDrawingStyle.1.png -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/GeometryProxy+Diameter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension GeometryProxy { 4 | var radius: CGFloat { min(size.width, size.height)/2 } 5 | var circle: CGRect { frame(in: .local) } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewSteampunkStyle.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewSteampunkStyle.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ClassicArmTests/testClassicArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ClassicArmTests/testClassicArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/CogwheelTests/testCogwheel.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/CogwheelTests/testCogwheel.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/PlateTests/testPlateSoftI.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/PlateTests/testPlateSoftI.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewArtNouveauStyle.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewArtNouveauStyle.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewDifferentColors.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/__Snapshots__/ClockViewTests/testClockViewDifferentColors.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testArmWith25MinuteAngle.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArmTests/testArmWith25MinuteAngle.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/CogwheelTests/testCogwheels.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/CogwheelTests/testCogwheels.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/PlateTests/testPlateHardXII.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/PlateTests/testPlateHardXII.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/WindUpKeyTests/testWindUpKey.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/WindUpKeyTests/testWindUpKey.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArtNouveauArmTests/testArtNouveauArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Arm/__Snapshots__/ArtNouveauArmTests/testArtNouveauArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/DrawnClockBorderTests/testDrawnClockBorder.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/DrawnClockBorderTests/testDrawnClockBorder.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/SteampunkHourArmTests/testSteampunkHourArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/SteampunkHourArmTests/testSteampunkHourArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/ClassicClockBorderTests/testClassicClockBorder.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/ClassicClockBorderTests/testClassicClockBorder.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/SteampunkMinuteArmTests/testSteampunkMinuteArm.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Steampunk/__Snapshots__/SteampunkMinuteArmTests/testSteampunkMinuteArm.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/SteampunkClockBorderTests/testSteampunkClockBorder.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/SteampunkClockBorderTests/testSteampunkClockBorder.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/ClassicClockBorderTests/testClassicClockBorderOnMac.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Borders/__Snapshots__/ClassicClockBorderTests/testClassicClockBorderOnMac.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/ArtNouveauIndicatorsTests/testArtNouveauIndicators.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/ArtNouveauIndicatorsTests/testArtNouveauIndicators.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/SteampunkIndicatorsTests/testSteampunkIndicators.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/SteampunkIndicatorsTests/testSteampunkIndicators.1.png -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Calendar+Test.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Calendar { 4 | static var test: Self { 5 | var calendar = Self(identifier: .gregorian) 6 | calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone 7 | return calendar 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/SteampunkIndicatorsTests/testSteampunkIndicatorsWithLimitedHours.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/SwiftClockUI/HEAD/Tests/SwiftClockUITests/Elements/Indicators/__Snapshots__/SteampunkIndicatorsTests/testSteampunkIndicatorsWithLimitedHours.1.png -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | trailing_comma: 5 | mandatory_comma: true 6 | disabled_rules: 7 | - identifier_name 8 | opt_in_rules: 9 | - force_unwrapping 10 | - implicitly_unwrapped_optional 11 | - implicit_return 12 | - unused_import 13 | - indentation_width 14 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Color+Background.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | static var background: Self { 5 | #if os(iOS) 6 | return Self(UIColor.systemBackground) 7 | #else 8 | return Self(NSColor.windowBackgroundColor) 9 | #endif 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/EyeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class EyeTests: XCTestCase { 7 | #if !os(macOS) 8 | func testEyes() { 9 | assertSnapshot(matching: Eye_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/ArmsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ArmsTests: XCTestCase { 7 | #if !os(macOS) 8 | func testArms() { 9 | assertSnapshot(matching: Arms_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/MouthTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class MouthTests: XCTestCase { 7 | #if !os(macOS) 8 | func testMouths() { 9 | assertSnapshot(matching: Mouth_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/MoonTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class MoonTests: XCTestCase { 7 | #if !os(macOS) 8 | func testMoon() { 9 | assertSnapshot(matching: Moon_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/ClassicArmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ClassicArmTests: XCTestCase { 7 | #if !os(macOS) 8 | func testClassicArm() { 9 | assertSnapshot(matching: ClassicArm_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/WindUpKeyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class WindUpKeyTests: XCTestCase { 7 | #if !os(macOS) 8 | func testWindUpKey() { 9 | assertSnapshot(matching: WindUpKey_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Face/ClockFaceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ClockFaceTests: XCTestCase { 7 | #if !os(macOS) 8 | func testClockFaceSmiling() { 9 | assertSnapshot(matching: ClockFaceSmiling_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Face/ClockFaceEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClockFaceShownKey: EnvironmentKey { 4 | public static let defaultValue = false 5 | } 6 | 7 | extension EnvironmentValues { 8 | var clockFaceShown: Bool { 9 | get { self[ClockFaceShownKey.self] } 10 | set { self[ClockFaceShownKey.self] = newValue } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/SteampunkArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SteampunkArm: View { 4 | let type: ArmType 5 | 6 | var body: some View { 7 | Group { 8 | if type == .hour { 9 | SteampunkHourArm() 10 | } else { 11 | SteampunkMinuteArm() 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/ArtNouveauArmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ArtNouveauArmTests: XCTestCase { 7 | #if !os(macOS) 8 | func testArtNouveauArm() { 9 | assertSnapshot(matching: ArtNouveauArm_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Angle+Circle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Angle { 4 | static let fullRound: Self = .radians(2 * .pi) 5 | 6 | static func inCircle(for point: CGPoint, circleCenter: CGPoint) -> Self { 7 | .radians(Double(atan2( 8 | point.y - circleCenter.y, 9 | point.x - circleCenter.x 10 | ))) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/SteampunkHourArmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class SteampunkHourArmTests: XCTestCase { 7 | #if !os(macOS) 8 | func testSteampunkHourArm() { 9 | assertSnapshot(matching: SteampunkHourArm_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Borders/ClassicClockBorder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClassicClockBorder: View { 4 | var body: some View { 5 | Circle().strokeBorder(lineWidth: 2) 6 | } 7 | } 8 | 9 | #if DEBUG 10 | struct ClassicClockBorder_Previews: PreviewProvider { 11 | static var previews: some View { 12 | ClassicClockBorder().padding() 13 | } 14 | } 15 | #endif 16 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/SteampunkMinuteArmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class SteampunkMinuteArmTests: XCTestCase { 7 | #if !os(macOS) 8 | func testSteampunkMinuteArm() { 9 | assertSnapshot(matching: SteampunkMinuteArm_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockDateEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockDateKey: EnvironmentKey { 4 | public static let defaultValue: Binding = .constant(Date()) 5 | } 6 | 7 | public extension EnvironmentValues { 8 | var clockDate: Binding { 9 | get { self[ClockDateKey.self] } 10 | set { self[ClockDateKey.self] = newValue } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/SteampunkClockBorderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class SteampunkClockBorderTests: XCTestCase { 7 | #if !os(macOS) 8 | func testSteampunkClockBorder() { 9 | assertSnapshot(matching: SteampunkClockBorder_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Indicators/ArtNouveauIndicatorsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ArtNouveauIndicatorsTests: XCTestCase { 7 | #if !os(macOS) 8 | func testArtNouveauIndicators() { 9 | assertSnapshot(matching: ArtNouveauIndicators_Previews.previews, as: .default) 10 | } 11 | #endif 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Snapshotting+DefaultImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SnapshotTesting 3 | 4 | #if !os(macOS) 5 | extension Snapshotting where Value: SwiftUI.View, Format == UIImage { 6 | static func `default`(precision: Float) -> Self { 7 | image(precision: precision, layout: .fixed(width: 200, height: 200)) 8 | } 9 | static var `default`: Self { .default(precision: 1) } 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockBorderColorEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockBorderColorArmKey: EnvironmentKey { 4 | public static let defaultValue: Color = .primary 5 | } 6 | 7 | public extension EnvironmentValues { 8 | var clockBorderColor: Color { 9 | get { self[ClockBorderColorArmKey.self] } 10 | set { self[ClockBorderColorArmKey.self] = newValue } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-snapshot-testing", 6 | "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "f29e2014f6230cf7d5138fc899da51c7f513d467", 10 | "version": "1.10.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockIndicatorsColorEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockIndicatorsColorArmKey: EnvironmentKey { 4 | public static let defaultValue: Color = .primary 5 | } 6 | 7 | public extension EnvironmentValues { 8 | var clockIndicatorsColor: Color { 9 | get { self[ClockIndicatorsColorArmKey.self] } 10 | set { self[ClockIndicatorsColorArmKey.self] = newValue } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockAnimationEnabled.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClockAnimationEnabledKey: EnvironmentKey { 4 | public static let defaultValue: Bool = !EnvironmentValues().accessibilityReduceMotion 5 | } 6 | 7 | extension EnvironmentValues { 8 | var clockAnimationEnabled: Bool { 9 | get { self[ClockAnimationEnabledKey.self] } 10 | set { self[ClockAnimationEnabledKey.self] = newValue } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/PlateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class PlateTests: XCTestCase { 7 | #if !os(macOS) 8 | func testPlateSoftI() { 9 | assertSnapshot(matching: PlateSoftI_Previews.previews, as: .default) 10 | } 11 | func testPlateHardXII() { 12 | assertSnapshot(matching: PlateHardXII_Previews.previews, as: .default) 13 | } 14 | #endif 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Steampunk/CogwheelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class CogwheelKeyTests: XCTestCase { 7 | #if !os(macOS) 8 | func testCogwheel() { 9 | assertSnapshot(matching: Cogwheel_Previews.previews, as: .default) 10 | } 11 | 12 | func testCogwheels() { 13 | assertSnapshot(matching: Cogwheels_Previews.previews, as: .default) 14 | } 15 | #endif 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/ViewModifiers/PositionInCircle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PositionInCircle: ViewModifier { 4 | let angle: Angle 5 | let marginRatio: CGFloat 6 | 7 | func body(content: Content) -> some View { 8 | GeometryReader { geometry in 9 | content.position(.inCircle( 10 | geometry.circle, 11 | for: self.angle, 12 | margin: geometry.radius * self.marginRatio 13 | )) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/CGRect+Circle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension CGRect { 4 | static func circle(center: CGPoint, radius: CGFloat) -> Self { 5 | .init(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2) 6 | } 7 | 8 | var center: CGPoint { .init(x: midX, y: midY) } 9 | var radius: CGFloat { min(width, height)/2 } 10 | var circle: CGRect { 11 | CGRect(origin: origin, size: CGSize(width: radius * 2, height: radius * 2)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/DrawnClockBorderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class DrawnClockBorderTests: XCTestCase { 7 | #if !os(macOS) 8 | func testDrawnClockBorder() { 9 | let preview = DrawnClockBorder_Previews.previews 10 | .environment(\.clockRandom, .fixed) 11 | .environment(\.clockAnimationEnabled, false) 12 | assertSnapshot(matching: preview, as: .default) 13 | } 14 | #endif 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/ViewModifiers/FontProportional.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FontProportional: ViewModifier { 4 | let ratio: CGFloat 5 | var weight = Font.Weight.regular 6 | var design = Font.Design.default 7 | 8 | func body(content: Content) -> some View { 9 | GeometryReader { geometry in 10 | content 11 | .font(.system(size: (geometry.radius * self.ratio).rounded(), weight: self.weight, design: self.design)) 12 | .minimumScaleFactor(0.5) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Path+Circle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Path { 4 | mutating func addCircle(_ circle: CGRect) { 5 | move(to: CGPoint(x: circle.maxX, y: circle.center.y)) 6 | addArc(center: circle.center, radius: circle.radius, startAngle: .zero, endAngle: .fullRound, clockwise: false) 7 | } 8 | 9 | mutating func addTest(to center: CGPoint) { 10 | let previous = currentPoint ?? .zero 11 | addCircle(CGRect.circle(center: center, radius: 2)) 12 | move(to: previous) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockIsAnimationEnabledEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockIsAnimationEnabledEnvironmentKey: EnvironmentKey { 4 | public static let defaultValue: Bool = true 5 | } 6 | 7 | public extension EnvironmentValues { 8 | @available(*, deprecated, message: "Not used anymore. You can safely remove it.") 9 | var clockIsAnimationEnabled: Bool { 10 | get { self[ClockIsAnimationEnabledEnvironmentKey.self] } 11 | set { self[ClockIsAnimationEnabledEnvironmentKey.self] = newValue } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | [ 6 | ("ClockTests", ClockTests), 7 | ("ArmTests", ArmTests), 8 | ("ClockFaceTests", ClockFaceTests), 9 | ("EyeTests", EyeTests), 10 | ("MouthTests", MouthTests), 11 | ("EnvironmentTests", EnvironmentTests), 12 | ("CGPointExtensionCircleTests", CGPointExtensionCircleTests), 13 | ("DateExtensionClockTests", DateExtensionClockTests), 14 | ] 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Extensions/CGRect+CircleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SwiftUI 4 | 5 | class CGRectExtensionCircleTests: XCTestCase { 6 | func testCenter() { 7 | let center = CGPoint(x: 30, y: 30) 8 | let circle = CGRect.circle(center: center, radius: 60) 9 | XCTAssertEqual(circle.center, center) 10 | } 11 | 12 | func testRadius() { 13 | let radius: CGFloat = 60 14 | let circle = CGRect.circle(center: CGPoint(x: 30, y: 30), radius: radius) 15 | XCTAssertEqual(circle.radius, radius) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Indicators/SteampunkIndicatorsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class SteampunkIndicatorsTests: XCTestCase { 7 | #if !os(macOS) 8 | func testSteampunkIndicators() { 9 | assertSnapshot(matching: SteampunkIndicators_Previews.previews, as: .default) 10 | } 11 | 12 | func testSteampunkIndicatorsWithLimitedHours() { 13 | let preview = SteampunkIndicatorsWithLimitedHours_Previews.previews 14 | assertSnapshot(matching: preview, as: .default) 15 | } 16 | #endif 17 | } 18 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/EnvironmentTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | 4 | class EnvironmentTests: XCTestCase { 5 | func testClockStyleDescription() { 6 | XCTAssertEqual(ClockStyle.classic.description, "Classic") 7 | XCTAssertEqual(ClockStyle.artNouveau.description, "Art Nouveau") 8 | XCTAssertEqual(ClockStyle.drawing.description, "Drawing") 9 | } 10 | 11 | func testClockStyleIdentifier() { 12 | XCTAssertEqual(ClockStyle.classic.id, 0) 13 | XCTAssertEqual(ClockStyle.artNouveau.id, 1) 14 | XCTAssertEqual(ClockStyle.drawing.id, 2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Borders/ClockBorderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClockBorderView: View { 4 | @Environment(\.clockStyle) var style 5 | @Environment(\.clockBorderColor) var color 6 | 7 | var body: some View { 8 | Group { 9 | if style == .artNouveau { 10 | ArtNouveauClockBorder() 11 | } else if style == .drawing { 12 | DrawnClockBorder() 13 | } else if style == .steampunk { 14 | SteampunkClockBorder() 15 | } else { 16 | ClassicClockBorder() 17 | } 18 | }.foregroundColor(color) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/IndicatorsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct IndicatorsView: View { 4 | @Environment(\.clockStyle) var style 5 | @Environment(\.clockIndicatorsColor) var color 6 | 7 | var body: some View { 8 | Group { 9 | if style == .artNouveau { 10 | ArtNouveauIndicators() 11 | } else if style == .drawing { 12 | DrawnIndicators() 13 | } else if style == .steampunk { 14 | SteampunkIndicators() 15 | } else { 16 | ClassicIndicators() 17 | } 18 | }.foregroundColor(color) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/RomanNumber.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum RomanNumber { 4 | static let numbers = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"] 5 | static let limitedNumbers = ["XII", "III", "VI", "IX"] 6 | 7 | static func numbers(configuration: ClockConfiguration) -> [String] { 8 | configuration.isLimitedHoursShown ? Self.limitedNumbers : Self.numbers 9 | } 10 | 11 | static func angle(for romanNumber: String) -> Angle { 12 | guard let index = Self.numbers.firstIndex(of: romanNumber) else { return .zero } 13 | return Angle(degrees: Double(index) * .hourInDegree) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockArmColorsEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockArmColors: Equatable { 4 | var minute: Color 5 | var hour: Color 6 | 7 | public init(minute: Color, hour: Color) { 8 | self.minute = minute 9 | self.hour = hour 10 | } 11 | } 12 | 13 | public struct ClockArmColorsKey: EnvironmentKey { 14 | public static let defaultValue = ClockArmColors( 15 | minute: .primary, 16 | hour: .primary 17 | ) 18 | } 19 | 20 | public extension EnvironmentValues { 21 | var clockArmColors: ClockArmColors { 22 | get { self[ClockArmColorsKey.self] } 23 | set { self[ClockArmColorsKey.self] = newValue } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Borders/ClassicClockBorderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ClassicClockBorderTests: XCTestCase { 7 | #if !os(macOS) 8 | func testClassicClockBorder() { 9 | assertSnapshot(matching: ClassicClockBorder_Previews.previews, as: .default) 10 | } 11 | #else 12 | func testClassicClockBorderOnMac() { 13 | let view = NSHostingView(rootView: ClassicClockBorder_Previews.previews) 14 | view.frame = CGRect(x: 0, y: 0, width: 800, height: 600) 15 | view.layer?.backgroundColor = .white 16 | assertSnapshot(matching: view, as: .image(precision: 97/100)) 17 | } 18 | #endif 19 | } 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftClockUI", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | ], 12 | products: [ 13 | .library( 14 | name: "SwiftClockUI", 15 | targets: ["SwiftClockUI"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.9.0"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "SwiftClockUI", 23 | dependencies: []), 24 | .testTarget( 25 | name: "SwiftClockUITests", 26 | dependencies: ["SwiftClockUI", "SnapshotTesting"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/ArmTypeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SwiftUI 4 | 5 | class ArmTypeTests: XCTestCase { 6 | func testSetHourAngle() { 7 | var date = Date(timeIntervalSince1970: 4300) 8 | XCTAssertEqual(1, Calendar.test.component(.hour, from: date)) 9 | 10 | let angle = Angle(degrees: .hourInDegree * 4) 11 | 12 | ArmType.hour.setAngle(angle, date: &date, calendar: .test) 13 | 14 | XCTAssertEqual(4, Calendar.test.component(.hour, from: date)) 15 | } 16 | 17 | func testSetMinuteAngle() { 18 | var date = Date(timeIntervalSince1970: 4300) 19 | XCTAssertEqual(11, Calendar.test.component(.minute, from: date)) 20 | 21 | let angle = Angle(degrees: .minuteInDegree * 25) 22 | 23 | ArmType.minute.setAngle(angle, date: &date, calendar: .test) 24 | 25 | XCTAssertEqual(25, Calendar.test.component(.minute, from: date)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Elements/Arm/ArmTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class ArmTests: XCTestCase { 7 | #if !os(macOS) 8 | func testMinuteArms() { 9 | assertSnapshot(matching: ArmMinute_Previews.previews, as: .default) 10 | } 11 | 12 | func testHourArm() { 13 | assertSnapshot(matching: ArmHour_Previews.previews, as: .default) 14 | } 15 | 16 | func testArmWith25MinuteAngle() { 17 | assertSnapshot(matching: ArmWith25MinuteAngle_Previews.previews, as: .default) 18 | } 19 | 20 | func testArtNouveauArm() { 21 | assertSnapshot(matching: ArtNouveauDesignArm_Previews.previews, as: .default) 22 | } 23 | 24 | func testDrawningArm() { 25 | let preview = DrawingDesignArm_Previews.previews 26 | .environment(\.clockRandom, .fixed) 27 | .environment(\.clockAnimationEnabled, false) 28 | assertSnapshot(matching: preview, as: .default) 29 | } 30 | #endif 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/Moon.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Moon: Shape { 4 | func path(in rect: CGRect) -> Path { 5 | let center = CGPoint(x: rect.midX, y: rect.midY) 6 | let width = min(rect.width, rect.height) 7 | let angle = Angle(degrees: 140) 8 | let startAngle = -Angle(degrees: 90) 9 | let endAngle = angle - Angle(degrees: 90) 10 | var path = Path() 11 | 12 | path.addArc(center: center, radius: width/2, startAngle: startAngle, endAngle: endAngle, clockwise: true) 13 | 14 | let margin: CGFloat = width 15 | path.addCurve( 16 | to: .inCircle(rect, for: .zero), 17 | control1: .inCircle(rect, for: angle * 1/4, margin: margin), 18 | control2: .inCircle(rect, for: angle * 3/4, margin: margin) 19 | ) 20 | 21 | return path 22 | } 23 | } 24 | 25 | struct Moon_Previews: PreviewProvider { 26 | static var previews: some View { 27 | Moon() 28 | .stroke() 29 | .padding() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockStyleEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum ClockStyle: Identifiable, CaseIterable { 4 | case classic 5 | case artNouveau 6 | case drawing 7 | case steampunk 8 | 9 | public var description: String { 10 | switch self { 11 | case .classic: return "Classic" 12 | case .artNouveau: return "Art Nouveau" 13 | case .drawing: return "Drawing" 14 | case .steampunk: return "Steampunk" 15 | } 16 | } 17 | 18 | public var id: Int { 19 | switch self { 20 | case .classic: return 0 21 | case .artNouveau: return 1 22 | case .drawing: return 2 23 | case .steampunk: return 3 24 | } 25 | } 26 | } 27 | 28 | public struct ClockStyleKey: EnvironmentKey { 29 | public static let defaultValue: ClockStyle = .classic 30 | } 31 | 32 | public extension EnvironmentValues { 33 | var clockStyle: ClockStyle { 34 | get { self[ClockStyleKey.self] } 35 | set { self[ClockStyleKey.self] = newValue } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockConfigurationEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ClockConfiguration: Equatable { 4 | public var isLimitedHoursShown = false 5 | public var isMinuteIndicatorsShown = true 6 | public var isHourIndicatorsShown = true 7 | 8 | public init( 9 | isLimitedHoursShown: Bool = false, 10 | isMinuteIndicatorsShown: Bool = true, 11 | isHourIndicatorsShown: Bool = true 12 | ) { 13 | self.isLimitedHoursShown = isLimitedHoursShown 14 | self.isMinuteIndicatorsShown = isMinuteIndicatorsShown 15 | self.isHourIndicatorsShown = isHourIndicatorsShown 16 | } 17 | } 18 | 19 | public struct ClockConfigurationEnvironmentKey: EnvironmentKey { 20 | public static let defaultValue: ClockConfiguration = ClockConfiguration() 21 | } 22 | 23 | public extension EnvironmentValues { 24 | var clockConfiguration: ClockConfiguration { 25 | get { self[ClockConfigurationEnvironmentKey.self] } 26 | set { self[ClockConfigurationEnvironmentKey.self] = newValue } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Renaud Jenny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Path+VerticalMirror.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Path { 4 | mutating func addVerticalMirror(in rect: CGRect) { 5 | let mirror = self 6 | .applying(.init(scaleX: -1, y: 1)) 7 | .applying(.init(translationX: rect.width, y: 0)) 8 | #if os(iOS) 9 | let reversedPath = Path(UIBezierPath(cgPath: mirror.cgPath).reversing().cgPath) 10 | #else 11 | let reversedPath = Path(NSBezierPath(cgPath: mirror.cgPath).reversed.cgPath) 12 | #endif 13 | 14 | reversedPath.forEach { 15 | switch $0 { 16 | case .move: break 17 | case .closeSubpath: break 18 | case .line(to: let to): 19 | self.addLine(to: to) 20 | case .quadCurve(to: let to, control: let control): 21 | self.addQuadCurve(to: to, control: control) 22 | case .curve(to: let to, control1: let control1, control2: let control2): 23 | self.addCurve(to: to, control1: control1, control2: control2) 24 | } 25 | } 26 | 27 | self.closeSubpath() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Extensions/Path+CircleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | 6 | class PathExtensionCircleTests: XCTestCase { 7 | #if !os(macOS) 8 | func testCenter() { 9 | let circles = CirclesTest() 10 | .stroke() 11 | .padding() 12 | assertSnapshot(matching: circles, as: .default) 13 | } 14 | #endif 15 | 16 | struct CirclesTest: Shape { 17 | func path(in rect: CGRect) -> Path { 18 | let radius = rect.radius * 1/3 19 | let firstCircleCenter = CGPoint(x: rect.minX + radius, y: rect.midY) 20 | let secondCircleCenter = CGPoint(x: rect.maxX - radius, y: rect.midY) 21 | let thirdCircleCenter = CGPoint(x: rect.midX, y: rect.midY - radius * 2) 22 | 23 | var path = Path() 24 | path.addCircle(CGRect.circle(center: firstCircleCenter, radius: radius)) 25 | path.addCircle(CGRect.circle(center: secondCircleCenter, radius: radius)) 26 | path.addCircle(CGRect.circle(center: thirdCircleCenter, radius: radius)) 27 | return path 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Borders/ArtNouveauClockBorder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArtNouveauClockBorder: View { 4 | var body: some View { 5 | ZStack { 6 | Circle().strokeBorder(lineWidth: 2) 7 | InnerCircle().strokeBorder() 8 | } 9 | } 10 | } 11 | 12 | private struct InnerCircle: InsettableShape { 13 | var insetAmount = 0.0 14 | 15 | func path(in rect: CGRect) -> Path { 16 | var path = Path() 17 | path.addCircle(CGRect( 18 | x: rect.midX - rect.radius * 90/100, 19 | y: rect.midY + rect.radius * 10/100 - rect.radius * 90/100, 20 | width: rect.radius * 2 * 90/100, 21 | height: rect.radius * 2 * 90/100 22 | ).insetBy(dx: insetAmount, dy: insetAmount)) 23 | return path 24 | } 25 | 26 | func inset(by amount: CGFloat) -> some InsettableShape { 27 | var circle = self 28 | circle.insetAmount += amount 29 | return circle 30 | } 31 | } 32 | 33 | #if DEBUG 34 | struct ArtNouveauClockBorderClockBorder_Previews: PreviewProvider { 35 | static var previews: some View { 36 | ArtNouveauClockBorder() 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arms.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct Arms: View { 5 | var body: some View { 6 | ZStack { 7 | ArmView(type: .hour) 8 | ArmView(type: .minute) 9 | } 10 | .modifier(OnHover()) 11 | } 12 | } 13 | 14 | struct OnHover: ViewModifier { 15 | @State var isHover: Bool = false 16 | 17 | func body(content: Content) -> some View { 18 | #if os(macOS) 19 | return content 20 | .onHover { isHover in withAnimation(.easeInOut) { self.isHover = isHover } } 21 | .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.44), radius: isHover ? 6 : 0) 22 | .scaleEffect(isHover ? 1.1 : 1) 23 | #else 24 | return content 25 | #endif 26 | } 27 | } 28 | 29 | #if DEBUG 30 | struct Arms_Previews: PreviewProvider { 31 | @Environment(\.calendar) static var calendar 32 | 33 | static var previews: some View { 34 | ZStack { 35 | Circle().stroke() 36 | Arms() 37 | } 38 | .padding() 39 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/ClockViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SnapshotTesting 4 | import SwiftUI 5 | import Combine 6 | 7 | class ClockTests: XCTestCase { 8 | #if !os(macOS) 9 | func testDefaultClockView() { 10 | assertSnapshot(matching: ClockView_Previews.previews, as: .default) 11 | } 12 | 13 | func testClockViewWithFace() { 14 | assertSnapshot(matching: ClockViewWithFace_Previews.previews, as: .default) 15 | } 16 | 17 | func testClockViewArtNouveauStyle() { 18 | assertSnapshot(matching: ClockViewArtNouveauStyle_Previews.previews, as: .default) 19 | } 20 | 21 | func testClockViewDrawingStyle() { 22 | let preview = ClockViewDrawingStyle_Previews.previews 23 | .environment(\.clockRandom, .fixed) 24 | .environment(\.clockAnimationEnabled, false) 25 | assertSnapshot(matching: preview, as: .default(precision: 99/100)) 26 | } 27 | 28 | func testClockViewSteampunkStyle() { 29 | assertSnapshot(matching: ClockViewSteampunkStyle_Previews.previews, as: .default) 30 | } 31 | 32 | func testClockViewDifferentColors() { 33 | assertSnapshot(matching: ClockViewDifferentColors_Previews.previews, as: .default) 34 | } 35 | #endif 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/ArmType.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum ArmType { 4 | case hour 5 | case minute 6 | 7 | typealias Ratio = (lineWidth: CGFloat, margin: CGFloat) 8 | private static let hourRatio: Ratio = (lineWidth: 1/2, margin: 2/5) 9 | private static let minuteRatio: Ratio = (lineWidth: 1/3, margin: 1/8) 10 | 11 | var ratio: Ratio { 12 | switch self { 13 | case .hour: return Self.hourRatio 14 | case .minute: return Self.minuteRatio 15 | } 16 | } 17 | 18 | func angle(date: Date, calendar: Calendar) -> Angle { 19 | switch self { 20 | case .hour: return date.hourAngle(calendar: calendar) 21 | case .minute: return date.minuteAngle(calendar: calendar) 22 | } 23 | } 24 | 25 | func setAngle(_ angle: Angle, date: inout Date, calendar: Calendar) { 26 | switch self { 27 | case .hour: 28 | date.setHour(angle: angle, calendar: calendar) 29 | case .minute: 30 | date.setMinute(angle: angle, calendar: calendar) 31 | } 32 | } 33 | 34 | func color(with armColors: ClockArmColors) -> Color { 35 | switch self { 36 | case .hour: return armColors.hour 37 | case .minute: return armColors.minute 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/xcodetest.yml: -------------------------------------------------------------------------------- 1 | name: Xcode Unit Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build-ios: 11 | runs-on: macOS-12 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Select Xcode 17 | run: sudo xcode-select -switch /Applications/Xcode_14.2.app 18 | 19 | - name: Linter 20 | run: swiftlint 21 | 22 | - name: Xcode version 23 | run: /usr/bin/xcodebuild -version 24 | 25 | - name: List all available build targets and schemes 26 | run: xcodebuild -list 27 | 28 | - name: List available devices 29 | run: xcrun simctl list 30 | 31 | - name: Xcode test on specific device 32 | run: xcodebuild clean test -scheme SwiftClockUI -destination 'platform=iOS Simulator,name=iPhone 14 Pro' 33 | 34 | - uses: actions/upload-artifact@v3 35 | if: failure() 36 | with: 37 | name: failed-screenshots 38 | path: '~/Library/Developer/CoreSimulator/Devices/*/data/tmp/*Tests' 39 | retention-days: 5 40 | 41 | build-macos: 42 | runs-on: macOS-12 43 | 44 | steps: 45 | - uses: actions/checkout@v3 46 | 47 | - name: List all available build targets and schemes 48 | run: xcodebuild -list 49 | 50 | - name: Xcode test on Mac 51 | run: xcodebuild clean test -scheme SwiftClockUI -destination 'platform=OS X' 52 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/ClassicArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClassicArm: Shape { 4 | let type: ArmType 5 | private static let widthRatio: CGFloat = 1/30 6 | 7 | func path(in rect: CGRect) -> Path { 8 | var path = Path() 9 | let center = CGPoint(x: rect.midX, y: rect.midY) 10 | let diameter = min(rect.width, rect.height) 11 | let width = diameter * Self.widthRatio * type.ratio.lineWidth 12 | 13 | path.move(to: center) 14 | path.addArc( 15 | center: center, 16 | radius: width/2, 17 | startAngle: .zero, 18 | endAngle: .degrees(180), 19 | clockwise: false 20 | ) 21 | 22 | let topY = center.y - diameter/2 23 | let margin = diameter/2 * type.ratio.margin 24 | 25 | let topLeft = CGPoint(x: center.x - width/4, y: topY + margin) 26 | path.addLine(to: topLeft) 27 | 28 | let topRight = CGPoint(x: center.x + width/4, y: topY + margin) 29 | path.addLine(to: topRight) 30 | 31 | let bottomRight = CGPoint(x: center.x + width/2, y: center.y) 32 | path.addLine(to: bottomRight) 33 | 34 | return path 35 | } 36 | } 37 | 38 | #if DEBUG 39 | struct ClassicArm_Previews: PreviewProvider { 40 | static var previews: some View { 41 | ZStack { 42 | Circle().stroke() 43 | ClassicArm(type: .minute) 44 | } 45 | .padding() 46 | } 47 | } 48 | #endif 49 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Borders/SteampunkClockBorder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SteampunkClockBorder: View { 4 | @State private var windUpKeyAnimationStep = 0.0 5 | 6 | var body: some View { 7 | GeometryReader { geometry in 8 | ZStack { 9 | windUpKey(geometry: geometry) 10 | border() 11 | } 12 | } 13 | .onAppear { 14 | DispatchQueue.main.async { 15 | withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { 16 | windUpKeyAnimationStep = 1 17 | } 18 | } 19 | } 20 | } 21 | 22 | private func border() -> some View { 23 | ZStack { 24 | Circle().fill(Color.background) 25 | Circle().strokeBorder(lineWidth: 2) 26 | } 27 | } 28 | 29 | private func windUpKey(geometry: GeometryProxy) -> some View { 30 | WindUpKey(animationStep: windUpKeyAnimationStep) 31 | .stroke(lineWidth: 2) 32 | .rotation(.degrees(-45)) 33 | .frame(width: geometry.radius * 1/4, height: geometry.radius * 1/4) 34 | .position( 35 | .inCircle( 36 | geometry.circle, 37 | for: .degrees(-45), 38 | margin: -geometry.radius * 1/10 39 | ) 40 | ) 41 | } 42 | } 43 | 44 | #if DEBUG 45 | struct SteampunkClockBorder_Previews: PreviewProvider { 46 | static var previews: some View { 47 | SteampunkClockBorder() 48 | } 49 | } 50 | #endif 51 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/Date+Clock.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Date { 4 | private func positiveDegrees(angle: Angle) -> Double { 5 | angle.degrees > 0 ? angle.degrees : angle.degrees + 360 6 | } 7 | 8 | func hourAngle(calendar: Calendar) -> Angle { 9 | let minute = Double(calendar.component(.minute, from: self)) 10 | let minuteInHour = minute > 0 ? minute/60 : 0 11 | let hour = Double(calendar.component(.hour, from: self)) + minuteInHour 12 | 13 | let relationship: Double = 360/12 14 | let degrees = hour * relationship 15 | return Angle(degrees: degrees) 16 | } 17 | 18 | func minuteAngle(calendar: Calendar) -> Angle { 19 | let minute = Double(calendar.component(.minute, from: self)) 20 | let relationship: Double = 360/60 21 | return Angle(degrees: Double(minute) * relationship) 22 | } 23 | 24 | mutating func setHour(angle: Angle, calendar: Calendar) { 25 | let hour = Int((positiveDegrees(angle: angle)/Double.hourInDegree).rounded()) 26 | let minute = calendar.component(.minute, from: self) 27 | self = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: self) ?? self 28 | } 29 | 30 | mutating func setMinute(angle: Angle, calendar: Calendar) { 31 | let minute = Int((positiveDegrees(angle: angle)/Double.minuteInDegree).rounded()) 32 | let hour = calendar.component(.hour, from: self) 33 | self = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: self) ?? self 34 | } 35 | 36 | init(hour: Int, minute: Int, calendar: Calendar) { 37 | self.init() 38 | self = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: self) ?? self 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/CGPoint+Circle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension CGPoint { 4 | static func inCircle(_ circle: CGRect, for angle: Angle, margin: CGFloat = 0.0) -> Self { 5 | let radians = CGFloat(angle.radians) - .pi/2 6 | return CGPoint( 7 | x: circle.midX + (circle.radius - margin) * cos(radians), 8 | y: circle.midY + (circle.radius - margin) * sin(radians) 9 | ) 10 | } 11 | } 12 | 13 | #if DEBUG 14 | struct CGPointCircle_Previews: PreviewProvider { 15 | static var previews: some View { 16 | Preview().previewLayout(.sizeThatFits) 17 | } 18 | 19 | private struct Preview: View { 20 | var body: some View { 21 | GeometryReader(content: content).padding() 22 | } 23 | 24 | private func content(geometry: GeometryProxy) -> some View { 25 | ZStack { 26 | Circle().stroke().foregroundColor(Color.red.opacity(0.20)) 27 | square(at: .degrees(0), geometry: geometry) 28 | square(at: .degrees(20), geometry: geometry) 29 | square(at: .degrees(45), geometry: geometry) 30 | square(at: .degrees(90), geometry: geometry) 31 | square(at: .degrees(540), geometry: geometry) 32 | square(at: .degrees(-60), geometry: geometry) 33 | } 34 | } 35 | 36 | func square(at angle: Angle, geometry: GeometryProxy) -> some View { 37 | Rectangle() 38 | .stroke() 39 | .frame(width: 10, height: 10) 40 | .position( 41 | .inCircle( 42 | geometry.frame(in: .local), 43 | for: angle 44 | ) 45 | ) 46 | } 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Environment/ClockRandomEnvironment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct Random { 4 | var controlRatio = ( 5 | leftX: { CGFloat.random(in: 0.1...1) }, 6 | leftY: { CGFloat.random(in: 0.1...1) }, 7 | rightX: { CGFloat.random(in: 0.1...1) }, 8 | rightY: { CGFloat.random(in: 0.1...1) } 9 | ) 10 | 11 | var borderMarginRatio = ( 12 | maxMargin: { CGFloat.random(in: 0...$0) }, 13 | angleMargin: { Double.random(in: 0...1/3) } 14 | ) 15 | 16 | private static let angles: [Angle] = [.zero, .degrees(-5), .degrees(5)] 17 | var angle: () -> Angle? = Self.angles.randomElement 18 | private static let scales: [CGFloat] = [1, 1.1, 0.9] 19 | var scale: () -> CGFloat? = Self.scales.randomElement 20 | } 21 | 22 | public struct ClockRandomKey: EnvironmentKey { 23 | public static let defaultValue: Random = .random 24 | } 25 | 26 | public extension EnvironmentValues { 27 | var clockRandom: Random { 28 | get { self[ClockRandomKey.self] } 29 | set { self[ClockRandomKey.self] = newValue } 30 | } 31 | } 32 | 33 | public extension Random { 34 | struct ControlRatio { 35 | let leftX: CGFloat 36 | let leftY: CGFloat 37 | let rightX: CGFloat 38 | let rightY: CGFloat 39 | 40 | init(random: Random) { 41 | self.leftX = random.controlRatio.leftX() 42 | self.leftY = random.controlRatio.leftY() 43 | self.rightX = random.controlRatio.rightX() 44 | self.rightY = random.controlRatio.rightY() 45 | } 46 | } 47 | } 48 | 49 | extension Random { 50 | static var random = Random() 51 | static var fixed: Random { 52 | Random( 53 | controlRatio: ( 54 | leftX: { 0.5 }, 55 | leftY: { 0.6 }, 56 | rightX: { 0.7 }, 57 | rightY: { 0.8 } 58 | ), 59 | borderMarginRatio: ( 60 | maxMargin: { $0 }, 61 | angleMargin: { 1/3 } 62 | ), 63 | angle: { .degrees(5) }, 64 | scale: { 1 } 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Extensions/Date+ClockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SwiftUI 4 | 5 | class DateExtensionClockTests: XCTestCase { 6 | func testDateHourAngle() { 7 | let date = Date(hour: 2, minute: 0, calendar: .test) 8 | XCTAssertEqual(Angle(degrees: 60), date.hourAngle(calendar: .test)) 9 | } 10 | 11 | func testDateHourAngleWithSomeMinutes() { 12 | let date = Date(hour: 2, minute: 10, calendar: .test) 13 | XCTAssertEqual(Angle(degrees: 65), date.hourAngle(calendar: .test)) 14 | } 15 | 16 | func testDateMinuteAngle() { 17 | let date = Date(hour: 0, minute: 10, calendar: .test) 18 | XCTAssertEqual(Angle(degrees: 60), date.minuteAngle(calendar: .test)) 19 | } 20 | 21 | func testDateMinuteWithLargeAngle() { 22 | let date = Date(hour: 10, minute: 45, calendar: .test) 23 | XCTAssertEqual(Angle(degrees: 270), date.minuteAngle(calendar: .test)) 24 | } 25 | 26 | func testSetDateHour() { 27 | var date = Date(hour: 10, minute: 10, calendar: .test) 28 | date.setHour(angle: Angle(degrees: 30), calendar: .test) 29 | 30 | let calendar: Calendar = .test 31 | XCTAssertEqual(1, calendar.component(.hour, from: date)) 32 | } 33 | 34 | func testSetDateMinute() { 35 | var date = Date(hour: 10, minute: 10, calendar: .test) 36 | date.setMinute(angle: Angle(degrees: 18), calendar: .test) 37 | 38 | let calendar: Calendar = .test 39 | XCTAssertEqual(3, calendar.component(.minute, from: date)) 40 | } 41 | 42 | func testInitDateWithHourAndMinute() { 43 | let date = Date(hour: 1, minute: 11, calendar: .test) 44 | let expectedDate = Date(timeIntervalSince1970: 4300) 45 | 46 | let calendar: Calendar = .test 47 | let expectedHour = calendar.component(.hour, from: expectedDate) 48 | let expectedMinute = calendar.component(.minute, from: expectedDate) 49 | 50 | XCTAssertEqual(expectedHour, calendar.component(.hour, from: date)) 51 | XCTAssertEqual(expectedMinute, calendar.component(.minute, from: date)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/ArtNouveauArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArtNouveauArm: Shape { 4 | let type: ArmType 5 | private static let widthRatio: CGFloat = 1/40 6 | 7 | func path(in rect: CGRect) -> Path { 8 | var path = Path() 9 | 10 | let center = CGPoint(x: rect.midX, y: rect.midY) 11 | let diameter = min(rect.width, rect.height) 12 | let width = diameter * Self.widthRatio * type.ratio.lineWidth 13 | let topY = center.y - diameter/2 14 | let margin = diameter/2 * type.ratio.margin 15 | let height = diameter/2 - margin 16 | 17 | path.addArc( 18 | center: center, 19 | radius: width, 20 | startAngle: .zero, 21 | endAngle: .degrees(180), 22 | clockwise: false 23 | ) 24 | 25 | let top = CGPoint(x: center.x, y: topY + margin) 26 | 27 | let topLeft = CGPoint(x: top.x - width/2, y: top.y) 28 | let controlLeft1 = CGPoint(x: center.x + width * 2, y: top.y + height * 3/4) 29 | let controlLeft2 = CGPoint(x: center.x - width * 3, y: top.y + height * 1/5) 30 | 31 | path.addCurve( 32 | to: topLeft, 33 | control1: controlLeft1, 34 | control2: controlLeft2 35 | ) 36 | 37 | path.addArc( 38 | center: top, 39 | radius: width/2, 40 | startAngle: .degrees(180), 41 | endAngle: .zero, 42 | clockwise: false 43 | ) 44 | 45 | let bottomRight = CGPoint(x: center.x + width, y: center.y) 46 | let controlRight1 = CGPoint(x: center.x - width * 2, y: top.y + height * 1/5) 47 | let controlRight2 = CGPoint(x: center.x + width * 3, y: top.y + height * 3/4) 48 | 49 | path.addCurve( 50 | to: bottomRight, 51 | control1: controlRight1, 52 | control2: controlRight2 53 | ) 54 | 55 | return path 56 | } 57 | } 58 | 59 | #if DEBUG 60 | struct ArtNouveauArm_Previews: PreviewProvider { 61 | static var previews: some View { 62 | ZStack { 63 | Circle().stroke() 64 | ArtNouveauArm(type: .minute) 65 | } 66 | .padding() 67 | } 68 | } 69 | #endif 70 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Extensions/NSBezierPath+CGPath.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | 4 | extension NSBezierPath { 5 | // Inspired from https://stackoverflow.com/questions/1815568/how-can-i-convert-nsbezierpath-to-cgpath 6 | var cgPath: CGPath { 7 | let path = CGMutablePath() 8 | var points = [CGPoint](repeating: .zero, count: 3) 9 | 10 | for i in 0 ..< self.elementCount { 11 | let type = self.element(at: i, associatedPoints: &points) 12 | switch type { 13 | case .moveTo: path.move(to: points[0]) 14 | case .lineTo: path.addLine(to: points[0]) 15 | case .curveTo: path.addCurve(to: points[2], control1: points[0], control2: points[1]) 16 | case .closePath: path.closeSubpath() 17 | @unknown default: break 18 | } 19 | } 20 | 21 | return path 22 | } 23 | 24 | // Inspired from https://gist.github.com/lukaskubanek/1f3585314903dfc66fc7 25 | convenience init(cgPath: CGPath) { 26 | self.init() 27 | cgPath.applyWithBlock { 28 | switch $0.pointee.type { 29 | case .moveToPoint: 30 | self.move(to: $0.pointee.points[0]) 31 | case .addLineToPoint: 32 | self.line(to: $0.pointee.points[0]) 33 | case .addQuadCurveToPoint: 34 | let firstPoint = $0.pointee.points[0] 35 | let secondPoint = $0.pointee.points[1] 36 | 37 | let currentPoint = self.currentPoint 38 | let x = (currentPoint.x + 2 * firstPoint.x) / 3 39 | let y = (currentPoint.y + 2 * firstPoint.y) / 3 40 | let interpolatedPoint = CGPoint(x: x, y: y) 41 | 42 | let endPoint = secondPoint 43 | 44 | self.curve(to: endPoint, controlPoint1: interpolatedPoint, controlPoint2: interpolatedPoint) 45 | case .addCurveToPoint: 46 | let to = $0.pointee.points[2] 47 | let controlPoint1 = $0.pointee.points[0] 48 | let controlPoint2 = $0.pointee.points[1] 49 | self.curve(to: to, controlPoint1: controlPoint1, controlPoint2: controlPoint2) 50 | case .closeSubpath: 51 | self.close() 52 | @unknown default: break 53 | } 54 | } 55 | } 56 | } 57 | #endif 58 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Face/Mouth.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ClockFaceView { 4 | struct Mouth: Shape { 5 | let shape: MouthShape 6 | private var mouthAnimationShape: Double 7 | 8 | init(shape: MouthShape) { 9 | self.shape = shape 10 | self.mouthAnimationShape = shape.rawValue 11 | } 12 | 13 | var animatableData: Double { 14 | get { self.mouthAnimationShape } 15 | set { self.mouthAnimationShape = newValue } 16 | } 17 | 18 | func path(in rect: CGRect) -> Path { 19 | let width = rect.width 20 | let height = rect.height 21 | guard width > 0, height > 0 else { return Path() } 22 | 23 | let margin: CGFloat = 8.0 24 | 25 | var path = Path() 26 | 27 | let newY = height/2 * (1 - CGFloat(self.mouthAnimationShape)) 28 | path.move(to: CGPoint(x: .zero, y: newY)) 29 | 30 | let leftTo = CGPoint(x: width/2, y: height/2 * (1 + CGFloat(self.mouthAnimationShape))) 31 | let leftControl = CGPoint(x: .zero + margin, y: leftTo.y) 32 | path.addQuadCurve(to: leftTo, control: leftControl) 33 | 34 | let rightTo = CGPoint(x: width, y: newY) 35 | let rightControl = CGPoint(x: width - margin, y: leftTo.y) 36 | path.addQuadCurve(to: rightTo, control: rightControl) 37 | 38 | return path 39 | } 40 | } 41 | 42 | enum MouthShape: Double { 43 | case smile = 1.0 44 | case neutral = 0.0 45 | case sad = -1.0 46 | } 47 | } 48 | 49 | #if DEBUG 50 | struct Mouth_Previews: PreviewProvider { 51 | static var previews: some View { 52 | VStack { 53 | ClockFaceView.Mouth(shape: .smile) 54 | .stroke(style: .init(lineWidth: 6.0, lineCap: .round, lineJoin: .round)) 55 | .aspectRatio(3/1, contentMode: .fit) 56 | ClockFaceView.Mouth(shape: .neutral) 57 | .stroke(style: .init(lineWidth: 6.0, lineCap: .round, lineJoin: .round)) 58 | .aspectRatio(3/1, contentMode: .fit) 59 | ClockFaceView.Mouth(shape: .sad) 60 | .stroke(style: .init(lineWidth: 6.0, lineCap: .round, lineJoin: .round)) 61 | .aspectRatio(3/1, contentMode: .fit) 62 | }.padding() 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Face/ClockFaceView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClockFaceView: View { 4 | @Environment(\.clockFaceShown) var isShown 5 | 6 | var body: some View { 7 | GeometryReader(content: content) 8 | } 9 | 10 | private func content(geometry: GeometryProxy) -> some View { 11 | ZStack { 12 | leftEye(geometry: geometry) 13 | rightEye(geometry: geometry) 14 | mouth(geometry: geometry) 15 | } 16 | .opacity(isShown ? 1 : 0) 17 | .animation(.easeInOut, value: isShown) 18 | } 19 | 20 | private func leftEye(geometry: GeometryProxy) -> some View { 21 | Eye(move: isShown, position: .left) 22 | .scaleEffect(1/4) 23 | .position( 24 | x: geometry.frame(in: .local).midX - geometry.radius * 2/5, 25 | y: geometry.frame(in: .local).midY - geometry.radius/3 26 | ) 27 | } 28 | 29 | private func rightEye(geometry: GeometryProxy) -> some View { 30 | Eye(move: isShown, position: .right) 31 | .scaleEffect(1/4) 32 | .position( 33 | x: geometry.frame(in: .local).midX + geometry.radius * 2/5, 34 | y: geometry.frame(in: .local).midY - geometry.radius/3 35 | ) 36 | } 37 | 38 | private func mouth(geometry: GeometryProxy) -> some View { 39 | Mouth(shape: isShown ? .smile : .neutral) 40 | .stroke(style: .init( 41 | lineWidth: geometry.radius * 1/20, 42 | lineCap: .round, 43 | lineJoin: .round 44 | )) 45 | .frame(width: geometry.radius * 3/5, height: geometry.radius/5) 46 | .position( 47 | x: geometry.frame(in: .local).midX, 48 | y: geometry.frame(in: .local).midY + geometry.radius/2 49 | ) 50 | } 51 | } 52 | 53 | #if DEBUG 54 | struct ClockFaceSmiling_Previews: PreviewProvider { 55 | static var previews: some View { 56 | ZStack { 57 | Circle().strokeBorder() 58 | ClockFaceView() 59 | } 60 | .environment(\.clockFaceShown, true) 61 | } 62 | } 63 | 64 | struct ClockFaceSmilingHideShow_Previews: PreviewProvider { 65 | static var previews: some View { 66 | Group { 67 | Preview() 68 | Preview().previewLayout(.fixed(width: 800, height: 400)) 69 | } 70 | } 71 | 72 | private struct Preview: View { 73 | @State private var isShown = true 74 | 75 | var body: some View { 76 | VStack { 77 | ZStack { 78 | Circle().stroke() 79 | ClockFaceView() 80 | } 81 | .padding() 82 | .environment(\.clockFaceShown, isShown) 83 | Button("Hide/Show", action: { self.isShown.toggle() }) 84 | } 85 | } 86 | } 87 | } 88 | #endif 89 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/ArtNouveauIndicators.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArtNouveauIndicators: View { 4 | @Environment(\.clockConfiguration) var configuration 5 | static let marginRatio: CGFloat = 1/12 6 | 7 | var body: some View { 8 | GeometryReader(content: content) 9 | } 10 | 11 | private func content(geometry: GeometryProxy) -> some View { 12 | ZStack { 13 | ForEach(RomanNumber.numbers(configuration: configuration), id: \.self) { romanNumber in 14 | self.romanHour(for: romanNumber, geometry: geometry) 15 | } 16 | if configuration.isMinuteIndicatorsShown { 17 | Sun() 18 | .stroke() 19 | .padding(2) 20 | } 21 | } 22 | } 23 | 24 | private func romanHour(for romanNumber: String, geometry: GeometryProxy) -> some View { 25 | Text(romanNumber) 26 | .modifier(NumberInCircle(radius: geometry.radius * 1/12)) 27 | .modifier(PositionInCircle( 28 | angle: RomanNumber.angle(for: romanNumber), 29 | marginRatio: Self.marginRatio * 2 30 | )) 31 | .font(.system(size: fontSize(geometry: geometry))) 32 | } 33 | 34 | private func fontSize(geometry: GeometryProxy) -> CGFloat { 35 | (geometry.radius * 1/8).rounded() 36 | } 37 | 38 | private struct NumberInCircle: ViewModifier { 39 | var radius: CGFloat 40 | 41 | func body(content: Content) -> some View { 42 | content 43 | .background(background) 44 | .overlay(overlay) 45 | } 46 | 47 | private var background: some View { 48 | Circle() 49 | .fill(Color.background) 50 | .frame(width: radius * 3, height: radius * 3) 51 | } 52 | 53 | private var overlay: some View { 54 | Circle() 55 | .stroke() 56 | .frame(width: radius * 3, height: radius * 3) 57 | } 58 | } 59 | 60 | private struct Sun: Shape { 61 | func path(in rect: CGRect) -> Path { 62 | var path = Path() 63 | let circle = rect 64 | let startPoint = CGPoint.inCircle(circle, for: .zero, margin: circle.radius * 1/3) 65 | path.move(to: startPoint) 66 | 67 | for minute in 1...60 { 68 | let point = CGPoint.inCircle( 69 | circle, 70 | for: Angle(degrees: Double(minute) * 6), 71 | margin: circle.radius * 1/3 72 | ) 73 | 74 | let control = CGPoint.inCircle( 75 | circle, 76 | for: Angle(degrees: Double(minute) * 6 - 3), 77 | margin: circle.radius * 1/2 78 | ) 79 | 80 | path.addQuadCurve(to: point, control: control) 81 | } 82 | 83 | return path 84 | } 85 | } 86 | } 87 | 88 | #if DEBUG 89 | struct ArtNouveauIndicators_Previews: PreviewProvider { 90 | static var previews: some View { 91 | ZStack { 92 | Circle().strokeBorder() 93 | ArtNouveauIndicators() 94 | } 95 | } 96 | } 97 | #endif 98 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/ArmView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct ArmView: View { 5 | @Environment(\.calendar) var calendar 6 | @Environment(\.clockDate) var date 7 | @Environment(\.clockStyle) var style 8 | @Environment(\.clockArmColors) var colors 9 | let type: ArmType 10 | 11 | var body: some View { 12 | Group { 13 | if style == .artNouveau { 14 | ArtNouveauArm(type: self.type) 15 | } else if style == .drawing { 16 | DrawnArm(type: self.type) 17 | } else if style == .steampunk { 18 | SteampunkArm(type: self.type) 19 | } else { 20 | ClassicArm(type: self.type) 21 | } 22 | } 23 | .modifier(ArmDragGesture(type: type)) 24 | .rotationEffect(rotationAngle) 25 | .animation(.spring(), value: rotationAngle) 26 | .foregroundColor(type.color(with: colors)) 27 | } 28 | 29 | private var rotationAngle: Angle { 30 | type.angle(date: date.wrappedValue, calendar: calendar) 31 | } 32 | } 33 | 34 | #if DEBUG 35 | struct ArmMinute_Previews: PreviewProvider { 36 | @Environment(\.calendar) static var calendar 37 | 38 | static var previews: some View { 39 | ZStack { 40 | Circle().stroke() 41 | ArmView(type: .minute) 42 | } 43 | .padding() 44 | .environment(\.clockDate, .constant(.init(hour: 0, minute: 0, calendar: calendar))) 45 | } 46 | } 47 | 48 | struct ArmHour_Previews: PreviewProvider { 49 | @Environment(\.calendar) static var calendar 50 | 51 | static var previews: some View { 52 | ZStack { 53 | Circle().stroke() 54 | ArmView(type: .hour) 55 | } 56 | .padding() 57 | .environment(\.clockDate, .constant(.init(hour: 0, minute: 0, calendar: calendar))) 58 | } 59 | } 60 | 61 | struct ArmWith25MinuteAngle_Previews: PreviewProvider { 62 | @Environment(\.calendar) static var calendar 63 | 64 | static var previews: some View { 65 | ZStack { 66 | Circle().stroke() 67 | ArmView(type: .minute) 68 | } 69 | .padding() 70 | .environment(\.clockDate, .constant(.init(hour: 0, minute: 25, calendar: calendar))) 71 | } 72 | } 73 | 74 | struct ArtNouveauDesignArm_Previews: PreviewProvider { 75 | @Environment(\.calendar) static var calendar 76 | 77 | static var previews: some View { 78 | ZStack { 79 | Circle().stroke() 80 | ArmView(type: .minute) 81 | } 82 | .padding() 83 | .environment(\.clockDate, .constant(.init(hour: 0, minute: 0, calendar: calendar))) 84 | .environment(\.clockStyle, .artNouveau) 85 | } 86 | } 87 | 88 | struct DrawingDesignArm_Previews: PreviewProvider { 89 | @Environment(\.calendar) static var calendar 90 | 91 | static var previews: some View { 92 | ZStack { 93 | Circle().stroke() 94 | ArmView(type: .minute) 95 | } 96 | .padding() 97 | .environment(\.clockDate, .constant(.init(hour: 0, minute: 0, calendar: calendar))) 98 | .environment(\.clockStyle, .drawing) 99 | } 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Borders/DrawnClockBorder.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DrawnClockBorder: View { 4 | @Environment(\.clockRandom) var random 5 | @Environment(\.clockAnimationEnabled) var isAnimationEnabled 6 | @State private var drawStep: CGFloat = 1 7 | 8 | var body: some View { 9 | DrawnCircle(drawStep: drawStep, random: random) 10 | .strokeBorder(lineWidth: 2) 11 | .onAppear { 12 | guard isAnimationEnabled else { return } 13 | drawStep = 0.01 14 | withAnimation(.easeInOut(duration: 1).delay(0.01)) { 15 | drawStep = 1 16 | } 17 | } 18 | } 19 | } 20 | 21 | struct DrawnCircle: Shape { 22 | private static let marginRatio: CGFloat = 1/80 23 | private static let numberOfArcs = 26 24 | private static let angleRatio: Double = 360/Double(Self.numberOfArcs - 1) 25 | private let maxMarginRatio: CGFloat 26 | private let angleMarginRatio: Double 27 | private var circleStep: CGFloat 28 | 29 | var insetAmount: CGFloat = 0 30 | 31 | init(drawStep: CGFloat, random: Random) { 32 | self.circleStep = drawStep 33 | self.maxMarginRatio = random.borderMarginRatio.maxMargin(Self.marginRatio) 34 | self.angleMarginRatio = random.borderMarginRatio.angleMargin() 35 | } 36 | 37 | var animatableData: CGFloat { 38 | get { self.circleStep } 39 | set { self.circleStep = newValue } 40 | } 41 | 42 | func path(in rect: CGRect) -> Path { 43 | var path = Path() 44 | let rect = rect.insetBy(dx: insetAmount, dy: insetAmount) 45 | path.move(to: .inCircle(rect, for: .zero)) 46 | addArcs(to: &path, rect: rect) 47 | return path.trimmedPath(from: 0, to: self.circleStep) 48 | } 49 | 50 | private func addArcs(to path: inout Path, rect: CGRect) { 51 | let margin = rect.width * self.maxMarginRatio 52 | for i in 1...Self.numberOfArcs { 53 | let angle = Angle(degrees: Double(i) * Self.angleRatio) 54 | let to = CGPoint.inCircle(rect, for: angle, margin: margin) 55 | 56 | let controlAngle = Angle(degrees: angle.degrees - self.angleMarginRatio * Self.angleRatio) 57 | let control = CGPoint.inCircle(rect, for: controlAngle, margin: margin) 58 | path.addQuadCurve(to: to, control: control) 59 | } 60 | } 61 | } 62 | 63 | extension DrawnCircle: InsettableShape { 64 | func inset(by amount: CGFloat) -> some InsettableShape { 65 | var circle = self 66 | circle.insetAmount += amount 67 | return circle 68 | } 69 | } 70 | 71 | #if DEBUG 72 | struct DrawnClockBorder_Previews: PreviewProvider { 73 | static var previews: some View { 74 | DrawnClockBorder() 75 | } 76 | } 77 | 78 | struct DrawnCircleAnimation_Previews: PreviewProvider { 79 | static var previews: some View { 80 | Preview() 81 | } 82 | 83 | private struct Preview: View { 84 | @Environment(\.clockRandom) var random 85 | @State private var drawStep: CGFloat = 1 86 | 87 | var body: some View { 88 | VStack { 89 | DrawnCircle(drawStep: drawStep, random: random).strokeBorder(lineWidth: 2) 90 | Slider(value: $drawStep) 91 | } 92 | } 93 | } 94 | } 95 | #endif 96 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/ClassicIndicators.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClassicIndicators: View { 4 | @Environment(\.clockConfiguration) var configuration 5 | private static let marginRatio: CGFloat = 2/7 6 | 7 | var body: some View { 8 | ZStack { 9 | HourTexts(marginRatio: Self.marginRatio) 10 | if configuration.isHourIndicatorsShown { 11 | HourIndicators(marginRatio: Self.marginRatio) 12 | } 13 | if configuration.isMinuteIndicatorsShown { 14 | MinuteIndicators(marginRatio: Self.marginRatio) 15 | } 16 | } 17 | } 18 | } 19 | 20 | private struct HourTexts: View { 21 | @Environment(\.clockConfiguration) var configuration 22 | private static let hours = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 23 | private static let limitedHours = [12, 3, 6, 9] 24 | private static let textFontRatio: CGFloat = 1/5 25 | let marginRatio: CGFloat 26 | 27 | var body: some View { 28 | ForEach(self.configurationHours, id: \.self) { hour in 29 | Text("\(hour)") 30 | .modifier(PositionInCircle( 31 | angle: .degrees(Double(hour) * .hourInDegree), 32 | marginRatio: self.dynamicMarginRatio 33 | )) 34 | .modifier(FontProportional(ratio: Self.textFontRatio)) 35 | } 36 | } 37 | 38 | private var configurationHours: [Int] { 39 | configuration.isLimitedHoursShown ? Self.limitedHours : Self.hours 40 | } 41 | 42 | private var dynamicMarginRatio: CGFloat { 43 | configuration.isMinuteIndicatorsShown || configuration.isHourIndicatorsShown 44 | ? self.marginRatio 45 | : self.marginRatio/2 46 | } 47 | } 48 | 49 | private struct HourIndicators: View { 50 | private static let hourDotRatio: CGFloat = 2/35 51 | let marginRatio: CGFloat 52 | 53 | var body: some View { 54 | GeometryReader(content: content) 55 | } 56 | 57 | private func content(geometry: GeometryProxy) -> some View { 58 | ForEach(1..<13) { hour in 59 | Circle() 60 | .frame(width: geometry.radius * Self.hourDotRatio) 61 | .modifier(PositionInCircle( 62 | angle: .degrees(Double(hour) * .hourInDegree), 63 | marginRatio: self.marginRatio/3 64 | )) 65 | } 66 | } 67 | } 68 | 69 | private struct MinuteIndicators: View { 70 | private static let minuteDotRatio: CGFloat = 1/35 71 | let marginRatio: CGFloat 72 | 73 | var body: some View { 74 | GeometryReader(content: content) 75 | } 76 | 77 | private func content(geometry: GeometryProxy) -> some View { 78 | ForEach(1..<61) { minute in 79 | Circle() 80 | .frame(width: geometry.radius * Self.minuteDotRatio) 81 | .modifier(PositionInCircle( 82 | angle: .degrees(Double(minute) * .minuteInDegree), 83 | marginRatio: self.marginRatio/3 84 | )) 85 | } 86 | } 87 | } 88 | 89 | #if DEBUG 90 | struct ClassicIndicators_Previews: PreviewProvider { 91 | static var previews: some View { 92 | ZStack { 93 | ClassicIndicators() 94 | Circle().stroke() 95 | }.padding() 96 | } 97 | } 98 | #endif 99 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/Plate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Plate: View { 4 | static let lineWidth: CGFloat = 6 5 | let type: PlateType 6 | let text: String 7 | @State private var rect: CGRect = .zero 8 | 9 | var body: some View { 10 | ZStack { 11 | if type == .hard { 12 | Circle().fill(Color.background) 13 | Circle().stroke(lineWidth: Self.lineWidth) 14 | } else { 15 | Circle() 16 | .scale(10/12) 17 | .fill(Color.background) 18 | } 19 | Circle() 20 | .stroke(lineWidth: Self.lineWidth) 21 | .scale(10/12) 22 | rivets 23 | Text(text) 24 | .font(.system(size: rect.radius.rounded(), design: .serif)) 25 | } 26 | .background(GeometryReader { geometry in 27 | Color.clear.preference(key: RectPreferenceKey.self, value: geometry.circle) 28 | }) 29 | .onPreferenceChange(RectPreferenceKey.self) { rect = $0 } 30 | } 31 | 32 | private var rivets: some View { 33 | Group { 34 | if self.type == .soft { 35 | SoftRivets() 36 | } else { 37 | HardRivets() 38 | } 39 | } 40 | } 41 | } 42 | 43 | extension Plate { 44 | enum PlateType { 45 | case hard, soft 46 | } 47 | } 48 | 49 | private struct SoftRivets: Shape { 50 | func path(in rect: CGRect) -> Path { 51 | var path = Path() 52 | let radius = rect.radius * 1/20 53 | 54 | let center1 = CGPoint.inCircle( 55 | rect, 56 | for: .radians(7/4 * .pi), 57 | margin: rect.radius * 40/100 58 | ) 59 | let center2 = CGPoint.inCircle( 60 | rect, 61 | for: .radians(3/4 * .pi), 62 | margin: rect.radius * 40/100 63 | ) 64 | path.addCircle(CGRect.circle(center: center1, radius: radius)) 65 | path.addCircle(CGRect.circle(center: center2, radius: radius)) 66 | 67 | return path 68 | } 69 | } 70 | 71 | private struct HardRivets: Shape { 72 | func path(in rect: CGRect) -> Path { 73 | var path = Path() 74 | let radius = rect.radius * 1/20 75 | for rivet in 0..<20 { 76 | let rivet = CGFloat(rivet) 77 | let center = CGPoint.inCircle( 78 | rect, 79 | for: .radians(.pi/10 * rivet), 80 | margin: rect.radius * 1/12 81 | ) 82 | let circleRect = CGRect.circle(center: center, radius: radius) 83 | path.addCircle(circleRect) 84 | } 85 | return path 86 | } 87 | } 88 | 89 | private struct RectPreferenceKey: PreferenceKey { 90 | static var defaultValue: CGRect = .zero 91 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) {} 92 | } 93 | 94 | #if DEBUG 95 | struct PlateSoftI_Previews: PreviewProvider { 96 | static var previews: some View { 97 | Plate(type: .soft, text: "I") 98 | .padding(1) 99 | } 100 | } 101 | 102 | struct PlateHardXII_Previews: PreviewProvider { 103 | static var previews: some View { 104 | Plate(type: .hard, text: "XII") 105 | .padding(4) 106 | } 107 | } 108 | #endif 109 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/SteampunkHourArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SteampunkHourArm: Shape { 4 | func path(in rect: CGRect) -> Path { 5 | let thickness = rect.radius * 1/30 6 | let middleHoleCenter = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/4 - thickness) 7 | 8 | var path = Path() 9 | addBottomHole(to: &path, rect: rect) 10 | addMiddleHole(to: &path, rect: rect) 11 | addRectangle(to: &path, rect: rect) 12 | addArrow(to: &path, rect: rect) 13 | path.addVerticalMirror(in: rect) 14 | 15 | path.addCircle(CGRect.circle(center: rect.center, radius: rect.radius * 1/15)) 16 | path.addCircle(CGRect.circle(center: middleHoleCenter, radius: rect.radius * 1/30)) 17 | 18 | return path 19 | } 20 | 21 | private func addBottomHole(to path: inout Path, rect: CGRect) { 22 | let thickness = rect.radius * 1/30 23 | let endPoint = CGPoint(x: rect.midX + thickness/2, y: rect.midY - rect.radius * 1/15 - thickness) 24 | path.addArc( 25 | center: rect.center, 26 | radius: rect.radius * 1/15 + thickness, 27 | startAngle: .fullRound * 1/4, 28 | endAngle: .inCircle(for: endPoint, circleCenter: rect.center), 29 | clockwise: true 30 | ) 31 | } 32 | 33 | private func addMiddleHole(to path: inout Path, rect: CGRect) { 34 | let thickness = rect.radius * 1/30 35 | let circle = CGRect.circle( 36 | center: CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/4 - thickness), 37 | radius: rect.radius * 1/30 + thickness 38 | ) 39 | let startPoint = CGPoint(x: rect.midX + thickness/2, y: circle.maxY) 40 | let endPoint = CGPoint(x: rect.midX + thickness/2, y: circle.minY) 41 | path.addArc( 42 | center: circle.center, 43 | radius: circle.radius, 44 | startAngle: .inCircle(for: startPoint, circleCenter: circle.center), 45 | endAngle: .inCircle(for: endPoint, circleCenter: circle.center), 46 | clockwise: true 47 | ) 48 | } 49 | 50 | func addRectangle(to path: inout Path, rect: CGRect) { 51 | let thickness = rect.radius * 1/30 52 | let y = rect.midY - rect.radius * 4/9 53 | 54 | path.addLine(to: CGPoint(x: rect.midX + thickness/2, y: y)) 55 | path.addLine(to: CGPoint(x: rect.midX + thickness * 2, y: y)) 56 | path.addLine(to: CGPoint(x: rect.midX + thickness * 2, y: y - thickness)) 57 | path.addLine(to: CGPoint(x: rect.midX + thickness/2, y: y - thickness)) 58 | } 59 | 60 | func addArrow(to path: inout Path, rect: CGRect) { 61 | let thickness = rect.radius * 1/30 62 | let bottomY = rect.midY - rect.radius * 49/90 63 | let middleY = rect.midY - rect.radius * 41/72 64 | let topY = rect.midY - rect.radius * 25/36 65 | path.addLine(to: CGPoint(x: rect.midX + thickness/2, y: bottomY)) 66 | path.addLine(to: CGPoint(x: rect.midX + thickness * 2, y: middleY)) 67 | path.addLine(to: CGPoint(x: rect.midX, y: topY)) 68 | } 69 | } 70 | 71 | struct SteampunkHourArm_Previews: PreviewProvider { 72 | static var previews: some View { 73 | ZStack { 74 | Circle().stroke() 75 | SteampunkHourArm().fill(style: .init(eoFill: true, antialiased: true)) 76 | } 77 | .padding() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Face/Eye.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ClockFaceView { 4 | struct Eye: View { 5 | let move: Bool 6 | let position: Position 7 | 8 | var body: some View { 9 | GeometryReader(content: content) 10 | } 11 | 12 | private func content(geometry: GeometryProxy) -> some View { 13 | ZStack { 14 | Circle().stroke(lineWidth: geometry.radius * 1/6) 15 | ClockFaceView.Iris(move: move, position: position).fill() 16 | } 17 | } 18 | } 19 | 20 | private struct Iris: Shape { 21 | let position: Position 22 | private var animationStep: Double 23 | 24 | var animatableData: Double { 25 | get { self.animationStep } 26 | set { self.animationStep = newValue } 27 | } 28 | 29 | init(move: Bool, position: Position) { 30 | self.animationStep = move ? 1 : 0 31 | self.position = position 32 | } 33 | 34 | func path(in rect: CGRect) -> Path { 35 | let width = rect.radius/4 36 | let animationStep = CGFloat(self.animationStep) 37 | 38 | let directionCenter = CGPoint.inCircle(rect, for: position.angle, margin: width) 39 | 40 | let center = rect.center 41 | let eyeCenter = CGPoint( 42 | x: (1 - animationStep) * center.x + directionCenter.x * animationStep, 43 | y: (1 - animationStep) * center.y + directionCenter.y * animationStep 44 | ) 45 | 46 | let iris = CGRect.circle( 47 | center: eyeCenter, 48 | radius: width 49 | ) 50 | 51 | var path = Path() 52 | path.addEllipse(in: iris) 53 | return path 54 | } 55 | } 56 | 57 | enum Position { 58 | case left 59 | case right 60 | 61 | var angle: Angle { 62 | switch self { 63 | case .left: return .radians(.pi * 3/4) 64 | case .right: return .radians(-.pi * 3/4) 65 | } 66 | } 67 | } 68 | } 69 | 70 | #if DEBUG 71 | struct Eye_Previews: PreviewProvider { 72 | static var previews: some View { 73 | VStack { 74 | ClockFaceView.Eye(move: false, position: .left).padding(2) 75 | HStack { 76 | ClockFaceView.Eye(move: true, position: .left).padding(2) 77 | ClockFaceView.Eye(move: true, position: .right).padding(2) 78 | } 79 | } 80 | .padding(4) 81 | } 82 | } 83 | 84 | struct EyeWithMove_Previews: PreviewProvider { 85 | static var previews: some View { 86 | Preview() 87 | } 88 | 89 | struct Preview: View { 90 | @State private var move = true 91 | 92 | var body: some View { 93 | VStack { 94 | ClockFaceView.Eye(move: false, position: .left).padding() 95 | ClockFaceView.Eye(move: move, position: .left).padding() 96 | ClockFaceView.Eye(move: move, position: .right).padding() 97 | Button( 98 | action: { withAnimation { self.move.toggle() } }, 99 | label: { 100 | Text("Move eyes") 101 | } 102 | ) 103 | } 104 | .padding() 105 | } 106 | } 107 | } 108 | #endif 109 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/SteampunkIndicators.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SteampunkIndicators: View { 4 | @Environment(\.clockConfiguration) var configuration 5 | 6 | @State private var animationRotationAngle: Angle = .zero 7 | 8 | var body: some View { 9 | ZStack { 10 | ZStack { 11 | Cogwheel(angle: animationRotationAngle).scale(56/100).stroke() 12 | ZStack { 13 | Moon().scale(70/100).fill(Color.background) 14 | Moon().scale(70/100).stroke() 15 | } 16 | gears().mask(Moon().scale(70/100)) 17 | } 18 | circles() 19 | numbers 20 | } 21 | .onAppear { 22 | guard animationRotationAngle == .zero else { return } 23 | DispatchQueue.main.async { 24 | withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) { 25 | animationRotationAngle += .fullRound 26 | } 27 | } 28 | } 29 | } 30 | 31 | private func circles() -> some View { 32 | ZStack { 33 | Circle().scale(21/25).stroke() 34 | Circle().scale(20/25).stroke() 35 | } 36 | } 37 | 38 | private var numbers: some View { 39 | ForEach(RomanNumber.numbers(configuration: configuration), id: \.self) { romanNumber in 40 | Plate(type: self.plateType(for: romanNumber), text: romanNumber) 41 | .scaleEffect(3/19) 42 | .modifier( 43 | PositionInCircle(angle: RomanNumber.angle(for: romanNumber), marginRatio: 1/5) 44 | ) 45 | } 46 | } 47 | 48 | private func plateType(for romanNumber: String) -> Plate.PlateType { 49 | RomanNumber.limitedNumbers.contains(romanNumber) ? .hard : .soft 50 | } 51 | 52 | private func gears() -> some View { 53 | Cogwheels( 54 | data: [ 55 | .init( 56 | cogwheel: (toothCount: 12, armCount: 3), 57 | relativeOffset: (x: 11.0/100, y: 61.0/100), 58 | scale: 1.0/5 59 | ), 60 | .init( 61 | cogwheel: (toothCount: 8, armCount: 5), 62 | relativeOffset: (x: 26.0/100, y: 71.0/100), 63 | scale: 1.0/6, 64 | isClockwise: false 65 | ), 66 | .init( 67 | cogwheel: (toothCount: 12, armCount: 8), 68 | relativeOffset: (x: 38.0/100, y: 87.0/100), 69 | scale: 1.0/4 70 | ), 71 | ], 72 | angle: animationRotationAngle 73 | ).scale(70/100).stroke() 74 | } 75 | } 76 | 77 | struct SteampunkIndicators_Previews: PreviewProvider { 78 | static var previews: some View { 79 | ZStack { 80 | Circle().stroke() 81 | SteampunkIndicators() 82 | }.padding() 83 | } 84 | } 85 | 86 | struct SteampunkIndicatorsWithLimitedHours_Previews: PreviewProvider { 87 | static var customClockConfiguration = ClockConfiguration( 88 | isLimitedHoursShown: true, 89 | isMinuteIndicatorsShown: true, 90 | isHourIndicatorsShown: true 91 | ) 92 | 93 | static var previews: some View { 94 | ZStack { 95 | Circle().stroke() 96 | SteampunkIndicators().environment(\.clockConfiguration, customClockConfiguration) 97 | }.padding() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/ArmDragGesture.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArmDragGesture: ViewModifier { 4 | @Environment(\.calendar) var calendar 5 | @Environment(\.clockDate) var date 6 | @GestureState private var dragAngle: Angle = .zero 7 | let type: ArmType 8 | 9 | func body(content: Content) -> some View { 10 | GeometryReader { geometry in 11 | content 12 | .contentShape(self.contentShape(geometry: geometry)) 13 | .gesture(self.dragGesture(geometry: geometry)) 14 | .rotationEffect(self.dragAngle) 15 | .animation(self.dragAngle == .zero ? .spring() : nil, value: self.dragAngle) 16 | } 17 | .aspectRatio(1, contentMode: .fit) 18 | } 19 | 20 | private func contentShape(geometry: GeometryProxy) -> Path { 21 | Rectangle().path(in: CGRect( 22 | x: geometry.frame(in: .local).midX - geometry.size.width/12, 23 | y: geometry.frame(in: .local).minY, 24 | width: geometry.size.width/6, 25 | height: geometry.size.height/1.9 26 | )) 27 | } 28 | 29 | private func dragGesture(geometry: GeometryProxy) -> some Gesture { 30 | DragGesture(coordinateSpace: .global) 31 | .updating($dragAngle, body: { value, state, _ in 32 | let extraRotationAngle = angle( 33 | dragGestureValue: value, 34 | frame: geometry.frame(in: .global) 35 | ) 36 | state = extraRotationAngle - self.currentAngle 37 | }) 38 | .onEnded({ value in 39 | let angle = angle(dragGestureValue: value, frame: geometry.frame(in: .global)) 40 | setAngle(angle) 41 | }) 42 | } 43 | 44 | private var currentAngle: Angle { 45 | type.angle(date: date.wrappedValue, calendar: calendar) 46 | } 47 | 48 | private func angle(dragGestureValue: DragGesture.Value, frame: CGRect) -> Angle { 49 | let radius = frame.size.width/2 50 | let location = ( 51 | x: dragGestureValue.location.x - radius - frame.origin.x, 52 | y: dragGestureValue.location.y - radius - frame.origin.y 53 | ) 54 | #if os(macOS) 55 | let arctan = atan2(location.x, location.y) 56 | #else 57 | let arctan = atan2(location.x, -location.y) 58 | #endif 59 | let positiveRadians = arctan >= 0 ? arctan : arctan + 2 * .pi 60 | return Angle(radians: Double(positiveRadians)) 61 | } 62 | 63 | private func setAngle(_ angle: Angle) { 64 | type.setAngle(angle, date: &date.wrappedValue, calendar: calendar) 65 | } 66 | } 67 | 68 | #if DEBUG 69 | struct ArmDragGesture_Previews: PreviewProvider { 70 | static var previews: some View { 71 | Group { 72 | Preview() 73 | VStack { 74 | Spacer(minLength: 300) 75 | Preview() 76 | Spacer(minLength: 100) 77 | } 78 | } 79 | } 80 | 81 | private struct Preview: View { 82 | @Environment(\.calendar) var calendar 83 | @State private var date = Date.init(hour: 0, minute: 0, calendar: .current) 84 | let type = ArmType.minute 85 | 86 | var body: some View { 87 | VStack { 88 | ClassicArm(type: .minute) 89 | .modifier(ArmDragGesture(type: ArmType.minute)) 90 | .rotationEffect(rotationAngle) 91 | .background(Color.red.opacity(10/100)) 92 | Text("minute: \(calendar.component(.minute, from: date))") 93 | }.environment(\.clockDate, $date) 94 | } 95 | 96 | private var rotationAngle: Angle { 97 | type.angle(date: date, calendar: calendar) 98 | } 99 | } 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/WindUpKey.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WindUpKey: Shape, Animatable { 4 | var animationStep: Double 5 | 6 | var animatableData: Double { 7 | get { animationStep } 8 | set { animationStep = newValue } 9 | } 10 | 11 | func path(in rect: CGRect) -> Path { 12 | var path = Path() 13 | addOutline(to: &path, rect: rect) 14 | addCircles(to: &path, rect: rect) 15 | return path.applying(CGAffineTransform( 16 | a: 1 - animationStep * 2, b: 0, 17 | c: 0, d: 1, 18 | tx: animationStep * 2 * rect.midX, ty: 0 19 | )) 20 | } 21 | 22 | private func addOutline(to path: inout Path, rect: CGRect) { 23 | let thickness = rect.radius * 1/6 24 | let holeRadius = rect.radius * 1/3 25 | let holeCenterY = holeRadius/2 + rect.radius/2 + thickness 26 | let leftHole = CGRect.circle(center: CGPoint(x: rect.radius * 1/2, y: holeCenterY), radius: holeRadius) 27 | let rightHole = CGRect.circle(center: CGPoint(x: rect.radius * 3/2, y: holeCenterY), radius: holeRadius) 28 | let topHole = CGRect.circle(center: CGPoint(x: rect.radius, y: holeRadius/2 + thickness), radius: holeRadius/2) 29 | 30 | path.move(to: CGPoint(x: rect.width * 1/3, y: rect.maxY)) 31 | path.addLine(to: CGPoint(x: rect.width * 1/3, y: leftHole.maxY + thickness)) 32 | path.addArc( 33 | center: leftHole.center, 34 | radius: leftHole.radius + thickness, 35 | startAngle: .radians(.pi/2), 36 | endAngle: .radians(.pi * 3/2), 37 | clockwise: false 38 | ) 39 | path.addArc( 40 | center: topHole.center, 41 | radius: topHole.radius + thickness, 42 | startAngle: .radians(.pi), 43 | endAngle: .zero, 44 | clockwise: false 45 | ) 46 | path.addArc( 47 | center: rightHole.center, 48 | radius: rightHole.radius + thickness, 49 | startAngle: .radians(.pi * 3/2), 50 | endAngle: .radians(.pi/2), 51 | clockwise: false 52 | ) 53 | path.addLine(to: CGPoint(x: rect.width * 2/3, y: rightHole.maxY + thickness)) 54 | path.addLine(to: CGPoint(x: rect.width * 2/3, y: rect.maxY)) 55 | path.closeSubpath() 56 | } 57 | 58 | private func addCircles(to path: inout Path, rect: CGRect) { 59 | let thickness = rect.radius * 1/6 60 | let holeRadius = rect.radius * 1/3 61 | let holeCenterY = holeRadius/2 + rect.radius/2 + thickness 62 | let leftHole = CGRect.circle(center: CGPoint(x: rect.radius * 1/2, y: holeCenterY), radius: holeRadius) 63 | let rightHole = CGRect.circle(center: CGPoint(x: rect.radius * 3/2, y: holeCenterY), radius: holeRadius) 64 | let topHole = CGRect.circle(center: CGPoint(x: rect.radius, y: holeRadius/2 + thickness), radius: holeRadius/2) 65 | 66 | path.addCircle(leftHole) 67 | path.addCircle(rightHole) 68 | path.addCircle(topHole) 69 | } 70 | } 71 | 72 | #if DEBUG 73 | struct WindUpKey_Previews: PreviewProvider { 74 | static var previews: some View { 75 | WindUpKey(animationStep: .zero) 76 | .stroke() 77 | .padding(1) 78 | } 79 | } 80 | 81 | struct WindUpKeyWithSteps_Previews: PreviewProvider { 82 | static var previews: some View { 83 | Preview() 84 | } 85 | 86 | private struct Preview: View { 87 | @State private var animationStep: Double = 0 88 | 89 | var body: some View { 90 | VStack { 91 | WindUpKey(animationStep: animationStep).stroke() 92 | 93 | Text(String(format: "%.f", animationStep * 100)) 94 | 95 | Slider(value: $animationStep, in: 0...1) 96 | } 97 | .padding() 98 | } 99 | } 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Arm/DrawnArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DrawnArm: View { 4 | @Environment(\.clockRandom) var random 5 | @Environment(\.clockAnimationEnabled) var isAnimationEnabled 6 | private static let widthRatio: CGFloat = 1/20 7 | let type: ArmType 8 | @State private var drawStep: CGFloat = 1 9 | 10 | var body: some View { 11 | DrawnArmShape(type: type, drawStep: drawStep, controlRatios: .init(random: self.random)) 12 | .onAppear { 13 | guard isAnimationEnabled else { return } 14 | self.drawStep = 0.01 15 | withAnimation(Animation.easeInOut.delay(0.01)) { 16 | self.drawStep = 1 17 | } 18 | } 19 | } 20 | } 21 | 22 | private struct DrawnArmShape: Shape { 23 | private static let thicknessRatio: CGFloat = 1/30 24 | let type: ArmType 25 | var drawStep: CGFloat 26 | let controlRatios: Random.ControlRatio 27 | 28 | var animatableData: CGFloat { 29 | get { self.drawStep } 30 | set { self.drawStep = newValue } 31 | } 32 | 33 | func path(in rect: CGRect) -> Path { 34 | var path = Path() 35 | let thickness = rect.radius * Self.thicknessRatio * type.ratio.lineWidth 36 | let margin = rect.radius * type.ratio.margin 37 | 38 | path.addArc( 39 | center: rect.center, 40 | radius: thickness, 41 | startAngle: .degrees(90), 42 | endAngle: .zero, 43 | clockwise: true 44 | ) 45 | 46 | let top = CGPoint( 47 | x: rect.midX, 48 | y: rect.midY - rect.radius * drawStep + margin * drawStep 49 | ) 50 | 51 | let control1 = CGPoint( 52 | x: rect.midX + thickness - thickness * 2 * controlRatios.leftX, 53 | y: top.y + rect.radius * drawStep * controlRatios.leftY 54 | ) 55 | let control2 = CGPoint( 56 | x: rect.midX + thickness + thickness * 2 * controlRatios.leftX, 57 | y: top.y + rect.radius * drawStep * controlRatios.leftY/2 58 | ) 59 | 60 | path.addCurve( 61 | to: CGPoint(x: rect.midX + thickness, y: top.y), 62 | control1: control1, 63 | control2: control2 64 | ) 65 | 66 | path.addArc( 67 | center: top, 68 | radius: thickness, 69 | startAngle: .zero, 70 | endAngle: .degrees(270), 71 | clockwise: true 72 | ) 73 | 74 | path.addVerticalMirror(in: rect) 75 | 76 | return path 77 | } 78 | } 79 | 80 | #if DEBUG 81 | struct DrawnArm_Previews: PreviewProvider { 82 | private struct Preview: View { 83 | @State private var redo: Bool = true 84 | 85 | var body: some View { 86 | VStack { 87 | ZStack { 88 | Circle().stroke() 89 | if redo { 90 | DrawnArm(type: .minute) 91 | } 92 | } 93 | .padding() 94 | 95 | Button("Redo animation") { 96 | redo = false 97 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 98 | redo = true 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | static var previews: some View { 106 | Preview() 107 | } 108 | } 109 | 110 | struct DrawnArmAnimated_Previews: PreviewProvider { 111 | static var previews: some View { 112 | Preview() 113 | } 114 | 115 | private struct Preview: View { 116 | @State private var drawStep: CGFloat = 1 117 | 118 | var body: some View { 119 | VStack { 120 | ZStack { 121 | Circle().stroke() 122 | DrawnArmShape( 123 | type: .minute, 124 | drawStep: drawStep, 125 | controlRatios: .init(random: .fixed) 126 | ) 127 | } 128 | Slider(value: $drawStep) 129 | }.padding() 130 | } 131 | } 132 | } 133 | #endif 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftClockUI 2 | 3 | ![Xcode Unit Test](https://github.com/renaudjenny/SwiftClockUI/workflows/Xcode%20Unit%20Test/badge.svg) 4 | 5 | Clock UI for SwiftUI 6 | 7 | This library has been tested 8 | * ✅💻 macOS Catalina 10.15.3 9 | * ✅💻 macOS Big Sur 11.6 10 | * ✅📱 iOS 13 11 | * ✅📱 iOS 14 12 | * ✅📱 iOS 15 13 | 14 | *For compatibility with Xcode version older than 13.3, I would recommend to checkout the 1.4.x tag, it should compile with Xcode 11 and greater* 15 | 16 | ## Bind a date 17 | 18 | ```swift 19 | struct ContentView: View { 20 | @State private var date = Date() 21 | 22 | var body: some View { 23 | ClockView().environment(\.clockDate, $date) 24 | } 25 | } 26 | ``` 27 | 28 | Simply set `.environment(\.clockDate, $date)` `$date` has to be a binding. 29 | If you want something constant (just for showing the time), you could pass `.constant(yourDate)` 30 | 31 | * Arms move when date are set (take hour and minute in account) 32 | * Move the Arms change the date (hour and minute depending on which arm you've moved) 33 | 34 | ## Change Clock style 35 | 36 | There is 4 different clock style: 37 | 38 | Style | Picture 39 | ------------ | ------------- 40 | Classic | ![Clock View with Classic style](docs/assets/ClockViewClassic.png) 41 | Art Nouveau | ![Clock View with Art Nouveau style](docs/assets/ClockViewArtNouveau.png) 42 | Drawing | ![Clock View with Drawing style](docs/assets/ClockViewDrawing.png) 43 | Steampunk | ![Clock View with Steampunk style](docs/assets/ClockViewSteampunk.png) 44 | 45 | To set the style: `.environment(\.clockStyle, .steampunk)` for Steampunk style for instance. 46 | 47 | ```swift 48 | struct ContentView: View { 49 | @State private var clockStyle: ClockStyle = .classic 50 | 51 | var body: some View { 52 | ClockView().environment(\.clockStyle, clockStyle) 53 | } 54 | } 55 | ``` 56 | 57 | `\.clockStyle` is typed as `enum ClockStyle` which is `Identifiable`, `CaseIterable`, and has a convenient method to get the description (in English): `public var description: String` 58 | 59 | It's very useful when you want to iterate over this `enum` to let the user choose the clock style, for instance you can easily do something like this: 60 | 61 | ```swift 62 | struct StylePicker: View { 63 | @Binding var clockStyle: ClockStyle 64 | 65 | var body: some View { 66 | Picker("Style", selection: clockStyle) { 67 | ForEach(ClockStyle.allCases) { style in 68 | Text(style.description).tag(style) 69 | } 70 | } 71 | .pickerStyle(SegmentedPickerStyle()) 72 | } 73 | } 74 | ``` 75 | 76 | ## Change elements color 77 | 78 | You can also change the color of Clock elements. Again with changing some `.environment` keys. 79 | 80 | ```swift 81 | ClockView() 82 | .environment(\.clockArmColors, ClockArmColors( 83 | minute: .red, 84 | hour: .blue 85 | )) 86 | .environment(\.clockBorderColor, .orange) 87 | .environment(\.clockIndicatorsColor, .green) 88 | ``` 89 | 90 | In light mode, you could expect a result like this: 91 | 92 | ![Clock View with Classic style and some colors changed](docs/assets/ClockViewClassicAndColors.png) 93 | 94 | ## Installation 95 | 96 | ### Xcode 97 | 98 | You can add SwiftToTen to an Xcode project by adding it as a package dependency. 99 | 100 | 1. From the **File** menu, select **Swift Packages › Add Package Dependency...** 101 | 2. Enter "https://github.com/renaudjenny/SwiftClockUI" into the package repository URL test field 102 | 103 | ### As package dependency 104 | 105 | Edit your `Package.swift` to add this library. 106 | 107 | ```swift 108 | let package = Package( 109 | ... 110 | dependencies: [ 111 | .package(url: "https://github.com/renaudjenny/SwiftClockUI", from: "2.0.0"), 112 | ... 113 | ], 114 | targets: [ 115 | .target( 116 | name: "", 117 | dependencies: ["SwiftClockUI"]), 118 | ... 119 | ] 120 | ) 121 | ``` 122 | 123 | ## App using this library 124 | 125 | * [📲 Tell Time UK](https://apps.apple.com/gb/app/tell-time-uk/id1496541173): https://github.com/renaudjenny/telltime 126 | 127 | ## For maintainers 128 | 129 | If you want to help maintaining this library, I would suggest to add this **git hooks** on `pre-commit` 130 | 131 | In a terminal opened in the repo folder, executes these commands 132 | 133 | ```bash 134 | echo '#!/bin/sh' > .git/hooks/pre-commit 135 | echo '' >> .git/hooks/pre-commit 136 | echo 'swiftlint' >> .git/hooks/pre-commit 137 | chmod +x .git/hooks/pre-commit 138 | ``` 139 | -------------------------------------------------------------------------------- /Tests/SwiftClockUITests/Extensions/CGPoint+CircleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftClockUI 3 | import SwiftUI 4 | 5 | class CGPointExtensionCircleTests: XCTestCase { 6 | func testCGPointExtensionPointInCircleFromAngleZero() { 7 | let circle = CGRect.circle(center: .zero, radius: 50) 8 | let angle = Angle(degrees: 0) 9 | let expectedPoint = CGPoint(x: circle.midX, y: circle.minY) 10 | 11 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle) 12 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 13 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 14 | } 15 | 16 | func testCGPointExtensionPointInCircleFromAngleNinetyDegrees() { 17 | let circle = CGRect.circle(center: .zero, radius: 50) 18 | let angle = Angle(degrees: 90) 19 | let expectedPoint = CGPoint(x: circle.maxX, y: circle.midY) 20 | 21 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle) 22 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 23 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 24 | } 25 | 26 | func testCGPointExtensionPointInCircleFromAngleTwoHundredSeventyDegrees() { 27 | let circle = CGRect.circle(center: .zero, radius: 50) 28 | let angle = Angle(degrees: 270) 29 | let expectedPoint = CGPoint(x: circle.minX, y: circle.midY) 30 | 31 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle) 32 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 33 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 34 | } 35 | 36 | func testCGPointExtensionPointInCircleFromAngleFortyFiveDegrees() { 37 | let circle = CGRect.circle(center: .zero, radius: 50) 38 | let angle = Angle(degrees: 45) 39 | 40 | // https://stackoverflow.com/questions/839899/how-do-i-calculate-a-point-on-a-circle-s-circumference 41 | // The parametric equation for a circle is 42 | // https://en.wikipedia.org/wiki/Circle#Equations 43 | // x = originX + radius * cos(angle) 44 | // y = originY + radius * sin(angle) 45 | 46 | let radians = CGFloat(angle.radians) - .pi/2 47 | let cosAngle = cos(radians) 48 | let sinAngle = sin(radians) 49 | 50 | let expectedPoint = CGPoint( 51 | x: circle.midX + circle.radius * cosAngle, 52 | y: circle.midY + circle.radius * sinAngle 53 | ) 54 | 55 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle) 56 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 57 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 58 | } 59 | 60 | func testCGPointExtensionPointInCircleFromAngleFortyFiveDegreesWithMargin() { 61 | let circle = CGRect.circle(center: .zero, radius: 50) 62 | let margin: CGFloat = 15.0 63 | let angle = Angle(degrees: 45) 64 | 65 | // https://stackoverflow.com/questions/839899/how-do-i-calculate-a-point-on-a-circle-s-circumference 66 | // The parametric equation for a circle is 67 | // https://en.wikipedia.org/wiki/Circle#Equations 68 | // x = originX + radius * cos(angle) 69 | // y = originY + radius * sin(angle) 70 | 71 | let radians = CGFloat(angle.radians) - .pi/2 72 | let cosAngle = cos(radians) 73 | let sinAngle = sin(radians) 74 | 75 | let expectedPoint = CGPoint( 76 | x: circle.midX + (circle.radius - margin) * cosAngle, 77 | y: circle.midY + (circle.radius - margin) * sinAngle 78 | ) 79 | 80 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle, margin: margin) 81 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 82 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 83 | } 84 | 85 | func testCGPointExtensionPointInCircleFromAngleFortyFiveDegreesWithMarginRecentered() { 86 | let decenteredCenter = CGPoint(x: 20, y: 30) 87 | let circle = CGRect.circle(center: decenteredCenter, radius: 50) 88 | let margin: CGFloat = 15.0 89 | let angle = Angle(degrees: 45) 90 | 91 | // https://stackoverflow.com/questions/839899/how-do-i-calculate-a-point-on-a-circle-s-circumference 92 | // The parametric equation for a circle is 93 | // https://en.wikipedia.org/wiki/Circle#Equations 94 | // x = originX + radius * cos(angle) 95 | // y = originY + radius * sin(angle) 96 | 97 | let radians = CGFloat(angle.radians) - .pi/2 98 | let cosAngle = cos(radians) 99 | let sinAngle = sin(radians) 100 | 101 | let expectedPoint = CGPoint( 102 | x: circle.midX + (circle.radius - margin) * cosAngle, 103 | y: circle.midY + (circle.radius - margin) * sinAngle 104 | ) 105 | 106 | let pointInCircleFromAngle = CGPoint.inCircle(circle, for: angle, margin: margin) 107 | XCTAssertEqual(expectedPoint.x, pointInCircleFromAngle.x, accuracy: 0.01) 108 | XCTAssertEqual(expectedPoint.y, pointInCircleFromAngle.y, accuracy: 0.01) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/SteampunkMinuteArm.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SteampunkMinuteArm: Shape { 4 | static let lineWidthRatio: CGFloat = 1/40 5 | 6 | func path(in rect: CGRect) -> Path { 7 | var path = Path() 8 | addBottomCircle(to: &path, rect: rect) 9 | addDroplet(to: &path, rect: rect) 10 | addBottomRectange(to: &path, rect: rect) 11 | addSun(to: &path, rect: rect) 12 | addTopRectange(to: &path, rect: rect) 13 | addArrow(to: &path, rect: rect) 14 | path.addVerticalMirror(in: rect) 15 | 16 | path.addCircle(CGRect.circle(center: rect.center, radius: rect.radius * 1/15)) 17 | addDropletHole(to: &path, rect: rect) 18 | addSunHole(to: &path, rect: rect) 19 | return path 20 | } 21 | 22 | private func addBottomCircle(to path: inout Path, rect: CGRect) { 23 | let circle = CGRect.circle(center: rect.center, radius: rect.radius * 1/15) 24 | let lineWidth = rect.radius * Self.lineWidthRatio 25 | 26 | let bottom = CGPoint(x: rect.midX, y: circle.maxY + lineWidth) 27 | let topRight = CGPoint(x: rect.midX + lineWidth/2, y: circle.minY - lineWidth) 28 | 29 | path.move(to: bottom) 30 | path.addArc( 31 | center: rect.center, 32 | radius: circle.radius + lineWidth, 33 | startAngle: .inCircle(for: bottom, circleCenter: rect.center), 34 | endAngle: .inCircle(for: topRight, circleCenter: rect.center), 35 | clockwise: true 36 | ) 37 | } 38 | 39 | private func addDroplet(to path: inout Path, rect: CGRect) { 40 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/4) 41 | let radius = rect.radius * 1/30 42 | 43 | let lineWidth = rect.radius * Self.lineWidthRatio 44 | let outlineBottomRightPoint = CGPoint(x: center.x + lineWidth/2, y: center.y + radius + lineWidth) 45 | let topY = center.y - radius * 2 - lineWidth 46 | 47 | path.addArc( 48 | center: center, 49 | radius: radius + lineWidth, 50 | startAngle: .inCircle(for: outlineBottomRightPoint, circleCenter: center), 51 | endAngle: .zero, 52 | clockwise: true 53 | ) 54 | 55 | let outlineTopRightPoint = CGPoint(x: rect.midX + lineWidth/2, y: topY) 56 | path.addLine(to: outlineTopRightPoint) 57 | } 58 | 59 | private func addDropletHole(to path: inout Path, rect: CGRect) { 60 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/4) 61 | let radius = rect.radius * 1/30 62 | let circle = CGRect.circle(center: center, radius: radius) 63 | 64 | path.move(to: CGPoint(x: circle.maxX, y: center.y)) 65 | path.addArc(center: center, radius: radius, startAngle: .zero, endAngle: .fullRound/2, clockwise: false) 66 | path.addLine(to: CGPoint(x: center.x, y: center.y - radius * 2)) 67 | path.addLine(to: CGPoint(x: circle.maxX, y: center.y)) 68 | } 69 | 70 | private func addBottomRectange(to path: inout Path, rect: CGRect) { 71 | let lineWidth = rect.radius * Self.lineWidthRatio 72 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 3/8) 73 | addRectangle(to: &path, center: center, lineWidth: lineWidth) 74 | } 75 | 76 | private func addTopRectange(to path: inout Path, rect: CGRect) { 77 | let lineWidth = rect.radius * Self.lineWidthRatio 78 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 5/8) 79 | addRectangle(to: &path, center: center, lineWidth: lineWidth) 80 | } 81 | 82 | private func addRectangle(to path: inout Path, center: CGPoint, lineWidth: CGFloat) { 83 | let bottomRight = CGPoint(x: center.x + lineWidth/2, y: center.y + lineWidth/2) 84 | let topRight = CGPoint(x: center.x + lineWidth/2, y: center.y - lineWidth/2) 85 | path.addLine(to: bottomRight) 86 | path.addLine(to: CGPoint(x: center.x + lineWidth * 2, y: center.y + lineWidth/2)) 87 | path.addLine(to: CGPoint(x: center.x + lineWidth * 2, y: center.y - lineWidth/2)) 88 | path.addLine(to: topRight) 89 | } 90 | 91 | private func addArrow(to path: inout Path, rect: CGRect) { 92 | let lineWidth = rect.radius * Self.lineWidthRatio 93 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 7/10) 94 | let top = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 6/7) 95 | 96 | let bottomRight = CGPoint(x: center.x + lineWidth/2, y: center.y + lineWidth) 97 | 98 | path.addLine(to: bottomRight) 99 | path.addLine(to: CGPoint(x: center.x + lineWidth/2, y: center.y + lineWidth)) 100 | path.addLine(to: CGPoint(x: center.x + lineWidth * 2, y: center.y)) 101 | path.addLine(to: CGPoint(x: top.x, y: top.y)) 102 | } 103 | 104 | private func addSun(to path: inout Path, rect: CGRect) { 105 | let lineWidth = rect.radius * Self.lineWidthRatio 106 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/2) 107 | let insideCircle = CGRect.circle(center: center, radius: rect.radius * 1/30) 108 | let outlineCircle = CGRect.circle(center: center, radius: insideCircle.radius * 2) 109 | 110 | let bottomRight = CGPoint(x: rect.midX + lineWidth/2, y: outlineCircle.maxY) 111 | 112 | path.addLine(to: bottomRight) 113 | 114 | let beamCount = 12 115 | let degreeByBeam = 360/Double(beamCount) 116 | for beam in 1...beamCount/2 { 117 | let point: CGPoint 118 | if beam == beamCount/2 { 119 | point = CGPoint(x: rect.midX + lineWidth/2, y: outlineCircle.minY) 120 | } else { 121 | point = CGPoint.inCircle(outlineCircle, for: Angle(degrees: Double(beamCount/2 - beam) * degreeByBeam)) 122 | } 123 | 124 | let controlAngle = Angle(degrees: Double(beamCount/2 - beam) * degreeByBeam + degreeByBeam/2) 125 | let control = CGPoint.inCircle(insideCircle, for: controlAngle) 126 | path.addQuadCurve(to: point, control: control) 127 | } 128 | } 129 | 130 | private func addSunHole(to path: inout Path, rect: CGRect) { 131 | let center = CGPoint(x: rect.midX, y: rect.midY - rect.radius * 1/2) 132 | path.addCircle(CGRect.circle(center: center, radius: rect.radius * 1/30)) 133 | } 134 | } 135 | 136 | struct SteampunkMinuteArm_Previews: PreviewProvider { 137 | static var previews: some View { 138 | ZStack { 139 | Circle().stroke() 140 | SteampunkMinuteArm().fill(style: .init(eoFill: true, antialiased: true)) 141 | } 142 | .padding() 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/ClockView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | public struct ClockView: View { 5 | @Environment(\.clockFaceShown) var initialClockFaceShown 6 | static let borderWidthRatio: CGFloat = 1/70 7 | @State private var clockFaceShown = false 8 | @State private var cancellables = Set() 9 | 10 | public init() { } 11 | 12 | public var body: some View { 13 | ZStack { 14 | ClockBorderView() 15 | IndicatorsView() 16 | Arms() 17 | ClockFaceView() 18 | } 19 | .environment(\.clockFaceShown, initialClockFaceShown || clockFaceShown) 20 | .onTapGesture(count: 3, perform: self.showClockFace) 21 | } 22 | 23 | private func showClockFace() { 24 | clockFaceShown = true 25 | Just(false) 26 | .delay(for: .seconds(3), scheduler: RunLoop.main) 27 | .sink { self.clockFaceShown = $0 } 28 | .store(in: &cancellables) 29 | } 30 | } 31 | 32 | #if DEBUG 33 | struct ClockView_Previews: PreviewProvider { 34 | @Environment(\.calendar) static var calendar 35 | 36 | static var previews: some View { 37 | ClockView() 38 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 39 | } 40 | } 41 | 42 | struct ClockViewWithFace_Previews: PreviewProvider { 43 | @Environment(\.calendar) static var calendar 44 | 45 | static var previews: some View { 46 | ClockView() 47 | .environment(\.clockDate, .constant(.init(hour: 8, minute: 17, calendar: calendar))) 48 | .environment(\.clockFaceShown, true) 49 | } 50 | } 51 | 52 | struct ClockViewArtNouveauStyle_Previews: PreviewProvider { 53 | @Environment(\.calendar) static var calendar 54 | 55 | static var previews: some View { 56 | ClockView() 57 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 58 | .environment(\.clockStyle, .artNouveau) 59 | } 60 | } 61 | 62 | struct ClockViewDrawingStyle_Previews: PreviewProvider { 63 | @Environment(\.calendar) static var calendar 64 | 65 | static var previews: some View { 66 | ClockView() 67 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 68 | .environment(\.clockStyle, .drawing) 69 | } 70 | } 71 | 72 | struct ClockViewSteampunkStyle_Previews: PreviewProvider { 73 | @Environment(\.calendar) static var calendar 74 | 75 | static var previews: some View { 76 | ClockView() 77 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 78 | .environment(\.clockStyle, .steampunk) 79 | } 80 | } 81 | 82 | struct ClockViewDifferentColors_Previews: PreviewProvider { 83 | @Environment(\.calendar) static var calendar 84 | 85 | static var previews: some View { 86 | ClockView() 87 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 88 | .environment(\.clockArmColors, ClockArmColors( 89 | minute: .red, 90 | hour: .blue 91 | )) 92 | .environment(\.clockBorderColor, .orange) 93 | .environment(\.clockIndicatorsColor, .green) 94 | } 95 | } 96 | 97 | struct ClockViewWithGradient_Previews: PreviewProvider { 98 | @Environment(\.calendar) static var calendar 99 | 100 | static var previews: some View { 101 | LinearGradient( 102 | gradient: Gradient(colors: [Color.red, Color.blue]), 103 | startPoint: .leading, 104 | endPoint: .trailing 105 | ) 106 | .aspectRatio(contentMode: .fit) 107 | .mask(ClockView()) 108 | .environment(\.clockDate, .constant(.init(hour: 10, minute: 10, calendar: calendar))) 109 | } 110 | } 111 | 112 | struct ClockViewWithConfiguration_Previews: PreviewProvider { 113 | private struct MainView: View { 114 | @State private var configuration = ClockConfiguration() 115 | @State private var clockStyle: ClockStyle = .classic 116 | 117 | var body: some View { 118 | NavigationView { 119 | VStack { 120 | ClockView() 121 | .environment(\.clockConfiguration, configuration) 122 | .environment(\.clockStyle, clockStyle) 123 | .padding() 124 | 125 | NavigationLink("Configuration") { 126 | ConfigurationView( 127 | configuration: $configuration, 128 | clockStyle: $clockStyle 129 | ) 130 | } 131 | .padding() 132 | 133 | Button("Set Steampunk style now") { 134 | clockStyle = .steampunk 135 | } 136 | .padding() 137 | } 138 | } 139 | } 140 | } 141 | 142 | private struct ConfigurationView: View { 143 | @Binding var configuration: ClockConfiguration 144 | @Binding var clockStyle: ClockStyle 145 | 146 | var body: some View { 147 | VStack { 148 | stylePicker().pickerStyle(SegmentedPickerStyle()) 149 | clockView() 150 | controls() 151 | Spacer() 152 | } 153 | .padding() 154 | } 155 | 156 | private func clockView() -> some View { 157 | ClockView() 158 | .padding() 159 | .allowsHitTesting(false) 160 | .environment(\.clockConfiguration, configuration) 161 | .environment(\.clockStyle, clockStyle) 162 | } 163 | 164 | private func controls() -> some View { 165 | VStack { 166 | Toggle(isOn: $configuration.isMinuteIndicatorsShown) { 167 | Text("Minute indicators") 168 | } 169 | Toggle(isOn: $configuration.isHourIndicatorsShown) { 170 | Text("Hour indicators") 171 | } 172 | Toggle(isOn: $configuration.isLimitedHoursShown) { 173 | Text("Limited hour texts") 174 | } 175 | } 176 | } 177 | 178 | private func stylePicker() -> some View { 179 | Picker("Style", selection: $clockStyle) { 180 | ForEach(ClockStyle.allCases) { style in 181 | Text(style.description).tag(style) 182 | } 183 | } 184 | } 185 | } 186 | 187 | static var previews: some View { 188 | MainView() 189 | } 190 | } 191 | #endif 192 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Steampunk/Cogwheel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Cogwheel: Shape, Animatable { 4 | private(set) var toothCount = 30 5 | private(set) var armCount = 10 6 | private(set) var addExtraHoles = true 7 | private(set) var angle: Angle 8 | 9 | var animatableData: Double { 10 | get { angle.radians } 11 | set { angle.radians = newValue } 12 | } 13 | 14 | func path(in rect: CGRect) -> Path { 15 | var path = Path() 16 | addCenterCircle(to: &path, rect: rect) 17 | addArms(to: &path, rect: rect) 18 | path.move(to: CGPoint(x: rect.midX + rect.radius, y: rect.midY)) 19 | addTeeth(to: &path, rect: rect) 20 | path.closeSubpath() 21 | return path.applying(CGAffineTransform( 22 | a: cos(angle.radians), b: sin(angle.radians), 23 | c: -sin(angle.radians), d: cos(angle.radians), 24 | tx: rect.midX - rect.midX * cos(angle.radians) + rect.midY * sin(angle.radians), 25 | ty: rect.midY - rect.midX * sin(angle.radians) - rect.midY * cos(angle.radians) 26 | )) 27 | } 28 | 29 | private func addCenterCircle(to path: inout Path, rect: CGRect) { 30 | let radius = rect.radius * 1/4 31 | path.addEllipse(in: CGRect( 32 | x: rect.midX - radius/2, 33 | y: rect.midY - radius/2, 34 | width: radius, 35 | height: radius 36 | )) 37 | } 38 | 39 | private func addArms(to path: inout Path, rect: CGRect) { 40 | let radius = rect.radius * 1/4 41 | let degreesByArm = 360/Double(armCount) 42 | let armThickness = Angle(degrees: 280 * 1/Double(armCount)) 43 | for arm in 1...armCount { 44 | let arm = Double(arm) 45 | let angle = Angle.degrees(degreesByArm * arm) 46 | let startAngle = angle + .degrees(90) 47 | let circle = CGRect.circle(center: rect.center, radius: radius) 48 | let startPoint = CGPoint.inCircle(circle, for: startAngle) 49 | path.move(to: startPoint) 50 | path.addArc( 51 | center: rect.center, 52 | radius: radius, 53 | startAngle: angle, 54 | endAngle: angle - armThickness, 55 | clockwise: true 56 | ) 57 | path.addArc( 58 | center: rect.center, 59 | radius: radius * 3, 60 | startAngle: angle - armThickness, 61 | endAngle: angle, 62 | clockwise: false 63 | ) 64 | path.closeSubpath() 65 | addArmHoleIfNeeded(to: &path, rect: rect, startAngle: startAngle) 66 | } 67 | } 68 | 69 | private func addArmHoleIfNeeded(to path: inout Path, rect: CGRect, startAngle: Angle) { 70 | guard addExtraHoles else { return } 71 | 72 | let degreesByArm = 360/Double(armCount) 73 | let extraHoleMargin = rect.radius * 1/5 74 | let extraHoleAngle = startAngle - .degrees(degreesByArm/3) 75 | let extraHoleRadius = rect.radius * 1/24 76 | let extraHoleCenter = CGPoint 77 | .inCircle(rect, for: extraHoleAngle, margin: extraHoleMargin) 78 | .applying(.init(translationX: -extraHoleRadius/2, y: -extraHoleRadius/2)) 79 | let extraHoleSize = CGSize(width: extraHoleRadius, height: extraHoleRadius) 80 | path.addEllipse(in: CGRect(origin: extraHoleCenter, size: extraHoleSize)) 81 | } 82 | 83 | private func addTeeth(to path: inout Path, rect: CGRect) { 84 | let degreesByTooth = 360/Double(toothCount) 85 | for tooth in 0.. Path { 124 | var path = Path() 125 | for datum in data { 126 | path.addPath( 127 | Cogwheel( 128 | toothCount: datum.cogwheel.toothCount, 129 | armCount: datum.cogwheel.armCount, 130 | addExtraHoles: false, 131 | angle: datum.isClockwise ? angle : -angle 132 | ) 133 | .scale(datum.scale) 134 | .path(in: rect.offsetBy( 135 | dx: datum.relativeOffset.x * rect.circle.maxX - rect.circle.midX, 136 | dy: datum.relativeOffset.y * rect.circle.maxY - rect.circle.midY 137 | )) 138 | ) 139 | } 140 | return path 141 | } 142 | } 143 | 144 | #if DEBUG 145 | struct Cogwheel_Previews: PreviewProvider { 146 | static var previews: some View { 147 | Cogwheel(angle: .zero) 148 | .stroke() 149 | .padding(1) 150 | } 151 | } 152 | 153 | struct CogwheelWithRotation_Previews: PreviewProvider { 154 | static var previews: some View { 155 | Preview() 156 | } 157 | 158 | private struct Preview: View { 159 | @State private var angle: Angle = .zero 160 | 161 | var body: some View { 162 | VStack { 163 | Cogwheel(angle: angle).stroke().padding() 164 | Spacer() 165 | Text(String(format: "Degrees: %.f", angle.degrees)) 166 | Slider(value: $angle.degrees, in: 0...360).padding() 167 | } 168 | } 169 | } 170 | } 171 | 172 | struct Cogwheels_Previews: PreviewProvider { 173 | static var previews: some View { 174 | Cogwheels( 175 | data: [ 176 | .init( 177 | cogwheel: (toothCount: 10, armCount: 4), 178 | relativeOffset: (x: 1/2, y: 26/100), 179 | scale: 1/2, 180 | isClockwise: true 181 | ), 182 | .init( 183 | cogwheel: (toothCount: 10, armCount: 4), 184 | relativeOffset: (x: 1/2, y: 74/100), 185 | scale: 1/2, 186 | isClockwise: false 187 | ), 188 | ], 189 | angle: .zero 190 | ) 191 | .stroke() 192 | .padding(1) 193 | } 194 | } 195 | 196 | struct CogwheelsWithRotation_Previews: PreviewProvider { 197 | static var previews: some View { 198 | Preview() 199 | } 200 | 201 | private struct Preview: View { 202 | @State private var angle: Angle = .zero 203 | 204 | var body: some View { 205 | VStack { 206 | Cogwheels( 207 | data: [ 208 | .init( 209 | cogwheel: (toothCount: 10, armCount: 4), 210 | relativeOffset: (x: 1/2, y: 26/100), 211 | scale: 1/2, 212 | isClockwise: true 213 | ), 214 | .init( 215 | cogwheel: (toothCount: 10, armCount: 4), 216 | relativeOffset: (x: 1/2, y: 74/100), 217 | scale: 1/2, 218 | isClockwise: false 219 | ), 220 | ], 221 | angle: angle 222 | ).stroke() 223 | Spacer() 224 | Text(String(format: "Degrees: %.f", angle.degrees)).animation(nil) 225 | Slider(value: $angle.degrees, in: 0...360).padding() 226 | Button("Start animation") { 227 | guard angle == .zero else { return } 228 | withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) { 229 | angle += .fullRound 230 | } 231 | }.padding() 232 | } 233 | } 234 | } 235 | } 236 | #endif 237 | -------------------------------------------------------------------------------- /Sources/SwiftClockUI/Elements/Indicators/DrawnIndicators.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DrawnIndicators: View { 4 | @Environment(\.clockConfiguration) var configuration 5 | @Environment(\.clockAnimationEnabled) var isAnimationEnabled 6 | @State private var drawStep: CGFloat = 1 7 | 8 | var body: some View { 9 | ZStack { 10 | if configuration.isHourIndicatorsShown { 11 | HoursShape(drawStep: drawStep) 12 | } 13 | if configuration.isMinuteIndicatorsShown { 14 | MinutesShape(drawStep: drawStep) 15 | } 16 | DrawnNumbers() 17 | } 18 | .onAppear { 19 | guard isAnimationEnabled else { return } 20 | drawStep = 0.01 21 | withAnimation(.default.delay(0.01)) { 22 | drawStep = 1 23 | } 24 | } 25 | } 26 | } 27 | 28 | private struct HoursShape: Shape { 29 | @Environment(\.clockRandom) var random 30 | var drawStep: CGFloat 31 | 32 | var animatableData: CGFloat { 33 | get { drawStep } 34 | set { drawStep = newValue } 35 | } 36 | 37 | func path(in rect: CGRect) -> Path { 38 | var path = Path() 39 | (1...12).map { hour in 40 | let position: CGPoint = .inCircle( 41 | rect, 42 | for: .degrees(Double(hour) * .hourInDegree), 43 | margin: rect.radius * 1/10 44 | ) 45 | return DrawnIndicator(drawStep: drawStep, controlRatios: .init(random: random)) 46 | .rotation(Angle(degrees: Double(hour) * .hourInDegree)) 47 | .path(in: CGRect( 48 | x: position.x - rect.radius/100, 49 | y: position.y - rect.radius/20, 50 | width: rect.radius/50, 51 | height: rect.radius/10 52 | )) 53 | }.forEach { path.addPath($0) } 54 | return path 55 | } 56 | } 57 | 58 | private struct MinutesShape: Shape { 59 | @Environment(\.clockConfiguration) var configuration 60 | @Environment(\.clockRandom) var random 61 | var drawStep: CGFloat 62 | 63 | var animatableData: CGFloat { 64 | get { drawStep } 65 | set { drawStep = newValue } 66 | } 67 | 68 | func path(in rect: CGRect) -> Path { 69 | var path = Path() 70 | (1...60).map { minute in 71 | guard !isOverlapingHour(minute: minute) else { return nil } 72 | let position: CGPoint = .inCircle( 73 | rect, 74 | for: .degrees(Double(minute) * .minuteInDegree), 75 | margin: rect.radius * 1/15 76 | ) 77 | return DrawnIndicator(drawStep: drawStep, controlRatios: .init(random: random)) 78 | .rotation(Angle(degrees: Double(minute) * .minuteInDegree)) 79 | .path(in: CGRect( 80 | x: position.x - rect.radius/140, 81 | y: position.y - rect.radius/40, 82 | width: rect.radius/70, 83 | height: rect.radius/20 84 | )) 85 | }.compactMap { $0 }.forEach { path.addPath($0) } 86 | return path 87 | } 88 | 89 | private func isOverlapingHour(minute: Int) -> Bool { 90 | guard configuration.isHourIndicatorsShown else { return false } 91 | return minute == 0 || minute % 5 == 0 92 | } 93 | } 94 | 95 | private struct DrawnIndicator: Shape { 96 | var drawStep: CGFloat 97 | let controlRatios: Random.ControlRatio 98 | 99 | var animatableData: CGFloat { 100 | get { self.drawStep } 101 | set { self.drawStep = newValue } 102 | } 103 | 104 | func path(in rect: CGRect) -> Path { 105 | var path = Path() 106 | let thickness = rect.width 107 | let maxY = rect.maxY - (rect.maxY - rect.minY) * (1 - drawStep) 108 | let bottomCenter = CGPoint(x: rect.midX, y: maxY) 109 | let bottomRight = CGPoint( 110 | x: bottomCenter.x + thickness, 111 | y: bottomCenter.y 112 | ) 113 | let topCenter = CGPoint( 114 | x: rect.midX, 115 | y: rect.minY 116 | ) 117 | let topLeft = CGPoint( 118 | x: topCenter.x - thickness, 119 | y: topCenter.y 120 | ) 121 | 122 | path.move(to: bottomRight) 123 | 124 | path.addArc( 125 | center: bottomCenter, 126 | radius: thickness, 127 | startAngle: .zero, 128 | endAngle: .degrees(180), 129 | clockwise: false 130 | ) 131 | 132 | let controlLeft = CGPoint( 133 | x: rect.midX + thickness * self.controlRatios.leftX, 134 | y: topCenter.y + thickness * 2 * self.controlRatios.leftY 135 | ) 136 | path.addQuadCurve(to: topLeft, control: controlLeft) 137 | 138 | path.addArc( 139 | center: topCenter, 140 | radius: thickness, 141 | startAngle: .degrees(180), 142 | endAngle: .zero, 143 | clockwise: false 144 | ) 145 | 146 | let controlRight = CGPoint( 147 | x: rect.midX + thickness * self.controlRatios.rightX, 148 | y: topCenter.y + thickness * 2 * self.controlRatios.rightY 149 | ) 150 | path.addQuadCurve(to: bottomRight, control: controlRight) 151 | 152 | return path 153 | } 154 | } 155 | 156 | struct DrawnNumbers: View { 157 | @Environment(\.clockConfiguration) var configuration 158 | @Environment(\.clockRandom) var random 159 | private static let hours = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 160 | private static let limitedHours = [12, 3, 6, 9] 161 | private static let textFontRatio: CGFloat = 1/5 162 | 163 | var body: some View { 164 | ForEach(self.configurationHours, id: \.self) { hour in 165 | self.hourText(hour) 166 | } 167 | } 168 | 169 | private func hourText(_ hour: Int) -> some View { 170 | Text("\(hour)") 171 | .modifier(PositionInCircle( 172 | angle: .degrees(Double(hour) * .hourInDegree), 173 | marginRatio: isIndicatorsShown ? 30/100 : 20/100 174 | )) 175 | .modifier(FontProportional(ratio: Self.textFontRatio)) 176 | .rotationEffect(random.angle() ?? .zero, anchor: .center) 177 | .scaleEffect(random.scale() ?? 1, anchor: .center) 178 | } 179 | 180 | private var isIndicatorsShown: Bool { 181 | configuration.isHourIndicatorsShown || configuration.isMinuteIndicatorsShown 182 | } 183 | 184 | private var configurationHours: [Int] { 185 | configuration.isLimitedHoursShown ? Self.limitedHours : Self.hours 186 | } 187 | } 188 | 189 | #if DEBUG 190 | struct DrawnIndicators_Previews: PreviewProvider { 191 | static var previews: some View { 192 | ZStack { 193 | Circle().stroke() 194 | DrawnIndicators() 195 | }.padding() 196 | } 197 | } 198 | 199 | struct DrawnIndicatorsAnimated_Previews: PreviewProvider { 200 | static var previews: some View { 201 | Preview() 202 | } 203 | 204 | private struct Preview: View { 205 | @State private var drawStep: CGFloat = 1 206 | 207 | var body: some View { 208 | VStack { 209 | Spacer() 210 | ZStack { 211 | Circle().stroke() 212 | HoursShape(drawStep: drawStep) 213 | MinutesShape(drawStep: drawStep) 214 | DrawnNumbers() 215 | }.padding() 216 | Spacer() 217 | Slider(value: $drawStep).padding() 218 | }.padding() 219 | } 220 | } 221 | } 222 | 223 | struct DrawnIndicatorsAnimatedElements_Previews: PreviewProvider { 224 | static var previews: some View { 225 | Preview() 226 | } 227 | 228 | private struct Preview: View { 229 | @State private var drawStep: CGFloat = 1 230 | 231 | var body: some View { 232 | VStack { 233 | Spacer() 234 | DrawnIndicator(drawStep: drawStep, controlRatios: .init(random: .fixed)) 235 | .frame(width: 10, height: 200) 236 | Spacer() 237 | Slider(value: $drawStep).padding() 238 | }.padding() 239 | } 240 | } 241 | } 242 | 243 | struct DrawnNumbers_Previews: PreviewProvider { 244 | static var previews: some View { 245 | VStack { 246 | ZStack { 247 | Circle().strokeBorder() 248 | DrawnIndicators() 249 | } 250 | ZStack { 251 | Circle().strokeBorder() 252 | DrawnIndicators() 253 | } 254 | .environment(\.clockConfiguration, ClockConfiguration( 255 | isLimitedHoursShown: false, 256 | isMinuteIndicatorsShown: false, 257 | isHourIndicatorsShown: false 258 | )) 259 | }.padding() 260 | } 261 | } 262 | #endif 263 | --------------------------------------------------------------------------------