├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── TimeRangePicker │ ├── ArcTicks.swift │ ├── TimeRange.swift │ └── TimeRangePicker.swift └── Tests └── TimeRangePickerTests └── TimeRangePickerTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "clockface", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/1amageek/ClockFace.git", 7 | "state" : { 8 | "branch" : "main", 9 | "revision" : "005bd69c3d482ac9cd3a4ad030cc783f59d482a2" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 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: "TimeRangePicker", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "TimeRangePicker", 12 | targets: ["TimeRangePicker"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/1amageek/ClockFace.git", branch: "main") 16 | ], 17 | targets: [ 18 | .target( 19 | name: "TimeRangePicker", 20 | dependencies: ["ClockFace"]), 21 | .testTarget( 22 | name: "TimeRangePickerTests", 23 | dependencies: ["TimeRangePicker"]), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeRangePicker 2 | 3 | TimeRangePicker is a SwiftUI view that provides a user-friendly interface for selecting a range of time. It displays a circular clock face and allows users to select a start and end time by dragging around the clock. 4 | 5 | 6 | https://github.com/1amageek/TimeRangePicker/assets/11146538/2b6b7886-7fef-485a-9c3c-1baffa9eb75d 7 | 8 | 9 | ## Features 10 | 11 | - Interactive time selection using a visual clock interface. 12 | - Customizable minimum and maximum time differences. 13 | - Supports both light and dark mode. 14 | - Includes Haptic feedback. 15 | 16 | ## Installation 17 | 18 | ### Swift Package Manager 19 | 20 | You can use The Swift Package Manager to install `TimeRangePicker` by adding the proper description to your `Package.swift` file: 21 | 22 | ```swift 23 | .package(url: "https://github.com/YOUR_GITHUB_USERNAME/TimeRangePicker.git", from: "1.0.0"), 24 | ``` 25 | 26 | ## Usage 27 | 28 | To use TimeRangePicker in your SwiftUI views: 29 | 30 | ```swift 31 | @State var timeRange = 3600..<7200 // 1:00 - 2:00 32 | 33 | var body: some View { 34 | TimeRangePicker($timeRange) 35 | } 36 | 37 | ``` 38 | -------------------------------------------------------------------------------- /Sources/TimeRangePicker/ArcTicks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArcTicks.swift 3 | // 4 | // 5 | // Created by Norikazu Muramoto on 2023/06/04. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Arc: Shape { 11 | 12 | var startAngle: CGFloat 13 | 14 | var endAngle: CGFloat 15 | 16 | var lineWidth: CGFloat 17 | 18 | init(startAngle: CGFloat, endAngle: CGFloat, lineWidth: CGFloat = 44) { 19 | self.startAngle = startAngle 20 | self.endAngle = endAngle 21 | self.lineWidth = lineWidth 22 | } 23 | 24 | func path(in rect: CGRect) -> Path { 25 | var path = Path() 26 | let center = CGPoint(x: rect.midX, y: rect.midY) 27 | let radius = min(rect.width, rect.height) / 2 28 | path.addArc(center: center, 29 | radius: radius, 30 | startAngle: Angle(degrees: Double(startAngle)), 31 | endAngle: Angle(degrees: Double(endAngle)), 32 | clockwise: false) 33 | 34 | let roundedLine = path.strokedPath(.init(lineWidth: lineWidth, lineCap: .round)) 35 | return roundedLine 36 | } 37 | } 38 | 39 | struct Ticks: Shape { 40 | 41 | var startAngle: CGFloat 42 | 43 | var endAngle: CGFloat 44 | 45 | var divisions: Int 46 | 47 | var tickWidth: CGFloat 48 | 49 | init(startAngle: CGFloat, endAngle: CGFloat, divisions: Int = 80, tickWidth: CGFloat = 14) { 50 | self.startAngle = startAngle 51 | self.endAngle = endAngle 52 | self.divisions = divisions 53 | self.tickWidth = tickWidth 54 | } 55 | 56 | func path(in rect: CGRect) -> Path { 57 | let center = CGPoint(x: rect.midX, y: rect.midY) 58 | let radius = min(rect.width, rect.height) / 2 59 | let startRadians = startAngle * CGFloat.pi / 180 60 | let endRadians = endAngle * CGFloat.pi / 180 61 | let step = 360.0 / CGFloat(divisions) * CGFloat.pi / 180 62 | var path = Path() 63 | for angle in stride(from: startRadians, to: endRadians, by: step) { 64 | let tickStart = CGPoint( 65 | x: center.x + (radius - tickWidth/2) * cos(angle), 66 | y: center.y + (radius - tickWidth/2) * sin(angle) 67 | ) 68 | let tickEnd = CGPoint( 69 | x: center.x + (radius + tickWidth/2) * cos(angle), 70 | y: center.y + (radius + tickWidth/2) * sin(angle) 71 | ) 72 | path.move(to: tickStart) 73 | path.addLine(to: tickEnd) 74 | } 75 | return path 76 | } 77 | } 78 | 79 | struct ArcTicks: View { 80 | 81 | var startAngle: CGFloat 82 | 83 | var endAngle: CGFloat 84 | 85 | var lineWidth: CGFloat 86 | 87 | var tickWidth: CGFloat 88 | 89 | var divisions: Int 90 | 91 | init(startAngle: CGFloat, endAngle: CGFloat, lineWidth: CGFloat = 44, tickWidth: CGFloat = 12, divisions: Int = 120) { 92 | self.startAngle = startAngle 93 | self.endAngle = endAngle 94 | self.lineWidth = lineWidth 95 | self.tickWidth = tickWidth 96 | self.divisions = divisions 97 | } 98 | 99 | var body: some View { 100 | ZStack { 101 | Arc(startAngle: startAngle, endAngle: endAngle, lineWidth: lineWidth) 102 | .foregroundColor(Color(UIColor.tertiarySystemBackground)) 103 | Ticks(startAngle: 0, endAngle: 360, divisions: divisions, tickWidth: tickWidth) 104 | .stroke(Color(UIColor.systemGray3), style: .init(lineWidth: 4, lineCap: .round)) 105 | .mask(Arc(startAngle: startAngle, endAngle: endAngle)) 106 | } 107 | .compositingGroup() 108 | .contentShape(Arc(startAngle: startAngle, endAngle: endAngle)) 109 | } 110 | } 111 | 112 | struct Arc_Previews: PreviewProvider { 113 | 114 | struct ContentView: View { 115 | 116 | @State var startAngle: CGFloat = 0 117 | @State var endAngle: CGFloat = 90 118 | let lineWidth: CGFloat = 40 119 | 120 | @GestureState private var rotation: CGFloat = 0 121 | 122 | var angleDifference: CGFloat { 123 | if startAngle > endAngle { 124 | return startAngle - endAngle 125 | } else { 126 | return 360 - endAngle + startAngle 127 | } 128 | } 129 | 130 | var body: some View { 131 | VStack(spacing: 32) { 132 | GeometryReader { proxy in 133 | ArcTicks(startAngle: startAngle, endAngle: endAngle) 134 | .padding(32) 135 | .rotationEffect(.degrees(Double(rotation))) 136 | .gesture( 137 | DragGesture(minimumDistance: 0.1) 138 | .updating($rotation) { value, state, _ in 139 | let vector1 = CGVector(dx: value.startLocation.x - proxy.size.width / 2, 140 | dy: value.startLocation.y - proxy.size.height / 2) 141 | let vector2 = CGVector(dx: value.location.x - proxy.size.width / 2, 142 | dy: value.location.y - proxy.size.height / 2) 143 | let startAngle = atan2(vector1.dy, vector1.dx) * (180 / CGFloat.pi) 144 | let endAngle = atan2(vector2.dy, vector2.dx) * (180 / CGFloat.pi) 145 | state = endAngle - startAngle 146 | } 147 | .onEnded { value in 148 | startAngle = (startAngle + rotation).truncatingRemainder(dividingBy: 360) 149 | endAngle = (startAngle + angleDifference).truncatingRemainder(dividingBy: 360) 150 | } 151 | ) 152 | } 153 | 154 | Slider(value: $startAngle, in: -360...360) 155 | Slider(value: $endAngle, in: -360...360) 156 | } 157 | } 158 | } 159 | 160 | static var previews: some View { 161 | ContentView() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/TimeRangePicker/TimeRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeRange.swift 3 | // 4 | // 5 | // Created by Norikazu Muramoto on 2023/06/05. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension TimeRangePicker { 12 | /// A representation of a range of time. 13 | /// 14 | /// A `TimeRange` object contains two `TimeInterval` values, representing a start time and an end time. 15 | /// This struct provides an easy way to manage and manipulate ranges of time. 16 | /// 17 | /// let morningTime = TimeRange(start: 6 * 3600, end: 12 * 3600) 18 | /// print(morningTime.start) // 21600.0 19 | /// print(morningTime.end) // 43200.0 20 | /// 21 | /// 22 | public struct TimeRange: Codable, Equatable, Sendable { 23 | 24 | /// The start time of the range, represented in seconds since midnight. 25 | public var start: TimeInterval 26 | 27 | /// The end time of the range, represented in seconds since midnight. 28 | public var end: TimeInterval 29 | 30 | /// Creates a `TimeRange` instance with the specified start and end times. 31 | /// 32 | /// - Parameters: 33 | /// - start: The start time of the range in seconds since midnight. 34 | /// - end: The end time of the range in seconds since midnight. 35 | /// 36 | /// - Returns: A new `TimeRange` instance. 37 | /// 38 | /// - Note: The start time should be less than the end time, and both should be within a 24 hour time period. 39 | /// 40 | public init(start: TimeInterval, end: TimeInterval) { 41 | self.start = start 42 | self.end = end 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/TimeRangePicker/TimeRangePicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ClockFace 3 | 4 | /// A SwiftUI view that provides a visual picker for a range of time. 5 | /// 6 | /// This view renders a clock face, and allows the user to select a start and end time by dragging on the clock. 7 | /// The current time range selection is represented by `TimeRange` and can be accessed or modified by a binding. 8 | /// 9 | /// ``` 10 | /// @State var timeRange = TimeRange(start: 3600, end: 7200) // 1:00 - 2:00 11 | /// 12 | /// var body: some View { 13 | /// TimeRangePicker($timeRange) 14 | /// } 15 | /// ``` 16 | /// 17 | public struct TimeRangePicker: View { 18 | 19 | @Environment(\.colorScheme) var colorScheme: ColorScheme 20 | 21 | @Binding var value: Range 22 | 23 | /// A binding to a `TimeRange` that determines the currently selected start and end time. 24 | @State var selection: TimeRange 25 | 26 | @State private var initialStartAngle: CGFloat = 0 27 | 28 | @State private var initialEndAngle: CGFloat = 0 29 | 30 | var minimumDifference: CGFloat 31 | 32 | var maximumDifference: CGFloat 33 | 34 | /// Creates a `TimeRangePicker` instance with a binding to the selected range and an optional range of allowable values. 35 | /// 36 | /// - Parameters: 37 | /// - value: A binding to a `TimeRange` that provides the initial start and end times and updates with any user changes. 38 | /// - range: An optional range of allowable times for the user to select. The default range is from 1 hour to 22.72 hours after midnight. 39 | /// 40 | /// - Returns: A new `TimeRangePicker` instance. 41 | /// 42 | public init(_ value: Binding>, in range: Range = 3600..<81800) { 43 | let start = value.wrappedValue.lowerBound.truncatingRemainder(dividingBy: 86400.0) 44 | let end = value.wrappedValue.upperBound.truncatingRemainder(dividingBy: 86400.0) 45 | self._selection = State(initialValue: TimeRange(start: start, end: end)) 46 | self._value = value 47 | self.minimumDifference = CGFloat(range.lowerBound / 86400.0 * 360.0) 48 | self.maximumDifference = CGFloat(range.upperBound / 86400.0 * 360.0) 49 | } 50 | 51 | static func timeToAngle(_ timeInterval: TimeInterval) -> CGFloat { 52 | return CGFloat(timeInterval / 86400.0 * 360.0) + 270 53 | } 54 | 55 | static func angleToTime(_ angle: CGFloat) -> TimeInterval { 56 | return TimeInterval((angle + 90) / 360.0 * 86400.0).truncatingRemainder(dividingBy: 86400.0) 57 | } 58 | 59 | var startAngle: Binding { 60 | Binding { 61 | TimeRangePicker.timeToAngle(self.selection.start) 62 | } set: { newValue in 63 | self.selection.start = TimeRangePicker.angleToTime(newValue).roundToNearest(300) 64 | } 65 | } 66 | 67 | var endAngle: Binding { 68 | Binding { 69 | TimeRangePicker.timeToAngle(self.selection.end) 70 | } set: { newValue in 71 | self.selection.end = TimeRangePicker.angleToTime(newValue).roundToNearest(300) 72 | } 73 | } 74 | 75 | let generator = UIImpactFeedbackGenerator(style: .medium) 76 | 77 | private func gesture(proxy: GeometryProxy) -> some Gesture { 78 | SimultaneousGesture( 79 | LongPressGesture(minimumDuration: 0.0, maximumDistance: 5) 80 | .onEnded { value in 81 | self.initialStartAngle = self.startAngle.wrappedValue 82 | self.initialEndAngle = self.endAngle.wrappedValue 83 | }, 84 | DragGesture(minimumDistance: 0, coordinateSpace: .local) 85 | .onChanged { value in 86 | let vector1 = CGVector(dx: value.startLocation.x - proxy.size.width / 2, 87 | dy: value.startLocation.y - proxy.size.height / 2) 88 | let vector2 = CGVector(dx: value.location.x - proxy.size.width / 2, 89 | dy: value.location.y - proxy.size.height / 2) 90 | let startAngleRad = atan2(vector1.dy, vector1.dx) 91 | let endAngleRad = atan2(vector2.dy, vector2.dx) 92 | let startAngleDrag = (startAngleRad < 0 ? startAngleRad + 2 * .pi : startAngleRad) * (180 / CGFloat.pi) 93 | let endAngleDrag = (endAngleRad < 0 ? endAngleRad + 2 * .pi : endAngleRad) * (180 / CGFloat.pi) 94 | let dragAmount = endAngleDrag - startAngleDrag 95 | self.startAngle.wrappedValue = (self.initialStartAngle + dragAmount).truncatingRemainder(dividingBy: 360) 96 | self.endAngle.wrappedValue = (self.initialEndAngle + dragAmount).truncatingRemainder(dividingBy: 360) 97 | } 98 | .onEnded { _ in 99 | self.initialStartAngle = 0 100 | self.initialEndAngle = 0 101 | } 102 | ) 103 | } 104 | 105 | public var body: some View { 106 | ZStack { 107 | 108 | ClockFace(0..<24, step: 2) { index in 109 | Text(index, format: .number) 110 | .font(.system(.callout, design: .rounded, weight: .semibold)) 111 | .monospacedDigit() 112 | .foregroundColor(index % 3 == 0 ? .primary : .secondary) 113 | } 114 | .overlay { 115 | VStack { 116 | Image(systemName: "moon.stars.fill") 117 | .foregroundColor(.cyan) 118 | Spacer() 119 | Image(systemName: "sun.max.fill") 120 | .foregroundColor(.yellow) 121 | } 122 | .font(.title3) 123 | .padding(60) 124 | } 125 | .padding(6) 126 | .background(Color(UIColor.tertiarySystemBackground), in: Circle()) 127 | .padding(28) 128 | 129 | 130 | GeometryReader { proxy in 131 | ArcTicks(startAngle: startAngle.wrappedValue, endAngle: endAngle.wrappedValue) 132 | .shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 0) 133 | .gesture(gesture(proxy: proxy)) 134 | } 135 | 136 | KnobView(angle: startAngle, offsetAngle: Double(0)) { 137 | Image(systemName: "power") 138 | .resizable() 139 | .scaledToFit() 140 | .bold() 141 | .padding(12) 142 | .frame(width: 44, height: 44) 143 | .background(Color(UIColor.tertiarySystemBackground)) 144 | .clipShape(Circle()) 145 | .foregroundColor(.secondary) 146 | } 147 | 148 | KnobView(angle: endAngle, offsetAngle: Double(0)) { 149 | Image(systemName: "poweroff") 150 | .resizable() 151 | .scaledToFit() 152 | .bold() 153 | .padding(12) 154 | .frame(width: 44, height: 44) 155 | .background(Color(UIColor.tertiarySystemBackground)) 156 | .clipShape(Circle()) 157 | .foregroundColor(.secondary) 158 | } 159 | } 160 | .onChange(of: startAngle.wrappedValue) { newValue in 161 | var diff = (endAngle.wrappedValue - newValue).truncatingRemainder(dividingBy: 360) 162 | if diff < 0 { 163 | diff += 360 164 | } 165 | if diff < minimumDifference { 166 | endAngle.wrappedValue = (newValue + minimumDifference).truncatingRemainder(dividingBy: 360) 167 | if endAngle.wrappedValue < 0 { 168 | endAngle.wrappedValue += 360 169 | } 170 | } else if diff > maximumDifference { 171 | endAngle.wrappedValue = (newValue + maximumDifference).truncatingRemainder(dividingBy: 360) 172 | if endAngle.wrappedValue < 0 { 173 | endAngle.wrappedValue += 360 174 | } 175 | } 176 | } 177 | .onChange(of: endAngle.wrappedValue) { newValue in 178 | var diff = (newValue - startAngle.wrappedValue).truncatingRemainder(dividingBy: 360) 179 | if diff < 0 { 180 | diff += 360 181 | } 182 | if diff < minimumDifference { 183 | startAngle.wrappedValue = (newValue - minimumDifference).truncatingRemainder(dividingBy: 360) 184 | if startAngle.wrappedValue < 0 { 185 | startAngle.wrappedValue += 360 186 | } 187 | } else if diff > maximumDifference { 188 | startAngle.wrappedValue = (newValue - maximumDifference).truncatingRemainder(dividingBy: 360) 189 | if startAngle.wrappedValue < 0 { 190 | startAngle.wrappedValue += 360 191 | } 192 | } 193 | } 194 | .onChange(of: selection) { [previousValue = selection] newValue in 195 | let startDifference = abs(newValue.start - previousValue.start) 196 | let endDifference = abs(newValue.end - previousValue.end) 197 | if startDifference >= 300 || endDifference >= 300 { 198 | generator.impactOccurred() 199 | } 200 | let start = newValue.start 201 | let end = newValue.end 202 | if start < end { 203 | self.value = start..: View where Content: View { 215 | 216 | @Binding var angle: CGFloat 217 | 218 | var offsetAngle: CGFloat 219 | 220 | var content: () -> Content 221 | 222 | init(angle: Binding, offsetAngle: CGFloat, content: @escaping () -> Content) { 223 | self._angle = angle 224 | self.offsetAngle = offsetAngle 225 | self.content = content 226 | } 227 | 228 | var body: some View { 229 | GeometryReader { proxy in 230 | let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2) 231 | let radius = min(proxy.size.width, proxy.size.height) / 2 232 | let position = CGPoint( 233 | x: center.x + radius * cos((angle + offsetAngle) / 180 * .pi), 234 | y: center.y + radius * sin((angle + offsetAngle) / 180 * .pi) 235 | ) 236 | content() 237 | .position(position) 238 | .gesture( 239 | DragGesture(minimumDistance: 0.1) 240 | .onChanged { value in 241 | let vector = CGVector(dx: value.location.x - center.x, dy: value.location.y - center.y) 242 | let radians = atan2(vector.dy, vector.dx) 243 | if radians < 0 { 244 | self.angle = (radians + 2 * .pi) * 180 / .pi 245 | } else { 246 | self.angle = radians * 180 / .pi 247 | } 248 | } 249 | ) 250 | } 251 | } 252 | } 253 | 254 | extension TimeInterval { 255 | func roundToNearest(_ value: TimeInterval) -> TimeInterval { 256 | return (self / value).rounded(.toNearestOrEven) * value 257 | } 258 | } 259 | 260 | struct TimeRangePicker_Previews: PreviewProvider { 261 | 262 | struct ContentView: View { 263 | 264 | @State var range: Range = Date().timeIntervalSince1970..