Skip to content

Commit d6d9e83

Browse files
Merge pull request #43 from componentskit/SUProgressBar
SUProgressBar
2 parents d3c89ac + c08d790 commit d6d9e83

File tree

5 files changed

+303
-0
lines changed

5 files changed

+303
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import ComponentsKit
2+
import SwiftUI
3+
import UIKit
4+
5+
struct ProgressBarPreview: View {
6+
@State private var model = ProgressBarVM()
7+
@State private var currentValue: CGFloat = 0
8+
private let timer = Timer
9+
.publish(every: 0.1, on: .main, in: .common)
10+
.autoconnect()
11+
12+
var body: some View {
13+
VStack {
14+
PreviewWrapper(title: "SwiftUI") {
15+
SUProgressBar(currentValue: self.$currentValue, model: self.model)
16+
}
17+
Form {
18+
ComponentColorPicker(selection: self.$model.color)
19+
ComponentRadiusPicker(selection: self.$model.cornerRadius) {
20+
Text("Custom: 2px").tag(ComponentRadius.custom(2))
21+
}
22+
SizePicker(selection: self.$model.size)
23+
Picker("Style", selection: self.$model.style) {
24+
Text("Light").tag(ProgressBarVM.Style.light)
25+
Text("Filled").tag(ProgressBarVM.Style.filled)
26+
Text("Striped").tag(ProgressBarVM.Style.striped)
27+
}
28+
}
29+
}
30+
.onReceive(self.timer) { _ in
31+
if self.currentValue < self.model.maxValue {
32+
self.currentValue += (self.model.maxValue - self.model.minValue) / 100
33+
} else {
34+
self.currentValue = self.model.minValue
35+
}
36+
}
37+
}
38+
}
39+
40+
#Preview {
41+
ProgressBarPreview()
42+
}

Examples/DemosApp/DemosApp/Core/App.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ struct App: View {
2929
NavigationLinkWithTitle("Loading") {
3030
LoadingPreview()
3131
}
32+
NavigationLinkWithTitle("Progress Bar") {
33+
ProgressBarPreview()
34+
}
3235
NavigationLinkWithTitle("Modal (Bottom)") {
3336
BottomModalPreview()
3437
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
3+
extension ProgressBarVM {
4+
/// Defines the visual styles for the progress bar component.
5+
public enum Style {
6+
case light
7+
case filled
8+
case striped
9+
}
10+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import SwiftUI
2+
3+
/// A model that defines the appearance properties for a a progress bar component.
4+
public struct ProgressBarVM: ComponentVM {
5+
/// The color of the progress bar.
6+
///
7+
/// Defaults to `.accent`.
8+
public var color: ComponentColor = .accent
9+
10+
/// The visual style of the progress bar component.
11+
///
12+
/// Defaults to `.striped`.
13+
public var style: Style = .striped
14+
15+
/// The size of the progress bar.
16+
///
17+
/// Defaults to `.medium`.
18+
public var size: ComponentSize = .medium
19+
20+
/// The minimum value of the progress bar.
21+
public var minValue: CGFloat = 0
22+
23+
/// The maximum value of the progress bar.
24+
public var maxValue: CGFloat = 100
25+
26+
/// The corner radius of the progress bar.
27+
///
28+
/// Defaults to `.medium`.
29+
public var cornerRadius: ComponentRadius = .medium
30+
31+
/// Initializes a new instance of `ProgressBarVM` with default values.
32+
public init() {}
33+
}
34+
35+
// MARK: - Shared Helpers
36+
37+
extension ProgressBarVM {
38+
var innerBarPadding: CGFloat {
39+
return 3
40+
}
41+
42+
var barHeight: CGFloat {
43+
switch self.style {
44+
case .light:
45+
switch size {
46+
case .small:
47+
return 4
48+
case .medium:
49+
return 8
50+
case .large:
51+
return 12
52+
}
53+
case .filled, .striped:
54+
switch self.size {
55+
case .small:
56+
return 20
57+
case .medium:
58+
return 32
59+
case .large:
60+
return 42
61+
}
62+
}
63+
}
64+
65+
var computedCornerRadius: CGFloat {
66+
switch self.cornerRadius {
67+
case .none:
68+
return 0.0
69+
case .small:
70+
return self.barHeight / 3.5
71+
case .medium:
72+
return self.barHeight / 3.0
73+
case .large:
74+
return self.barHeight / 2.5
75+
case .full:
76+
return self.barHeight / 2.0
77+
case .custom(let value):
78+
return min(value, self.barHeight / 2)
79+
}
80+
}
81+
82+
var innerCornerRadius: CGFloat {
83+
return max(0, self.computedCornerRadius - self.innerBarPadding)
84+
}
85+
86+
var backgroundColor: UniversalColor {
87+
switch style {
88+
case .light:
89+
return self.color.background
90+
case .filled, .striped:
91+
return self.color.main
92+
}
93+
}
94+
95+
var barColor: UniversalColor {
96+
switch style {
97+
case .light:
98+
return self.color.main
99+
case .filled, .striped:
100+
return self.color.contrast
101+
}
102+
}
103+
104+
func shouldUpdateLayout(_ oldModel: Self) -> Bool {
105+
return self.size != oldModel.size
106+
}
107+
108+
private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
109+
let stripeWidth: CGFloat = 2
110+
let stripeSpacing: CGFloat = 4
111+
let stripeAngle: Angle = .degrees(135)
112+
113+
let path = CGMutablePath()
114+
let step = stripeWidth + stripeSpacing
115+
let radians = stripeAngle.radians
116+
let dx = rect.height * tan(radians)
117+
for x in stride(from: dx, through: rect.width + rect.height, by: step) {
118+
let topLeft = CGPoint(x: x, y: 0)
119+
let topRight = CGPoint(x: x + stripeWidth, y: 0)
120+
let bottomLeft = CGPoint(x: x + dx, y: rect.height)
121+
let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height)
122+
path.move(to: topLeft)
123+
path.addLine(to: topRight)
124+
path.addLine(to: bottomRight)
125+
path.addLine(to: bottomLeft)
126+
path.closeSubpath()
127+
}
128+
return path
129+
}
130+
131+
public func stripesPath(in rect: CGRect) -> Path {
132+
return Path(self.stripesCGPath(in: rect))
133+
}
134+
135+
public func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
136+
return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
137+
}
138+
}
139+
140+
// MARK: - Validation
141+
142+
extension ProgressBarVM {
143+
func validateMinMaxValues() {
144+
if self.minValue > self.maxValue {
145+
assertionFailure("Min value must be less than max value")
146+
}
147+
}
148+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftUI
2+
3+
/// A SwiftUI component that displays a progress bar.
4+
public struct SUProgressBar: View {
5+
// MARK: - Properties
6+
7+
/// A model that defines the appearance properties.
8+
public var model: ProgressBarVM
9+
/// A binding to control the current value.
10+
@Binding public var currentValue: CGFloat
11+
12+
private var progress: CGFloat {
13+
let range = self.model.maxValue - self.model.minValue
14+
15+
guard range > 0 else {
16+
return 0
17+
}
18+
19+
let progress = (self.currentValue - self.model.minValue) / range
20+
return max(0, min(1, progress))
21+
}
22+
23+
// MARK: - Initializer
24+
25+
/// Initializer.
26+
/// - Parameters:
27+
/// - currentValue: A binding to the current value.
28+
/// - model: A model that defines the appearance properties.
29+
public init(
30+
currentValue: Binding<CGFloat>,
31+
model: ProgressBarVM = .init()
32+
) {
33+
self._currentValue = currentValue
34+
self.model = model
35+
}
36+
37+
// MARK: - Body
38+
39+
public var body: some View {
40+
GeometryReader { geometry in
41+
switch self.model.style {
42+
case .light:
43+
HStack(spacing: 4) {
44+
RoundedRectangle(cornerRadius: self.model.computedCornerRadius)
45+
.foregroundStyle(self.model.barColor.color)
46+
.frame(width: geometry.size.width * self.progress)
47+
RoundedRectangle(cornerRadius: self.model.computedCornerRadius)
48+
.foregroundStyle(self.model.backgroundColor.color)
49+
.frame(width: geometry.size.width * (1 - self.progress))
50+
}
51+
52+
case .filled:
53+
ZStack(alignment: .leading) {
54+
RoundedRectangle(cornerRadius: self.model.computedCornerRadius)
55+
.foregroundStyle(self.model.color.main.color)
56+
.frame(width: geometry.size.width)
57+
58+
RoundedRectangle(cornerRadius: self.model.innerCornerRadius)
59+
.foregroundStyle(self.model.color.contrast.color)
60+
.frame(width: (geometry.size.width - self.model.innerBarPadding * 2) * self.progress)
61+
.padding(.vertical, self.model.innerBarPadding)
62+
.padding(.horizontal, self.model.innerBarPadding)
63+
}
64+
65+
case .striped:
66+
ZStack(alignment: .leading) {
67+
RoundedRectangle(cornerRadius: self.model.computedCornerRadius)
68+
.foregroundStyle(self.model.color.main.color)
69+
.frame(width: geometry.size.width)
70+
71+
RoundedRectangle(cornerRadius: self.model.innerCornerRadius)
72+
.foregroundStyle(self.model.color.contrast.color)
73+
.frame(width: (geometry.size.width - self.model.innerBarPadding * 2) * self.progress)
74+
.padding(.vertical, self.model.innerBarPadding)
75+
.padding(.horizontal, self.model.innerBarPadding)
76+
77+
StripesShape(model: self.model)
78+
.foregroundStyle(self.model.color.main.color)
79+
.cornerRadius(self.model.computedCornerRadius)
80+
.clipped()
81+
}
82+
}
83+
}
84+
.animation(.spring, value: self.progress)
85+
.frame(height: self.model.barHeight)
86+
.onAppear {
87+
self.model.validateMinMaxValues()
88+
}
89+
}
90+
}
91+
92+
// MARK: - Helpers
93+
94+
struct StripesShape: Shape {
95+
var model: ProgressBarVM
96+
97+
func path(in rect: CGRect) -> Path {
98+
self.model.stripesPath(in: rect)
99+
}
100+
}

0 commit comments

Comments
 (0)