Skip to content

Commit 9f7a9bd

Browse files
committed
Add Toggle (Switch)
1 parent 4e7eeeb commit 9f7a9bd

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
//
2+
// Switch.swift
3+
// Demo
4+
//
5+
// Created by Ming on 12/6/2025.
6+
//
7+
8+
import SwiftUI
9+
import SwiftGlass
10+
11+
struct Toggle: View {
12+
@State private var isOn: Bool = false
13+
14+
var body: some View {
15+
ZStack {
16+
bg
17+
HStack {
18+
VStack(spacing: 20) {
19+
Switch("", isOn: $isOn)
20+
.accentColor(.green)
21+
22+
Switch("", isOn: $isOn)
23+
.accentColor(.clear)
24+
25+
Switch("", isOn: $isOn)
26+
.accentColor(.black)
27+
28+
Switch("", isOn: $isOn)
29+
.accentColor(.blue)
30+
31+
Switch("", isOn: $isOn)
32+
.accentColor(.brown)
33+
34+
Switch("", isOn: $isOn)
35+
.accentColor(.cyan)
36+
37+
Spacer()
38+
}
39+
40+
VStack(spacing: 20) {
41+
Switch("", isOn: $isOn)
42+
.accentColor(.indigo)
43+
44+
Switch("", isOn: $isOn)
45+
.accentColor(.mint)
46+
47+
Switch("", isOn: $isOn)
48+
.accentColor(.orange)
49+
50+
Switch("", isOn: $isOn)
51+
.accentColor(.pink)
52+
53+
Switch("", isOn: $isOn)
54+
.accentColor(.purple)
55+
56+
Switch("", isOn: $isOn)
57+
.accentColor(.red)
58+
59+
Spacer()
60+
}
61+
62+
VStack(spacing: 20) {
63+
Switch("", isOn: $isOn)
64+
.accentColor(.teal)
65+
66+
Switch("", isOn: $isOn)
67+
.accentColor(.white)
68+
69+
Switch("", isOn: $isOn)
70+
.accentColor(.yellow)
71+
72+
Switch("", isOn: $isOn)
73+
.accentColor(.accentColor)
74+
75+
Switch("", isOn: $isOn)
76+
.accentColor(.primary)
77+
78+
Switch("", isOn: $isOn)
79+
.accentColor(.secondary)
80+
81+
Spacer()
82+
}
83+
Spacer()
84+
}
85+
.padding()
86+
}
87+
}
88+
89+
var bg: some View {
90+
LinearGradient(colors: [Color.clear, Color.blue.opacity(0.5)], startPoint: .topLeading, endPoint: .bottomTrailing)
91+
.ignoresSafeArea()
92+
}
93+
}
94+
95+
struct Switch: View {
96+
let title: String
97+
@Binding var isOn: Bool
98+
99+
@State private var dragOffset: CGFloat = 0
100+
@State private var isDragging: Bool = false
101+
@State private var dragDirection: CGFloat = 0
102+
@State private var lastDragValue: CGFloat = 0
103+
104+
private let toggleWidth: CGFloat = 60
105+
private let thumbSize: CGFloat = 26
106+
private let maxOffset: CGFloat = 15
107+
108+
init(_ title: String, isOn: Binding<Bool>) {
109+
self.title = title
110+
self._isOn = isOn
111+
}
112+
113+
private var fillProgress: CGFloat {
114+
if isDragging {
115+
let progress = (dragOffset + maxOffset) / (maxOffset * 2)
116+
return max(0, min(1, progress))
117+
} else {
118+
return isOn ? 1.0 : 0.0
119+
}
120+
}
121+
122+
var body: some View {
123+
HStack {
124+
ZStack {
125+
// Base track (gray background)
126+
RoundedRectangle(cornerRadius: 25)
127+
.fill(LinearGradient(
128+
colors: isOn ? [.accentColor.opacity(0.3), .accentColor.opacity(1.0)] : [.gray.opacity(0.3), .clear],
129+
startPoint: .leading,
130+
endPoint: .trailing
131+
))
132+
.frame(width: toggleWidth, height: 30)
133+
.glass()
134+
135+
if isDragging {
136+
// Progressive fill container with proper right-to-left gray unfill
137+
RoundedRectangle(cornerRadius: 25)
138+
.fill(Color.clear)
139+
.frame(width: toggleWidth, height: 30)
140+
.overlay(
141+
ZStack {
142+
// Base fill (toggleColor or gray depending on direction)
143+
if dragDirection >= 0 && !isOn {
144+
// Dragging right - toggleColor fill from left
145+
HStack {
146+
Rectangle()
147+
.fill(
148+
LinearGradient(
149+
colors: [.accentColor.opacity(0.3), .accentColor.opacity(1.0)],
150+
startPoint: .leading,
151+
endPoint: .trailing
152+
)
153+
)
154+
.frame(width: toggleWidth * fillProgress, height: 30)
155+
156+
Spacer(minLength: 0)
157+
}
158+
} else {
159+
// Dragging left - gray "unfill" from right
160+
HStack {
161+
Spacer(minLength: 0)
162+
163+
Rectangle()
164+
.fill(
165+
LinearGradient(
166+
colors: [Color.gray.opacity(0.8), Color.gray.opacity(0.1)],
167+
startPoint: .leading,
168+
endPoint: .trailing
169+
)
170+
)
171+
.frame(width: toggleWidth * (1.0 - fillProgress), height: 30)
172+
}
173+
}
174+
}
175+
)
176+
.clipShape(RoundedRectangle(cornerRadius: 25))
177+
}
178+
179+
// Toggle thumb
180+
Circle()
181+
.fill(isDragging ? Color.white.opacity(0.9) : Color.white.opacity(0.85))
182+
.frame(width: thumbSize, height: thumbSize)
183+
.glass()
184+
.offset(x: isDragging ? dragOffset : (isOn ? maxOffset : -maxOffset))
185+
.scaleEffect(isDragging ? 1.25 : 1.0)
186+
.animation(.easeInOut(duration: 0.3), value: isDragging ? dragOffset : (isOn ? maxOffset : -maxOffset))
187+
.gesture(
188+
DragGesture()
189+
.onChanged { value in
190+
if !isDragging {
191+
isDragging = true
192+
lastDragValue = value.translation.width
193+
} else {
194+
// Calculate drag direction based on movement
195+
dragDirection = value.translation.width - lastDragValue
196+
lastDragValue = value.translation.width
197+
}
198+
199+
// Calculate position based on drag from initial position
200+
let startPosition = isOn ? maxOffset : -maxOffset
201+
let newOffset = startPosition + value.translation.width
202+
dragOffset = min(maxOffset, max(-maxOffset, newOffset))
203+
}
204+
.onEnded { value in
205+
let threshold: CGFloat = 0.0 // Use center as threshold
206+
207+
let newState = dragOffset > threshold
208+
209+
// Only animate if state actually changes
210+
if newState != isOn {
211+
withAnimation(.easeInOut(duration: 0.3)) {
212+
isOn = newState
213+
isDragging = false
214+
}
215+
} else {
216+
isDragging = false
217+
}
218+
219+
dragOffset = 0
220+
dragDirection = 0
221+
lastDragValue = 0
222+
}
223+
)
224+
}
225+
}
226+
.onTapGesture {
227+
withAnimation {
228+
isOn.toggle()
229+
}
230+
}
231+
}
232+
}
233+
234+
#Preview("Dark") {
235+
Toggle()
236+
.preferredColorScheme(.dark)
237+
}

0 commit comments

Comments
 (0)