swift-forms/Sources/LilithForms/Switch.swift

318 lines
9.9 KiB
Swift
Executable file

// Switch.swift
// iOS UI Components - Form Components
//
// Toggle switch with haptic feedback
import SwiftUI
import LilithDesignTokens
/// Toggle switch control
///
/// Switch control with label and haptic feedback.
///
/// Example:
/// ```swift
/// @State private var isEnabled = true
///
/// Switch(
/// "Enable notifications",
/// isOn: $isEnabled
/// )
/// ```
public struct Switch: View {
// MARK: - Properties
private let label: String?
@Binding private var isOn: Bool
private let isDisabled: Bool
private let onChange: ((Bool) -> Void)?
// MARK: - Initialization
/// Create a switch
/// - Parameters:
/// - label: Optional label text
/// - isOn: Binding to on/off state
/// - isDisabled: Disable interaction
/// - onChange: Callback when state changes
public init(
_ label: String? = nil,
isOn: Binding<Bool>,
isDisabled: Bool = false,
onChange: ((Bool) -> Void)? = nil
) {
self.label = label
self._isOn = isOn
self.isDisabled = isDisabled
self.onChange = onChange
}
// MARK: - Body
public var body: some View {
Toggle(isOn: Binding(
get: { isOn },
set: { newValue in
isOn = newValue
handleChange(newValue)
}
)) {
if let label = label {
Text(label)
.font(AppTypography.body())
.foregroundColor(labelColor)
}
}
.toggleStyle(CustomSwitchStyle(isDisabled: isDisabled))
.disabled(isDisabled)
.accessibilityLabel(label ?? "Switch")
.accessibilityAddTraits(isOn ? [.isSelected] : [])
.accessibilityHint("Double tap to \(isOn ? "turn off" : "turn on")")
}
// MARK: - Computed Properties
private var labelColor: Color {
isDisabled ? AppColors.textTertiary : AppColors.textPrimary
}
// MARK: - Methods
private func handleChange(_ newValue: Bool) {
guard !isDisabled else { return }
// Medium haptic feedback
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
#endif
onChange?(newValue)
}
}
// MARK: - Custom Switch Style
struct CustomSwitchStyle: ToggleStyle {
let isDisabled: Bool
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
Spacer()
// Switch track
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
// Track background
Capsule()
.fill(trackColor(isOn: configuration.isOn))
.frame(width: 51, height: 31)
// Thumb
Circle()
.fill(.white)
.shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 2)
.frame(width: 27, height: 27)
.padding(2)
}
.onTapGesture {
if !isDisabled {
withAnimation(AppAnimations.fast) {
configuration.isOn.toggle()
}
}
}
}
}
private func trackColor(isOn: Bool) -> Color {
if isDisabled {
return AppColors.Gray.gray700
}
return isOn ? AppColors.primary : AppColors.Gray.gray600
}
}
// MARK: - Preview Provider
#if DEBUG
struct Switch_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppSpacing.xl) {
// Basic states
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Basic States")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
Switch("Off", isOn: .constant(false))
Switch("On", isOn: .constant(true))
Switch("Disabled Off", isOn: .constant(false), isDisabled: true)
Switch("Disabled On", isOn: .constant(true), isDisabled: true)
}
.padding()
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
Divider()
// Without labels
VStack(alignment: .leading, spacing: AppSpacing.md) {
Text("Without Labels")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
HStack {
Switch(isOn: .constant(false))
Switch(isOn: .constant(true))
Switch(isOn: .constant(false), isDisabled: true)
}
}
.padding()
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
Divider()
// In context - Settings list
VStack(alignment: .leading, spacing: 0) {
Text("Notification Settings")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
.padding()
Group {
settingsRow(
title: "Push Notifications",
subtitle: "Receive notifications on this device",
isOn: true
)
Divider()
.padding(.leading)
settingsRow(
title: "Email Notifications",
subtitle: "Receive updates via email",
isOn: true
)
Divider()
.padding(.leading)
settingsRow(
title: "SMS Notifications",
subtitle: "Receive text message alerts",
isOn: false
)
Divider()
.padding(.leading)
settingsRow(
title: "Marketing Emails",
subtitle: "Receive product updates and offers",
isOn: false
)
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
Divider()
// In context - Privacy controls
VStack(alignment: .leading, spacing: 0) {
Text("Privacy Controls")
.font(AppTypography.h3())
.foregroundColor(AppColors.textPrimary)
.padding()
Group {
privacyRow(
icon: "eye",
title: "Profile Visible",
description: "Allow others to see your profile",
isOn: true
)
Divider()
.padding(.leading, 56)
privacyRow(
icon: "location",
title: "Location Services",
description: "Share your approximate location",
isOn: false
)
Divider()
.padding(.leading, 56)
privacyRow(
icon: "hand.raised",
title: "Do Not Track",
description: "Opt out of analytics tracking",
isOn: true
)
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
}
.padding()
}
.background(AppColors.background)
.previewDisplayName("Switch States")
}
static func settingsRow(title: String, subtitle: String, isOn: Bool) -> some View {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text(title)
.font(AppTypography.body())
.foregroundColor(AppColors.textPrimary)
Text(subtitle)
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
Spacer()
Switch(isOn: .constant(isOn))
}
.padding()
}
}
static func privacyRow(icon: String, title: String, description: String, isOn: Bool) -> some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(AppColors.primary)
.frame(width: 32, height: 32)
.background(AppColors.primary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.sm))
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text(title)
.font(AppTypography.body(weight: .medium))
.foregroundColor(AppColors.textPrimary)
Text(description)
.font(AppTypography.caption())
.foregroundColor(AppColors.textSecondary)
}
Spacer()
Switch(isOn: .constant(isOn))
}
.padding()
}
}
#endif