Skip to content

Commit c22d663

Browse files
Merge pull request #65 from componentskit/SUSlider
SUSlider
2 parents 2842132 + 058dd67 commit c22d663

File tree

5 files changed

+357
-0
lines changed

5 files changed

+357
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import SwiftUI
2+
import ComponentsKit
3+
4+
struct SliderPreview: View {
5+
@State private var model = Self.initialModel
6+
@State private var currentValue: CGFloat = Self.initialValue
7+
8+
var body: some View {
9+
VStack {
10+
PreviewWrapper(title: "SwiftUI") {
11+
SUSlider(currentValue: self.$currentValue, model: self.model)
12+
}
13+
Form {
14+
ComponentColorPicker(selection: self.$model.color)
15+
ComponentRadiusPicker(selection: self.$model.cornerRadius) {
16+
Text("Custom: 2px").tag(ComponentRadius.custom(2))
17+
}
18+
SizePicker(selection: self.$model.size)
19+
Picker("Step", selection: self.$model.step) {
20+
Text("1").tag(CGFloat(1))
21+
Text("5").tag(CGFloat(5))
22+
Text("10").tag(CGFloat(10))
23+
Text("25").tag(CGFloat(25))
24+
Text("50").tag(CGFloat(50))
25+
}
26+
Picker("Style", selection: self.$model.style) {
27+
Text("Light").tag(SliderVM.Style.light)
28+
Text("Striped").tag(SliderVM.Style.striped)
29+
}
30+
}
31+
}
32+
}
33+
34+
// MARK: - Helpers
35+
36+
private static var initialValue: CGFloat {
37+
50
38+
}
39+
40+
private static var initialModel: SliderVM {
41+
var model = SliderVM()
42+
model.style = .light
43+
model.minValue = 0
44+
model.maxValue = 100
45+
model.cornerRadius = .full
46+
return model
47+
}
48+
}
49+
50+
#Preview {
51+
SliderPreview()
52+
}

Examples/DemosApp/DemosApp/Core/App.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ struct App: View {
5353
NavigationLinkWithTitle("Segmented Control") {
5454
SegmentedControlPreview()
5555
}
56+
NavigationLinkWithTitle("Slider") {
57+
SliderPreview()
58+
}
5659
NavigationLinkWithTitle("Text Input") {
5760
TextInputPreviewPreview()
5861
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Foundation
2+
3+
extension SliderVM {
4+
/// Defines the visual styles for the slider component.
5+
public enum Style {
6+
case light
7+
case striped
8+
}
9+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import SwiftUI
2+
3+
/// A model that defines the appearance properties for a slider component.
4+
public struct SliderVM: ComponentVM {
5+
/// The color of the slider.
6+
///
7+
/// Defaults to `.accent`.
8+
public var color: ComponentColor = .accent
9+
10+
/// The visual style of the slider component.
11+
///
12+
/// Defaults to `.light`.
13+
public var style: Style = .light
14+
15+
/// The size of the slider.
16+
///
17+
/// Defaults to `.medium`.
18+
public var size: ComponentSize = .medium
19+
20+
/// The minimum value of the slider.
21+
public var minValue: CGFloat = 0
22+
23+
/// The maximum value of the slider.
24+
public var maxValue: CGFloat = 100
25+
26+
/// The corner radius of the slider track and handle.
27+
///
28+
/// Defaults to `.full`.
29+
public var cornerRadius: ComponentRadius = .full
30+
31+
/// The step value for the slider.
32+
///
33+
/// Defaults to `1`.
34+
public var step: CGFloat = 1
35+
36+
/// Initializes a new instance of `SliderVM` with default values.
37+
public init() {}
38+
}
39+
40+
// MARK: - Shared Helpers
41+
42+
extension SliderVM {
43+
var trackHeight: CGFloat {
44+
switch self.size {
45+
case .small:
46+
return 6
47+
case .medium:
48+
return 12
49+
case .large:
50+
return 32
51+
}
52+
}
53+
var handleSize: CGSize {
54+
switch self.size {
55+
case .small, .medium:
56+
return CGSize(width: 20, height: 32)
57+
case .large:
58+
return CGSize(width: 40, height: 40)
59+
}
60+
}
61+
func cornerRadius(for height: CGFloat) -> CGFloat {
62+
switch self.cornerRadius {
63+
case .none:
64+
return 0
65+
case .small:
66+
return height / 3.5
67+
case .medium:
68+
return height / 3.0
69+
case .large:
70+
return height / 2.5
71+
case .full:
72+
return height / 2.0
73+
case .custom(let value):
74+
return min(value, height / 2)
75+
}
76+
}
77+
var trackSpacing: CGFloat {
78+
return 4
79+
}
80+
var handleOverlaySide: CGFloat {
81+
return 12
82+
}
83+
private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
84+
let stripeWidth: CGFloat = 2
85+
let stripeSpacing: CGFloat = 4
86+
let stripeAngle: Angle = .degrees(135)
87+
88+
let path = CGMutablePath()
89+
let step = stripeWidth + stripeSpacing
90+
let radians = stripeAngle.radians
91+
let dx = rect.height * tan(radians)
92+
93+
for x in stride(from: rect.width + rect.height, through: dx, by: -step) {
94+
let topLeft = CGPoint(x: x, y: 0)
95+
let topRight = CGPoint(x: x + stripeWidth, y: 0)
96+
let bottomLeft = CGPoint(x: x + dx, y: rect.height)
97+
let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height)
98+
path.move(to: topLeft)
99+
path.addLine(to: topRight)
100+
path.addLine(to: bottomRight)
101+
path.addLine(to: bottomLeft)
102+
path.closeSubpath()
103+
}
104+
105+
return path
106+
}
107+
}
108+
109+
extension SliderVM {
110+
func steppedValue(for offset: CGFloat, trackWidth: CGFloat) -> CGFloat {
111+
guard trackWidth > 0 else { return self.minValue }
112+
113+
let newProgress = offset / trackWidth
114+
115+
let newValue = self.minValue + newProgress * (self.maxValue - self.minValue)
116+
117+
if self.step > 0 {
118+
let stepsCount = (newValue / self.step).rounded()
119+
return stepsCount * self.step
120+
} else {
121+
return newValue
122+
}
123+
}
124+
}
125+
126+
extension SliderVM {
127+
func progress(for currentValue: CGFloat) -> CGFloat {
128+
let range = self.maxValue - self.minValue
129+
guard range > 0 else { return 0 }
130+
let normalized = (currentValue - self.minValue) / range
131+
return max(0, min(1, normalized))
132+
}
133+
}
134+
135+
extension SliderVM {
136+
var containerHeight: CGFloat {
137+
max(self.handleSize.height, self.trackHeight)
138+
}
139+
140+
private func sliderWidth(for totalWidth: CGFloat) -> CGFloat {
141+
max(0, totalWidth - self.handleSize.width - 2 * self.trackSpacing)
142+
}
143+
144+
func barWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
145+
let width = self.sliderWidth(for: totalWidth)
146+
return width * progress
147+
}
148+
149+
func backgroundWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
150+
let width = self.sliderWidth(for: totalWidth)
151+
let filled = width * progress
152+
return width - filled
153+
}
154+
}
155+
156+
// MARK: - UIKit Helpers
157+
158+
extension SliderVM {
159+
func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
160+
return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
161+
}
162+
163+
func shouldUpdateLayout(_ oldModel: Self) -> Bool {
164+
return self.style != oldModel.style ||
165+
self.size != oldModel.size ||
166+
self.step != oldModel.step
167+
}
168+
}
169+
170+
// MARK: - SwiftUI Helpers
171+
172+
extension SliderVM {
173+
func stripesPath(in rect: CGRect) -> Path {
174+
Path(self.stripesCGPath(in: rect))
175+
}
176+
}
177+
178+
// MARK: - Validation
179+
180+
extension SliderVM {
181+
func validateMinMaxValues() {
182+
if self.minValue > self.maxValue {
183+
assertionFailure("Min value must be less than max value")
184+
}
185+
}
186+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import SwiftUI
2+
3+
/// A SwiftUI component that displays a slider.
4+
public struct SUSlider: View {
5+
// MARK: - Properties
6+
7+
/// A model that defines the appearance properties.
8+
public var model: SliderVM
9+
10+
/// A binding to control the current value.
11+
@Binding public var currentValue: CGFloat
12+
13+
private var progress: CGFloat {
14+
self.model.progress(for: self.currentValue)
15+
}
16+
17+
// MARK: - Initializer
18+
19+
/// Initializer.
20+
/// - Parameters:
21+
/// - currentValue: A binding to the current value.
22+
/// - model: A model that defines the appearance properties.
23+
public init(
24+
currentValue: Binding<CGFloat>,
25+
model: SliderVM = .init()
26+
) {
27+
self._currentValue = currentValue
28+
self.model = model
29+
}
30+
31+
// MARK: - Body
32+
33+
public var body: some View {
34+
GeometryReader { geometry in
35+
let barWidth = self.model.barWidth(for: geometry.size.width, progress: self.progress)
36+
let backgroundWidth = self.model.backgroundWidth(for: geometry.size.width, progress: self.progress)
37+
38+
HStack(spacing: self.model.trackSpacing) {
39+
// Progress segment
40+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
41+
.foregroundStyle(self.model.color.main.color)
42+
.frame(width: barWidth, height: self.model.trackHeight)
43+
44+
// Handle
45+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.height))
46+
.foregroundStyle(self.model.color.main.color)
47+
.frame(width: self.model.handleSize.width, height: self.model.handleSize.height)
48+
.overlay(
49+
Group {
50+
if self.model.size == .large {
51+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleOverlaySide))
52+
.foregroundStyle(self.model.color.contrast.color)
53+
.frame(width: self.model.handleOverlaySide, height: self.model.handleOverlaySide)
54+
}
55+
}
56+
)
57+
.gesture(
58+
DragGesture(minimumDistance: 0)
59+
.onChanged { value in
60+
let totalWidth = geometry.size.width
61+
let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)
62+
63+
let currentLeft = barWidth
64+
let newOffset = currentLeft + value.translation.width
65+
66+
let clampedOffset = min(max(newOffset, 0), sliderWidth)
67+
self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
68+
}
69+
)
70+
71+
// Remaining segment
72+
Group {
73+
switch self.model.style {
74+
case .light:
75+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
76+
.foregroundStyle(self.model.color.background.color)
77+
.frame(width: backgroundWidth)
78+
case .striped:
79+
ZStack {
80+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
81+
.foregroundStyle(.clear)
82+
83+
StripesShapeSlider(model: self.model)
84+
.foregroundStyle(self.model.color.main.color)
85+
.cornerRadius(self.model.cornerRadius(for: self.model.trackHeight))
86+
}
87+
.frame(width: backgroundWidth)
88+
}
89+
}
90+
.frame(height: self.model.trackHeight)
91+
}
92+
}
93+
.frame(height: self.model.containerHeight)
94+
.onAppear {
95+
self.model.validateMinMaxValues()
96+
}
97+
}
98+
}
99+
// MARK: - Helpers
100+
101+
struct StripesShapeSlider: Shape, @unchecked Sendable {
102+
var model: SliderVM
103+
104+
func path(in rect: CGRect) -> Path {
105+
self.model.stripesPath(in: rect)
106+
}
107+
}

0 commit comments

Comments
 (0)