Skip to content

Commit 4fa9c11

Browse files
shp7724peng-u-0807
andauthored
New TimeRangeSlider for minHour and maxHour (#223)
* implement `TimeRangeSlider` * fix wobbling text label * remove magic number * add taptic feedback * save fixed config back to the UserDefaults * Apply SwiftFormat changes * add label Co-authored-by: Yoorim Choi <70614553+peng-u-0807@users.noreply.github.com> * remove redundant ZStack * Apply SwiftFormat changes --------- Co-authored-by: shp7724 <shp7724@users.noreply.github.com> Co-authored-by: Yoorim Choi <70614553+peng-u-0807@users.noreply.github.com>
1 parent b1f32eb commit 4fa9c11

File tree

4 files changed

+190
-41
lines changed

4 files changed

+190
-41
lines changed

SNUTT-2022/SNUTT.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
BEF9233628E7EE45004AFCB2 /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233528E7EE45004AFCB2 /* SignUpView.swift */; };
214214
BEF9233828E84653004AFCB2 /* LectureTimeSheetScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */; };
215215
BEF9233A28E84B62004AFCB2 /* SearchLectureCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */; };
216+
CE4F2C6229BA45420007194E /* TimeRangeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */; };
216217
DC1E0ECC28771B32005632A3 /* TimetableRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ECB28771B32005632A3 /* TimetableRepository.swift */; };
217218
DC1E0ECF28772F13005632A3 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ECE28772F13005632A3 /* NetworkUtils.swift */; };
218219
DC1E0ED12877381F005632A3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ED02877381F005632A3 /* Router.swift */; };
@@ -458,6 +459,7 @@
458459
BEF9233528E7EE45004AFCB2 /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = "<group>"; };
459460
BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LectureTimeSheetScene.swift; sourceTree = "<group>"; };
460461
BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchLectureCell.swift; sourceTree = "<group>"; };
462+
CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeSlider.swift; sourceTree = "<group>"; };
461463
DC1E0ECB28771B32005632A3 /* TimetableRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableRepository.swift; sourceTree = "<group>"; };
462464
DC1E0ECE28772F13005632A3 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = "<group>"; };
463465
DC1E0ED02877381F005632A3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
@@ -905,6 +907,7 @@
905907
B85B244C295BF36F00E6577E /* FindLocalIdView.swift */,
906908
B8F40EA8289809C60021A2A9 /* LicenseView.swift */,
907909
B88D170028AF71E300E2D652 /* UserSupportView.swift */,
910+
CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */,
908911
BE28036028E884D300B2B1AB /* WebViews */,
909912
BE9413D728C3AF8300171060 /* Search */,
910913
BEB3B6AB28D4D3DF00E56062 /* LectureDetail */,
@@ -1354,6 +1357,7 @@
13541357
DC860F4927E5C87D0068C94B /* SNUTTApp.swift in Sources */,
13551358
BE9413D028C220C900171060 /* NotificationService.swift in Sources */,
13561359
BE9413B928C20A4000171060 /* AuthRouter.swift in Sources */,
1360+
CE4F2C6229BA45420007194E /* TimeRangeSlider.swift in Sources */,
13571361
BE682BDB28870872009EBCB7 /* LectureService.swift in Sources */,
13581362
BE7E230127FF20EE004DC202 /* TimetableScene.swift in Sources */,
13591363
DCD41A7227E5CE1D00CF380E /* TimetableViewModel.swift in Sources */,

SNUTT-2022/SNUTT/Services/TimetableService.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,14 @@ struct TimetableService: TimetableServiceProtocol {
125125

126126
func loadTimetableConfig() {
127127
DispatchQueue.main.async {
128-
appState.timetable.configuration = userDefaultsRepository.get(TimetableConfiguration.self, key: .timetableConfig, defaultValue: .init())
128+
var localConfig = userDefaultsRepository.get(TimetableConfiguration.self, key: .timetableConfig, defaultValue: .init())
129+
if localConfig.maxHour - localConfig.minHour < 6 {
130+
// fix data integrity
131+
localConfig.minHour = 9
132+
localConfig.maxHour = 18
133+
setTimetableConfig(config: localConfig)
134+
}
135+
appState.timetable.configuration = localConfig
129136
}
130137
}
131138

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//
2+
// TimeRangeSlider.swift
3+
// SNUTT
4+
//
5+
// Created by user on 2023/03/10.
6+
//
7+
8+
import SwiftUI
9+
10+
struct TimeRangeSliderConfig {
11+
var lineWidth: CGFloat = 5
12+
var handleDiameter: CGFloat = 20
13+
var minimumDistance = 6
14+
var tickCount = 24
15+
var tickMarkWidth: CGFloat = 2
16+
}
17+
18+
struct TimeRangeSlider: View {
19+
@Binding var minHour: Int
20+
@Binding var maxHour: Int
21+
var config: TimeRangeSliderConfig = .init()
22+
23+
@State private var feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
24+
25+
struct SliderPath: Shape {
26+
func path(in rect: CGRect) -> Path {
27+
let height = rect.size.height
28+
return Path { path in
29+
path.move(to: .init(x: 0, y: height / 2))
30+
path.addLine(to: .init(x: rect.size.width, y: height / 2))
31+
}
32+
}
33+
}
34+
35+
struct TickMarks: Shape {
36+
let tickCount: Int
37+
func path(in rect: CGRect) -> Path {
38+
let width = rect.size.width
39+
let centerY = rect.size.height / 2
40+
return Path { path in
41+
for i in 0 ... tickCount {
42+
let x: Double = .init(i) * width / Double(tickCount)
43+
let y: Double = i % 6 == 0 ? 5 : 2
44+
path.move(to: CGPoint(x: x, y: centerY))
45+
path.addLine(to: .init(x: x, y: centerY + y))
46+
}
47+
}
48+
}
49+
}
50+
51+
struct SliderHandle: View {
52+
let hour: Int
53+
let offset: CGFloat
54+
let diameter: CGFloat
55+
let onChanged: (DragGesture.Value) -> Void
56+
57+
@GestureState private var isDragging = false
58+
59+
private var simultaneousGesture: some Gesture {
60+
// Gesture that toggles `isDragging` when pressed
61+
let initialGesture = DragGesture(minimumDistance: 0)
62+
.updating($isDragging, body: { _, state, _ in
63+
state = true
64+
})
65+
// Gesture that reacts to the location changes
66+
let draggingGesture = DragGesture()
67+
.onChanged { value in
68+
onChanged(value)
69+
}
70+
// Combine two gestures with different minimumDistance
71+
// to prevent jumps when pressed
72+
return draggingGesture.simultaneously(with: initialGesture)
73+
}
74+
75+
var body: some View {
76+
Circle()
77+
.fill(Color.white)
78+
.frame(width: diameter)
79+
.shadow(radius: 1)
80+
.scaleEffect(isDragging ? 1.5 : 1.0)
81+
.offset(x: offset)
82+
.gesture(simultaneousGesture)
83+
.overlay {
84+
Text("\(hour)")
85+
.fixedSize()
86+
.font(.system(size: isDragging ? 14 : 12, weight: .bold))
87+
.offset(x: offset, y: isDragging ? -25 : -20)
88+
.opacity(0.8)
89+
}
90+
.animation(.customSpring, value: isDragging)
91+
}
92+
}
93+
94+
func translateHourToWidth(hour: Int, reader: GeometryProxy) -> CGFloat {
95+
return CGFloat(hour) * reader.size.width / CGFloat(config.tickCount)
96+
}
97+
98+
func translateWidthToHour(width: CGFloat, reader: GeometryProxy) -> Int {
99+
let normalizedWidth = max(min(width / reader.size.width, 1.0), 0.0)
100+
let hour = Int(round(Double(normalizedWidth) * Double(config.tickCount)))
101+
return hour
102+
}
103+
104+
func calculatePercentage(hour: Int) -> Double {
105+
return Double(hour) / Double(config.tickCount)
106+
}
107+
108+
var body: some View {
109+
GeometryReader { reader in
110+
ZStack(alignment: .leading) {
111+
SliderPath()
112+
.stroke(Color(uiColor: .quaternaryLabel).opacity(0.5), style: StrokeStyle(lineWidth: config.lineWidth, lineCap: .round, lineJoin: .round))
113+
114+
TickMarks(tickCount: config.tickCount)
115+
.stroke(Color(uiColor: .quaternaryLabel).opacity(0.5), style: StrokeStyle(lineWidth: config.tickMarkWidth, lineCap: .round, lineJoin: .round))
116+
.offset(y: config.lineWidth)
117+
118+
SliderPath()
119+
.trim(from: calculatePercentage(hour: minHour), to: calculatePercentage(hour: maxHour))
120+
.stroke(STColor.cyan, style: StrokeStyle(lineWidth: config.lineWidth, lineCap: .round, lineJoin: .round))
121+
122+
ZStack {
123+
SliderHandle(hour: minHour, offset: translateHourToWidth(hour: minHour, reader: reader), diameter: config.handleDiameter) { value in
124+
let newValue = min(translateWidthToHour(width: value.location.x, reader: reader), maxHour - config.minimumDistance)
125+
if minHour != newValue {
126+
minHour = newValue
127+
feedbackGenerator.impactOccurred()
128+
}
129+
}
130+
131+
SliderHandle(hour: maxHour, offset: translateHourToWidth(hour: maxHour, reader: reader), diameter: config.handleDiameter) { value in
132+
let newValue = max(translateWidthToHour(width: value.location.x, reader: reader), minHour + config.minimumDistance)
133+
if maxHour != newValue {
134+
maxHour = newValue
135+
feedbackGenerator.impactOccurred()
136+
}
137+
}
138+
}
139+
.padding(.horizontal, -config.handleDiameter / 2)
140+
}
141+
.padding(.top, 10)
142+
}
143+
.padding(.horizontal, 5)
144+
}
145+
}
146+
147+
struct TimeRangeSliderWrapper: View {
148+
@State private var minHour = 4
149+
@State private var maxHour = 18
150+
151+
var config: TimeRangeSliderConfig {
152+
var config = TimeRangeSliderConfig()
153+
config.lineWidth = 20
154+
return config
155+
}
156+
157+
var body: some View {
158+
VStack {
159+
TimeRangeSlider(minHour: $minHour, maxHour: $maxHour, config: config)
160+
.padding(.horizontal, 20)
161+
}
162+
}
163+
}
164+
165+
struct TimeRangeSliderWrapper_Previews: PreviewProvider {
166+
static var previews: some View {
167+
TimeRangeSliderWrapper()
168+
}
169+
}

SNUTT-2022/SNUTT/Views/Scenes/Settings/TimetableSettingScene.swift

Lines changed: 9 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,12 @@ struct TimetableSettingScene: View {
4747
.navigationBarTitle("요일 선택")
4848
}
4949

50-
Group {
51-
DatePicker("시작",
52-
selection: $viewModel.minHour,
53-
in: viewModel.minTimeRange,
54-
displayedComponents: [.hourAndMinute])
55-
DatePicker("종료",
56-
selection: $viewModel.maxHour,
57-
in: viewModel.maxTimeRange,
58-
displayedComponents: [.hourAndMinute])
59-
}.datePickerStyle(.compact)
50+
VStack(alignment: .leading) {
51+
Text("시간대")
52+
53+
TimeRangeSlider(minHour: $viewModel.timetableConfig.minHour, maxHour: $viewModel.timetableConfig.maxHour)
54+
.frame(height: 40)
55+
}
6056
}
6157
}
6258

@@ -79,11 +75,6 @@ struct TimetableSettingScene: View {
7975
}
8076
.navigationTitle("시간표 설정")
8177
.navigationBarTitleDisplayMode(.inline)
82-
.onChange(of: viewModel.minHour, perform: { _ in
83-
if !viewModel.maxTimeRange.contains(viewModel.maxHour) {
84-
viewModel.maxHour = viewModel.maxTimeRange.lowerBound
85-
}
86-
})
8778
}
8879
}
8980

@@ -97,30 +88,6 @@ extension TimetableSettingScene {
9788
set { services.timetableService.setTimetableConfig(config: newValue) }
9889
}
9990

100-
var minHour: Date {
101-
get { Calendar.current.date(from: DateComponents(hour: timetableConfig.minHour))! }
102-
set {
103-
timetableConfig.minHour = Calendar.current.component(.hour, from: newValue)
104-
}
105-
}
106-
107-
var maxHour: Date {
108-
get { Calendar.current.date(from: DateComponents(hour: timetableConfig.maxHour))! }
109-
set {
110-
timetableConfig.maxHour = Calendar.current.component(.hour, from: newValue)
111-
}
112-
}
113-
114-
var minTimeRange: ClosedRange<Date> {
115-
let calendar = Calendar.current
116-
return calendar.date(from: .init(hour: 0, minute: 0))! ... calendar.date(from: .init(hour: 17, minute: 0))!
117-
}
118-
119-
var maxTimeRange: ClosedRange<Date> {
120-
let calendar = Calendar.current
121-
return calendar.date(byAdding: .hour, value: 6, to: minHour)! ... calendar.date(from: .init(hour: 23, minute: 0))!
122-
}
123-
12491
var visibleWeekdaysPreview: String {
12592
timetableConfig.visibleWeeksSorted
12693
.map { $0.shortSymbol }.joined(separator: " ")
@@ -146,7 +113,9 @@ extension TimetableSettingScene {
146113
#if DEBUG
147114
struct TimetableSettingScene_Previews: PreviewProvider {
148115
static var previews: some View {
149-
TimetableSettingScene(viewModel: .init(container: .preview))
116+
var preview = DIContainer.preview
117+
preview.appState.timetable.configuration.autoFit = false
118+
return TimetableSettingScene(viewModel: .init(container: preview))
150119
}
151120
}
152121
#endif

0 commit comments

Comments
 (0)