Skip to content

Commit

Permalink
New TimeRangeSlider for minHour and maxHour (#223)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* remove redundant ZStack

* Apply SwiftFormat changes

---------

Co-authored-by: shp7724 <[email protected]>
Co-authored-by: Yoorim Choi <[email protected]>
  • Loading branch information
3 people authored Mar 13, 2023
1 parent b1f32eb commit 4fa9c11
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 41 deletions.
4 changes: 4 additions & 0 deletions SNUTT-2022/SNUTT.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
BEF9233628E7EE45004AFCB2 /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233528E7EE45004AFCB2 /* SignUpView.swift */; };
BEF9233828E84653004AFCB2 /* LectureTimeSheetScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */; };
BEF9233A28E84B62004AFCB2 /* SearchLectureCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */; };
CE4F2C6229BA45420007194E /* TimeRangeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */; };
DC1E0ECC28771B32005632A3 /* TimetableRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ECB28771B32005632A3 /* TimetableRepository.swift */; };
DC1E0ECF28772F13005632A3 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ECE28772F13005632A3 /* NetworkUtils.swift */; };
DC1E0ED12877381F005632A3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1E0ED02877381F005632A3 /* Router.swift */; };
Expand Down Expand Up @@ -458,6 +459,7 @@
BEF9233528E7EE45004AFCB2 /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = "<group>"; };
BEF9233728E84653004AFCB2 /* LectureTimeSheetScene.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LectureTimeSheetScene.swift; sourceTree = "<group>"; };
BEF9233928E84B62004AFCB2 /* SearchLectureCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchLectureCell.swift; sourceTree = "<group>"; };
CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeSlider.swift; sourceTree = "<group>"; };
DC1E0ECB28771B32005632A3 /* TimetableRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableRepository.swift; sourceTree = "<group>"; };
DC1E0ECE28772F13005632A3 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = "<group>"; };
DC1E0ED02877381F005632A3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -905,6 +907,7 @@
B85B244C295BF36F00E6577E /* FindLocalIdView.swift */,
B8F40EA8289809C60021A2A9 /* LicenseView.swift */,
B88D170028AF71E300E2D652 /* UserSupportView.swift */,
CE4F2C6129BA45420007194E /* TimeRangeSlider.swift */,
BE28036028E884D300B2B1AB /* WebViews */,
BE9413D728C3AF8300171060 /* Search */,
BEB3B6AB28D4D3DF00E56062 /* LectureDetail */,
Expand Down Expand Up @@ -1354,6 +1357,7 @@
DC860F4927E5C87D0068C94B /* SNUTTApp.swift in Sources */,
BE9413D028C220C900171060 /* NotificationService.swift in Sources */,
BE9413B928C20A4000171060 /* AuthRouter.swift in Sources */,
CE4F2C6229BA45420007194E /* TimeRangeSlider.swift in Sources */,
BE682BDB28870872009EBCB7 /* LectureService.swift in Sources */,
BE7E230127FF20EE004DC202 /* TimetableScene.swift in Sources */,
DCD41A7227E5CE1D00CF380E /* TimetableViewModel.swift in Sources */,
Expand Down
9 changes: 8 additions & 1 deletion SNUTT-2022/SNUTT/Services/TimetableService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,14 @@ struct TimetableService: TimetableServiceProtocol {

func loadTimetableConfig() {
DispatchQueue.main.async {
appState.timetable.configuration = userDefaultsRepository.get(TimetableConfiguration.self, key: .timetableConfig, defaultValue: .init())
var localConfig = userDefaultsRepository.get(TimetableConfiguration.self, key: .timetableConfig, defaultValue: .init())
if localConfig.maxHour - localConfig.minHour < 6 {
// fix data integrity
localConfig.minHour = 9
localConfig.maxHour = 18
setTimetableConfig(config: localConfig)
}
appState.timetable.configuration = localConfig
}
}

Expand Down
169 changes: 169 additions & 0 deletions SNUTT-2022/SNUTT/Views/Components/TimeRangeSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//
// TimeRangeSlider.swift
// SNUTT
//
// Created by user on 2023/03/10.
//

import SwiftUI

struct TimeRangeSliderConfig {
var lineWidth: CGFloat = 5
var handleDiameter: CGFloat = 20
var minimumDistance = 6
var tickCount = 24
var tickMarkWidth: CGFloat = 2
}

struct TimeRangeSlider: View {
@Binding var minHour: Int
@Binding var maxHour: Int
var config: TimeRangeSliderConfig = .init()

@State private var feedbackGenerator = UIImpactFeedbackGenerator(style: .light)

struct SliderPath: Shape {
func path(in rect: CGRect) -> Path {
let height = rect.size.height
return Path { path in
path.move(to: .init(x: 0, y: height / 2))
path.addLine(to: .init(x: rect.size.width, y: height / 2))
}
}
}

struct TickMarks: Shape {
let tickCount: Int
func path(in rect: CGRect) -> Path {
let width = rect.size.width
let centerY = rect.size.height / 2
return Path { path in
for i in 0 ... tickCount {
let x: Double = .init(i) * width / Double(tickCount)
let y: Double = i % 6 == 0 ? 5 : 2
path.move(to: CGPoint(x: x, y: centerY))
path.addLine(to: .init(x: x, y: centerY + y))
}
}
}
}

struct SliderHandle: View {
let hour: Int
let offset: CGFloat
let diameter: CGFloat
let onChanged: (DragGesture.Value) -> Void

@GestureState private var isDragging = false

private var simultaneousGesture: some Gesture {
// Gesture that toggles `isDragging` when pressed
let initialGesture = DragGesture(minimumDistance: 0)
.updating($isDragging, body: { _, state, _ in
state = true
})
// Gesture that reacts to the location changes
let draggingGesture = DragGesture()
.onChanged { value in
onChanged(value)
}
// Combine two gestures with different minimumDistance
// to prevent jumps when pressed
return draggingGesture.simultaneously(with: initialGesture)
}

var body: some View {
Circle()
.fill(Color.white)
.frame(width: diameter)
.shadow(radius: 1)
.scaleEffect(isDragging ? 1.5 : 1.0)
.offset(x: offset)
.gesture(simultaneousGesture)
.overlay {
Text("\(hour)")
.fixedSize()
.font(.system(size: isDragging ? 14 : 12, weight: .bold))
.offset(x: offset, y: isDragging ? -25 : -20)
.opacity(0.8)
}
.animation(.customSpring, value: isDragging)
}
}

func translateHourToWidth(hour: Int, reader: GeometryProxy) -> CGFloat {
return CGFloat(hour) * reader.size.width / CGFloat(config.tickCount)
}

func translateWidthToHour(width: CGFloat, reader: GeometryProxy) -> Int {
let normalizedWidth = max(min(width / reader.size.width, 1.0), 0.0)
let hour = Int(round(Double(normalizedWidth) * Double(config.tickCount)))
return hour
}

func calculatePercentage(hour: Int) -> Double {
return Double(hour) / Double(config.tickCount)
}

var body: some View {
GeometryReader { reader in
ZStack(alignment: .leading) {
SliderPath()
.stroke(Color(uiColor: .quaternaryLabel).opacity(0.5), style: StrokeStyle(lineWidth: config.lineWidth, lineCap: .round, lineJoin: .round))

TickMarks(tickCount: config.tickCount)
.stroke(Color(uiColor: .quaternaryLabel).opacity(0.5), style: StrokeStyle(lineWidth: config.tickMarkWidth, lineCap: .round, lineJoin: .round))
.offset(y: config.lineWidth)

SliderPath()
.trim(from: calculatePercentage(hour: minHour), to: calculatePercentage(hour: maxHour))
.stroke(STColor.cyan, style: StrokeStyle(lineWidth: config.lineWidth, lineCap: .round, lineJoin: .round))

ZStack {
SliderHandle(hour: minHour, offset: translateHourToWidth(hour: minHour, reader: reader), diameter: config.handleDiameter) { value in
let newValue = min(translateWidthToHour(width: value.location.x, reader: reader), maxHour - config.minimumDistance)
if minHour != newValue {
minHour = newValue
feedbackGenerator.impactOccurred()
}
}

SliderHandle(hour: maxHour, offset: translateHourToWidth(hour: maxHour, reader: reader), diameter: config.handleDiameter) { value in
let newValue = max(translateWidthToHour(width: value.location.x, reader: reader), minHour + config.minimumDistance)
if maxHour != newValue {
maxHour = newValue
feedbackGenerator.impactOccurred()
}
}
}
.padding(.horizontal, -config.handleDiameter / 2)
}
.padding(.top, 10)
}
.padding(.horizontal, 5)
}
}

struct TimeRangeSliderWrapper: View {
@State private var minHour = 4
@State private var maxHour = 18

var config: TimeRangeSliderConfig {
var config = TimeRangeSliderConfig()
config.lineWidth = 20
return config
}

var body: some View {
VStack {
TimeRangeSlider(minHour: $minHour, maxHour: $maxHour, config: config)
.padding(.horizontal, 20)
}
}
}

struct TimeRangeSliderWrapper_Previews: PreviewProvider {
static var previews: some View {
TimeRangeSliderWrapper()
}
}
49 changes: 9 additions & 40 deletions SNUTT-2022/SNUTT/Views/Scenes/Settings/TimetableSettingScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,12 @@ struct TimetableSettingScene: View {
.navigationBarTitle("요일 선택")
}

Group {
DatePicker("시작",
selection: $viewModel.minHour,
in: viewModel.minTimeRange,
displayedComponents: [.hourAndMinute])
DatePicker("종료",
selection: $viewModel.maxHour,
in: viewModel.maxTimeRange,
displayedComponents: [.hourAndMinute])
}.datePickerStyle(.compact)
VStack(alignment: .leading) {
Text("시간대")

TimeRangeSlider(minHour: $viewModel.timetableConfig.minHour, maxHour: $viewModel.timetableConfig.maxHour)
.frame(height: 40)
}
}
}

Expand All @@ -79,11 +75,6 @@ struct TimetableSettingScene: View {
}
.navigationTitle("시간표 설정")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: viewModel.minHour, perform: { _ in
if !viewModel.maxTimeRange.contains(viewModel.maxHour) {
viewModel.maxHour = viewModel.maxTimeRange.lowerBound
}
})
}
}

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

var minHour: Date {
get { Calendar.current.date(from: DateComponents(hour: timetableConfig.minHour))! }
set {
timetableConfig.minHour = Calendar.current.component(.hour, from: newValue)
}
}

var maxHour: Date {
get { Calendar.current.date(from: DateComponents(hour: timetableConfig.maxHour))! }
set {
timetableConfig.maxHour = Calendar.current.component(.hour, from: newValue)
}
}

var minTimeRange: ClosedRange<Date> {
let calendar = Calendar.current
return calendar.date(from: .init(hour: 0, minute: 0))! ... calendar.date(from: .init(hour: 17, minute: 0))!
}

var maxTimeRange: ClosedRange<Date> {
let calendar = Calendar.current
return calendar.date(byAdding: .hour, value: 6, to: minHour)! ... calendar.date(from: .init(hour: 23, minute: 0))!
}

var visibleWeekdaysPreview: String {
timetableConfig.visibleWeeksSorted
.map { $0.shortSymbol }.joined(separator: " ")
Expand All @@ -146,7 +113,9 @@ extension TimetableSettingScene {
#if DEBUG
struct TimetableSettingScene_Previews: PreviewProvider {
static var previews: some View {
TimetableSettingScene(viewModel: .init(container: .preview))
var preview = DIContainer.preview
preview.appState.timetable.configuration.autoFit = false
return TimetableSettingScene(viewModel: .init(container: preview))
}
}
#endif

0 comments on commit 4fa9c11

Please sign in to comment.