iOS/Sources/App/WebView/ExperimentalSpace/Components/VerticalToggleControl.swift
Copilot 8cdfb4f294
Experiment: Native HomeView/dashboard (#4142)
Basic implementation of a native dashboard - WIP

- [ ] Support more domains toggle action when tapping icon
  - [x] Light
  - [x] Cover
  - [x] Switch
- [ ] Create the equivalent of "more info dialog" for each domain
  - [x] Light
  - [x] Cover
  - [x] Switch
- [x] Add haptics
- [ ] Add more cards such as a camera card
- [ ] Find a way to display sensors information such as humidity and
temperature
- [ ] Allow customizing background
- [x] Allow opening the native dashboard from iOS controls
- [ ] Allow opening the native dashboard from Shortcuts
- [ ] Add an advanced option somewhere in settings that allow user to
see the native dashboard first instead of web UI
- [ ] Add error state
- [x] Add empty state
- [x] Add loading state
- [x] Allow reordering rooms
- [x] Allow reordering cards

<img width="1232" height="1158" alt="CleanShot 2026-01-05 at 16 19
59@2x"
src="https://github.com/user-attachments/assets/84ae8748-ec40-430c-a820-fcb6a88a6270"
/>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
2026-01-05 22:38:09 +00:00

227 lines
7.0 KiB
Swift

import SFSafeSymbols
import Shared
import SwiftUI
/// A reusable vertical toggle control with a draggable thumb and glass effect
@available(iOS 26.0, *)
struct VerticalToggleControl: View {
// MARK: - Configuration
struct Configuration {
var trackWidth: CGFloat = 120
var trackHeight: CGFloat = 320
var trackCornerRadius: CGFloat = 32
var thumbHeight: CGFloat = 140
var iconSize: CGFloat = 32
var iconOpacity: CGFloat = 0.8
var thumbPadding: CGFloat = DesignSystem.Spaces.one
var minimumDragDistance: CGFloat = 10
var toggleThreshold: CGFloat = 30
static let `default` = Configuration()
}
// MARK: - Properties
@Binding var isOn: Bool
var icon: SFSymbol
var accentColor: Color
var isDisabled: Bool
var configuration: Configuration
var onToggle: (() -> Void)?
@State private var isDragging = false
@State private var dragOffset: CGFloat = 0
@State private var triggerHaptic = 0
// MARK: - Initialization
/// Creates a vertical toggle control
/// - Parameters:
/// - isOn: Binding to the toggle state
/// - icon: SF Symbol to display in the thumb
/// - accentColor: Color for the active state (defaults to system accent)
/// - isDisabled: Whether the control is disabled
/// - configuration: Visual configuration for the control
/// - onToggle: Optional callback when the toggle state changes
init(
isOn: Binding<Bool>,
icon: SFSymbol = .powerCircle,
accentColor: Color = .accentColor,
isDisabled: Bool = false,
configuration: Configuration = .default,
onToggle: (() -> Void)? = nil
) {
self._isOn = isOn
self.icon = icon
self.accentColor = accentColor
self.isDisabled = isDisabled
self.configuration = configuration
self.onToggle = onToggle
}
// MARK: - Body
var body: some View {
ZStack {
// Track background with glass effect
RoundedRectangle(cornerRadius: configuration.trackCornerRadius, style: .continuous)
.fill(Color(uiColor: .secondarySystemFill))
.frame(width: configuration.trackWidth, height: configuration.trackHeight)
// Animated thumb
thumb
.offset(y: thumbOffset)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isOn)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
if !isDragging {
isDragging = true
}
// Clamp drag offset to track bounds
let availableTravel = configuration.trackHeight - configuration
.thumbHeight - (configuration.thumbPadding * 2)
let maxOffset = availableTravel / 2
dragOffset = min(max(gesture.translation.height, -maxOffset), maxOffset)
}
.onEnded { gesture in
let translation = gesture.translation.height
// If minimal movement, treat as tap
if abs(translation) < configuration.minimumDragDistance {
performToggle()
} else {
// Determine if we should toggle based on drag direction and distance
if abs(translation) > configuration.toggleThreshold {
if translation < 0, !isOn {
// Dragged up and currently off -> turn on
performToggle()
} else if translation > 0, isOn {
// Dragged down and currently on -> turn off
performToggle()
}
}
}
isDragging = false
dragOffset = 0
}
)
}
.disabled(isDisabled)
.opacity(isDisabled ? 0.6 : 1.0)
.sensoryFeedback(.impact, trigger: triggerHaptic)
}
// MARK: - Thumb
private var thumb: some View {
RoundedRectangle(
cornerRadius: configuration.trackCornerRadius - (configuration.thumbPadding / 2),
style: .continuous
)
.fill(isOn ? accentColor : Color(uiColor: .systemBackground))
.frame(
width: configuration.trackWidth - (configuration.thumbPadding * 2),
height: configuration.thumbHeight
)
.overlay(
Image(systemSymbol: icon)
.font(.system(size: configuration.iconSize, weight: .semibold))
.foregroundStyle(isOn ? .white : accentColor)
.opacity(configuration.iconOpacity)
)
.scaleEffect(isDragging ? 0.95 : 1.0)
}
// MARK: - Computed Properties
private var thumbOffset: CGFloat {
let availableTravel = configuration.trackHeight - configuration.thumbHeight - (configuration.thumbPadding * 2)
let baseOffset = isOn ? -(availableTravel / 2) : (availableTravel / 2)
// Add drag offset when dragging
return baseOffset + dragOffset
}
// MARK: - Actions
private func performToggle() {
triggerHaptic += 1
isOn.toggle()
onToggle?()
}
}
// MARK: - Preview
@available(iOS 26.0, *)
#Preview("Vertical Toggle - On") {
@Previewable @State var isOn = true
VStack {
Text(isOn ? "On" : "Off")
.font(.title)
VerticalToggleControl(
isOn: $isOn,
icon: .powerCircle,
accentColor: .blue
)
}
.padding()
}
@available(iOS 26.0, *)
#Preview("Vertical Toggle - Off") {
@Previewable @State var isOn = false
VStack {
Text(isOn ? "On" : "Off")
.font(.title)
VerticalToggleControl(
isOn: $isOn,
icon: .powerCircle,
accentColor: .green
)
}
.padding()
}
@available(iOS 26.0, *)
#Preview("Vertical Toggle - Custom Icon") {
@Previewable @State var isOn = false
VStack {
Text(isOn ? "Light On" : "Light Off")
.font(.title)
VerticalToggleControl(
isOn: $isOn,
icon: .lightbulb,
accentColor: .yellow
)
}
.padding()
}
@available(iOS 26.0, *)
#Preview("Vertical Toggle - Disabled") {
@Previewable @State var isOn = true
VStack {
Text("Disabled State")
.font(.title)
VerticalToggleControl(
isOn: $isOn,
icon: .powerCircle,
accentColor: .red,
isDisabled: true
)
}
.padding()
}