Created
December 13, 2023 18:52
-
-
Save Kirill12a/3016cf6d0099790d6d655e104b819109 to your computer and use it in GitHub Desktop.
Custom slider for changing time on a time scale (hours, days, weeks, months, year)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
enum SliderConstants { | |
static let sliderHeight: CGFloat = 60 | |
static let horizontalPadding: CGFloat = 10 | |
static let trackHeight: CGFloat = 8 | |
static let largeThumbSize: CGFloat = 24 | |
static let regularThumbSize: CGFloat = 16 | |
static let keyIntervalThreshold: CGFloat = 4 | |
static let labelOffsetY: CGFloat = -30 | |
static let thumbOffsetY: CGFloat = 25 | |
static let pointDiameter: CGFloat = 15 | |
} | |
struct KeyInterval: Hashable, Identifiable{ | |
let id = UUID() | |
let hours: CGFloat | |
let label: String | |
} | |
struct TimePicker: View { | |
@State private var sliderValue: CGFloat = 0.0 | |
let maxHours: CGFloat = 100 | |
let keyIntervals: [KeyInterval] = [ | |
KeyInterval(hours: 0, label: "1 час"), | |
KeyInterval(hours: 25, label: "1 день"), | |
KeyInterval(hours: 50, label: "1 неделя"), | |
KeyInterval(hours: 75, label: "1 месяц"), | |
KeyInterval(hours: 100, label: "1 год") | |
] | |
var body: some View { | |
VStack { | |
SliderView( | |
sliderValue: $sliderValue, | |
maxHours: maxHours, | |
keyIntervals: keyIntervals | |
) | |
.frame(height: SliderConstants.sliderHeight) | |
} | |
.padding() | |
} | |
} | |
// Вид слайдера | |
struct SliderView: View { | |
@Binding var sliderValue: CGFloat | |
let maxHours: CGFloat | |
let keyIntervals: [KeyInterval] | |
var body: some View { | |
GeometryReader { geometry in | |
ZStack(alignment: .leading) { | |
SliderTrackWithPoints( | |
sliderValue: $sliderValue, | |
maxHours: maxHours, | |
keyIntervals: keyIntervals, | |
geometry: geometry | |
) | |
SliderThumb( | |
sliderValue: $sliderValue, | |
maxHours: maxHours, | |
geometry: geometry, | |
keyIntervals: keyIntervals, | |
onCrossingInterval: handleIntervalCrossing | |
) | |
} | |
} | |
.padding(.horizontal, SliderConstants.horizontalPadding) | |
.frame(height: SliderConstants.sliderHeight) | |
} | |
// Обработка пересечения ключевого интервала | |
private func handleIntervalCrossing() { | |
let impactMed = UIImpactFeedbackGenerator(style: .medium) | |
impactMed.impactOccurred() | |
} | |
} | |
// Вид трека слайдера с точками | |
struct SliderTrackWithPoints: View { | |
@Binding var sliderValue: CGFloat | |
let maxHours: CGFloat | |
let keyIntervals: [KeyInterval] | |
let geometry: GeometryProxy | |
var body: some View { | |
ZStack { | |
CustomSliderTrack( | |
sliderValue: sliderValue, | |
maxHours: maxHours, | |
keyIntervals: keyIntervals) | |
.stroke(Color.gray.opacity(0.2), lineWidth: SliderConstants.trackHeight) | |
.overlay( | |
CustomSliderTrack( | |
sliderValue: sliderValue, | |
maxHours: maxHours, | |
keyIntervals: keyIntervals) | |
.stroke(Color.blue.opacity(0.8), lineWidth: SliderConstants.trackHeight) | |
.mask( | |
Rectangle() | |
.frame(width: sliderPosition(sliderValue, geometry: geometry) * 2 + 15) | |
.offset(x: -geometry.size.width / 2 - 7.5) | |
) | |
) | |
// Отображение меток на ключевых интервалах | |
ForEach(keyIntervals, id: \.self) { interval in | |
Text(interval.label) | |
.font(.caption) | |
.foregroundColor(.black) | |
.position( | |
x: sliderPosition(interval.hours, geometry: geometry), | |
y: geometry.size.height / 2 + SliderConstants.labelOffsetY) | |
} | |
} | |
} | |
// Вычисление позиции слайдера | |
private func sliderPosition( | |
_ value: CGFloat, | |
geometry: GeometryProxy | |
) -> CGFloat { | |
let proportion = min(max(value, 0), maxHours) / maxHours | |
return proportion * geometry.size.width | |
} | |
} | |
// Вид ползунка слайдера | |
struct SliderThumb: View { | |
@Binding var sliderValue: CGFloat | |
let maxHours: CGFloat | |
let geometry: GeometryProxy | |
let keyIntervals: [KeyInterval] | |
var onCrossingInterval: () -> Void | |
var isOnKeyInterval: Bool { | |
keyIntervals.contains { abs($0.hours - sliderValue) < SliderConstants.keyIntervalThreshold } | |
} | |
var body: some View { | |
ZStack { | |
// Текст под ручкой слайдера | |
Text(formattedTime) | |
.font(.caption) | |
.foregroundColor(.black) | |
.offset(y: SliderConstants.thumbOffsetY) | |
// Ручка слайдера | |
Circle() | |
.frame(width: isOnKeyInterval ? SliderConstants.largeThumbSize : SliderConstants.regularThumbSize, height: isOnKeyInterval ? SliderConstants.largeThumbSize : SliderConstants.regularThumbSize) | |
.foregroundColor(.blue) | |
.animation(.easeInOut, value: isOnKeyInterval) | |
} | |
.position( | |
x: sliderPosition(sliderValue, geometry: geometry), | |
y: geometry.size.height / 2 | |
) | |
.gesture( | |
DragGesture() | |
.onChanged { value in | |
let previousValue = sliderValue | |
sliderValue = valueFromPosition(value.location.x, geometry: geometry) | |
if crossedInterval(previousValue: previousValue, newValue: sliderValue) { | |
onCrossingInterval() | |
} | |
} | |
) | |
} | |
// Форматирование времени под ручкой | |
var formattedTime: String { | |
let proportion = sliderValue / maxHours | |
switch proportion { | |
case 0..<0.25: return "\(max(1, Int(proportion / 0.25 * 24))) часов" | |
case 0.25..<0.5: return "\(max(1, Int((proportion - 0.25) / 0.25 * 7))) дней" | |
case 0.5..<0.75: return "\(max(1, Int((proportion - 0.5) / 0.25 * 4))) недель" | |
case 0.75..<1.0: return "\(max(1, Int((proportion - 0.75) / 0.25 * 12))) месяцев" | |
default: return "1 год" | |
} | |
} | |
// Вычисление позиции и значения слайдера | |
private func sliderPosition(_ value: CGFloat, geometry: GeometryProxy) -> CGFloat { | |
let proportion = min(max(value, 0), maxHours) / maxHours | |
return proportion * geometry.size.width | |
} | |
private func valueFromPosition(_ position: CGFloat, geometry: GeometryProxy) -> CGFloat { | |
let proportion = position / geometry.size.width | |
return min(max(proportion * maxHours, 0), maxHours) | |
} | |
// Проверка пересечения нового ключевого интервала | |
private func crossedInterval(previousValue: CGFloat, newValue: CGFloat) -> Bool { | |
let previousInterval = keyIntervals.firstIndex(where: { previousValue < $0.hours }) | |
let newInterval = keyIntervals.firstIndex(where: { newValue < $0.hours }) | |
return previousInterval != newInterval | |
} | |
} | |
// Вид пользовательского трека слайдера | |
struct CustomSliderTrack: Shape { | |
var sliderValue: CGFloat | |
let maxHours: CGFloat | |
let keyIntervals: [KeyInterval] | |
func path(in rect: CGRect) -> Path { | |
var path = Path() | |
// Основная линия трека | |
path.addRoundedRect( | |
in: CGRect( | |
x: rect.minX, | |
y: rect.midY - SliderConstants.trackHeight / 2, | |
width: rect.width, | |
height: SliderConstants.trackHeight | |
), | |
cornerSize: CGSize( | |
width: SliderConstants.trackHeight / 2, | |
height: SliderConstants.trackHeight / 2 | |
) | |
) | |
// Точки на ключевых интервалах | |
for interval in keyIntervals { | |
let xPosition = interval.hours / maxHours * rect.width | |
path.addEllipse( | |
in: CGRect( | |
x: xPosition - SliderConstants.pointDiameter / 2, | |
y: rect.midY - SliderConstants.pointDiameter / 2, | |
width: SliderConstants.pointDiameter, | |
height: SliderConstants.pointDiameter | |
) | |
) | |
} | |
return path | |
} | |
} | |
// Предпросмотр | |
struct TimePicker_Previews: PreviewProvider { | |
static var previews: some View { | |
TimePicker() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment