├── .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..