swift-forms/Sources/LilithForms/Slider.swift

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