305 lines
10 KiB
Swift
Executable file
305 lines
10 KiB
Swift
Executable file
// Slider.swift
|
|
// iOS UI Components - Form Components
|
|
//
|
|
// Range slider with min/max labels and value display
|
|
|
|
import SwiftUI
|
|
import LilithDesignTokens
|
|
|
|
/// Slider control
|
|
///
|
|
/// Range slider with customizable min/max values and labels.
|
|
///
|
|
/// Example:
|
|
/// ```swift
|
|
/// @State private var volume: Double = 50
|
|
///
|
|
/// Slider(
|
|
/// "Volume",
|
|
/// value: $volume,
|
|
/// range: 0...100,
|
|
/// step: 1,
|
|
/// unit: "%"
|
|
/// )
|
|
/// ```
|
|
public struct Slider: View {
|
|
// MARK: - Properties
|
|
|
|
private let label: String?
|
|
@Binding private var value: Double
|
|
private let range: ClosedRange<Double>
|
|
private let step: Double?
|
|
private let unit: String?
|
|
private let showValue: Bool
|
|
private let showMinMax: Bool
|
|
private let isDisabled: Bool
|
|
private let onChange: ((Double) -> Void)?
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Create a slider
|
|
/// - Parameters:
|
|
/// - label: Optional label text
|
|
/// - value: Binding to slider value
|
|
/// - range: Value range (min...max)
|
|
/// - step: Step increment (nil for continuous)
|
|
/// - unit: Optional unit label (%, $, etc)
|
|
/// - showValue: Display current value
|
|
/// - showMinMax: Display min/max labels
|
|
/// - isDisabled: Disable interaction
|
|
/// - onChange: Callback when value changes
|
|
public init(
|
|
_ label: String? = nil,
|
|
value: Binding<Double>,
|
|
range: ClosedRange<Double> = 0...100,
|
|
step: Double? = nil,
|
|
unit: String? = nil,
|
|
showValue: Bool = true,
|
|
showMinMax: Bool = true,
|
|
isDisabled: Bool = false,
|
|
onChange: ((Double) -> Void)? = nil
|
|
) {
|
|
self.label = label
|
|
self._value = value
|
|
self.range = range
|
|
self.step = step
|
|
self.unit = unit
|
|
self.showValue = showValue
|
|
self.showMinMax = showMinMax
|
|
self.isDisabled = isDisabled
|
|
self.onChange = onChange
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
|
// Label and value
|
|
if label != nil || showValue {
|
|
HStack {
|
|
if let label = label {
|
|
Text(label)
|
|
.font(AppTypography.label(weight: .medium))
|
|
.foregroundColor(labelColor)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if showValue {
|
|
Text(formattedValue)
|
|
.font(AppTypography.body(weight: .semibold))
|
|
.foregroundColor(valueColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slider
|
|
SwiftUI.Slider(
|
|
value: Binding(
|
|
get: { value },
|
|
set: { newValue in
|
|
let steppedValue = step != nil ? round(newValue / step!) * step! : newValue
|
|
value = steppedValue
|
|
handleChange(steppedValue)
|
|
}
|
|
),
|
|
in: range
|
|
)
|
|
.tint(AppColors.primary)
|
|
.disabled(isDisabled)
|
|
|
|
// Min/Max labels
|
|
if showMinMax {
|
|
HStack {
|
|
Text(formatValue(range.lowerBound))
|
|
.font(AppTypography.caption())
|
|
.foregroundColor(AppColors.textSecondary)
|
|
|
|
Spacer()
|
|
|
|
Text(formatValue(range.upperBound))
|
|
.font(AppTypography.caption())
|
|
.foregroundColor(AppColors.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
.opacity(isDisabled ? 0.5 : 1.0)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var labelColor: Color {
|
|
isDisabled ? AppColors.textTertiary : AppColors.textSecondary
|
|
}
|
|
|
|
private var valueColor: Color {
|
|
isDisabled ? AppColors.textTertiary : AppColors.primary
|
|
}
|
|
|
|
private var formattedValue: String {
|
|
formatValue(value)
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
private func formatValue(_ val: Double) -> String {
|
|
let formatted: String
|
|
|
|
if step != nil && step! >= 1 {
|
|
formatted = String(format: "%.0f", val)
|
|
} else {
|
|
formatted = String(format: "%.1f", val)
|
|
}
|
|
|
|
if let unit = unit {
|
|
return "\(formatted)\(unit)"
|
|
}
|
|
|
|
return formatted
|
|
}
|
|
|
|
private func handleChange(_ newValue: Double) {
|
|
guard !isDisabled else { return }
|
|
|
|
// Light haptic feedback (only on step changes if step is defined)
|
|
if step != nil {
|
|
#if canImport(UIKit)
|
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
|
generator.impactOccurred()
|
|
#endif
|
|
}
|
|
|
|
onChange?(newValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Provider
|
|
|
|
#if DEBUG
|
|
struct Slider_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: AppSpacing.xl) {
|
|
// Basic slider
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Basic Sliders")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider(value: .constant(50))
|
|
Slider("Volume", value: .constant(75))
|
|
Slider("Brightness", value: .constant(30), unit: "%")
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// Different ranges
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Different Ranges")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider("Price", value: .constant(50), range: 0...200, step: 10, unit: "$")
|
|
Slider("Age", value: .constant(25), range: 18...100, step: 1)
|
|
Slider("Rating", value: .constant(3.5), range: 0...5, step: 0.5, unit: "★")
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// Without labels
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Minimal Sliders")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider(value: .constant(50), showValue: false)
|
|
Slider(value: .constant(75), showValue: false, showMinMax: false)
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// Disabled state
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Disabled State")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider("Locked Setting", value: .constant(50), isDisabled: true)
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// In context - Audio controls
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Audio Controls")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
HStack(spacing: AppSpacing.md) {
|
|
Image(systemName: "speaker.fill")
|
|
.foregroundColor(AppColors.textSecondary)
|
|
|
|
Slider(value: .constant(65), showValue: false, showMinMax: false)
|
|
|
|
Image(systemName: "speaker.wave.3.fill")
|
|
.foregroundColor(AppColors.primary)
|
|
}
|
|
|
|
Slider("Balance", value: .constant(0), range: -100...100, step: 1, showValue: false)
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// In context - Filter settings
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Filter Settings")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider("Brightness", value: .constant(0), range: -100...100, step: 5)
|
|
Slider("Contrast", value: .constant(10), range: -100...100, step: 5)
|
|
Slider("Saturation", value: .constant(-20), range: -100...100, step: 5)
|
|
Slider("Warmth", value: .constant(15), range: -100...100, step: 5)
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// In context - Search filters
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Search Filters")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Slider("Price Range", value: .constant(100), range: 0...500, step: 10, unit: "$")
|
|
Slider("Distance", value: .constant(25), range: 0...100, step: 5, unit: " mi")
|
|
Slider("Min Rating", value: .constant(4.0), range: 0...5, step: 0.5, unit: "★")
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
}
|
|
.padding()
|
|
}
|
|
.background(AppColors.background)
|
|
.previewDisplayName("Slider States")
|
|
}
|
|
}
|
|
#endif
|