Skip to content

Commit de4f86c

Browse files
committed
feat: Add linear progress and refactor component architecture
- Add new linear progress indicator with customizable padding - Extract circular progress into separate component - Create BaseProgress protocol for shared functionality - Improve spinner animation with configurable cycle duration - Reorganize codebase with better directory structure - Update examples and demo configurations This commit introduces a new linear progress option while making the codebase more maintainable through better component separation and code organization.
1 parent f2bbe62 commit de4f86c

10 files changed

Lines changed: 496 additions & 144 deletions

File tree

Example/Example/ContentView.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ struct ContentView: View {
2424
.setSize(.small)
2525
#endif
2626

27-
ProgressUI(progress: 0.5)
28-
.setSize(.small)
27+
ProgressUI(progress: 0.6)
28+
.setTrackWidth(25)
29+
.setShape(.linear(10))
30+
.setSpinnerCycleDuration(0.0001)
31+
.setSpinnerCycleDuration(1)
32+
.setIsSpinner()
2933

3034
ProgressUI(progress: 0.3)
3135
.setProgressColor(.red.opacity(0.5))
@@ -58,7 +62,7 @@ struct ContentView: View {
5862
.setInnerProgressWidth(10)
5963
.setTrackColor(.black.opacity(0.1))
6064
.setIsSpinner()
61-
.setAnimationMaxValue(0.5)
65+
.setAnimationMaxValue(1)
6266
.onReceive(loadingTimer) { _ in
6367
if loadingProgress >= 1 {
6468
loadingProgress = 0
@@ -103,12 +107,12 @@ enum Status: CaseIterable, Progressable {
103107
*/
104108
var color: Color {
105109
return switch(self){
106-
case .Excellent: .green.opacity(0.5)
107-
case .Normal: .yellow.opacity(0.5)
108-
case .SemiNormal: .orange.opacity(0.5)
109-
case .Bad: .red.opacity(0.5)
110-
case .Critical: .purple.opacity(0.5)
111-
case .Danger: .black.opacity(0.5)
110+
case .Excellent: .green.opacity(0.8)
111+
case .Normal: .yellow.opacity(0.8)
112+
case .SemiNormal: .orange.opacity(0.8)
113+
case .Bad: .red.opacity(0.8)
114+
case .Critical: .purple.opacity(0.8)
115+
case .Danger: .black.opacity(0.8)
112116
}
113117
}
114118

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// BaseProgress.swift
3+
// ProgressUI
4+
//
5+
// Created by Pierre Janineh on 13/05/2025.
6+
//
7+
8+
import SwiftUI
9+
10+
/// A protocol that provides common functionality for progress indicators
11+
internal protocol BaseProgress {
12+
var progress: CGFloat { get }
13+
var options: Options { get }
14+
var statusType: (any CaseIterableAndProgressable.Type)? { get }
15+
16+
init<Status: CaseIterable & Progressable>(
17+
progress: Binding<CGFloat>,
18+
options: Options?,
19+
statusType: Status.Type
20+
)
21+
22+
init(progress: Binding<CGFloat>, options: Options?)
23+
}
24+
25+
extension BaseProgress {
26+
/// The current status for dynamic coloring.
27+
var status: (any CaseIterableAndProgressable)? {
28+
statusType.map { $0.calculate(from: progress) as any CaseIterableAndProgressable }
29+
}
30+
31+
/// The main progress color.
32+
var color: Color {
33+
status?.color ?? options.progressColor
34+
}
35+
36+
/// The inner progress color.
37+
var innerColor: Color? {
38+
status?.innerColor ?? options.innerProgressColor
39+
}
40+
41+
/// The track/background color.
42+
var trackColor: Color {
43+
options.trackColor
44+
}
45+
46+
/// Determines the width of the track.
47+
var trackWidth: CGFloat {
48+
if let trackWidth = options.trackWidth { return trackWidth }
49+
50+
return switch options.size {
51+
case .large: 45
52+
case .small: 15
53+
}
54+
}
55+
56+
/// Determines the width of the inner progress.
57+
var innerProgressWidth: CGFloat {
58+
if let innerProgressWidth = options.innerProgressWidth { return innerProgressWidth }
59+
60+
return switch options.size {
61+
case .large: 5
62+
case .small: 2.5
63+
}
64+
}
65+
66+
/// Animates the width of the progress at the start for a growing effect.
67+
func animatedWidth(_ value: CGFloat) -> CGFloat {
68+
guard let maxValue = options.animationMaxValue else { return trackWidth }
69+
70+
let percentage = (value / maxValue).clamped(to: 0...1)
71+
return percentage * trackWidth
72+
}
73+
}
74+
75+
/// Utility to clamp values within a range.
76+
internal extension Comparable {
77+
func clamped(to range: ClosedRange<Self>) -> Self {
78+
min(max(self, range.lowerBound), range.upperBound)
79+
}
80+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// The Swift Programming Language
2+
// https://docs.swift.org/swift-book
3+
//
4+
// CircularProgress.swift
5+
// ProgressUI
6+
//
7+
// Created by Pierre Janineh on 19/06/2023.
8+
//
9+
10+
import SwiftUI
11+
import Combine
12+
13+
internal struct CircularProgress: View, BaseProgress {
14+
@EnvironmentObject private var vm: ProgressUI.ViewModel
15+
16+
/// The main progress value, either bound or constant.
17+
@Binding internal var progress: CGFloat
18+
19+
/// The options for Progress' configurations
20+
internal var options: Options
21+
22+
/// The type used for dynamic coloring/status.
23+
internal let statusType: (any CaseIterableAndProgressable.Type)?
24+
25+
/// The current rotation angle for spinner animation.
26+
@State private var rotationAngle: Angle = .zero
27+
28+
/**
29+
Creates a progress indicator with adaptable coloring and updating progress value.
30+
- Parameters:
31+
- progress: The binding object of the progress updates (0...1).
32+
- options: Options for customizing the progress (see ``Options``).
33+
- statusType: The ``Progressable`` & `CaseIterable` type to use for dynamic coloring.
34+
35+
## Example
36+
```swift
37+
ProgressUI(
38+
progress: $progress,
39+
options: .init(isRounded: true),
40+
statusType: CompletionStatus.self
41+
)
42+
```
43+
*/
44+
internal init<Status: CaseIterable & Progressable>(
45+
progress: Binding<CGFloat>,
46+
options: Options? = nil,
47+
statusType: Status.Type
48+
) {
49+
self._progress = progress
50+
self.options = options ?? Options()
51+
self.statusType = statusType
52+
}
53+
54+
/**
55+
Creates a progress indicator with updating progress value.
56+
- Parameters:
57+
- progress: The binding object of the progress updates (0...1).
58+
- options: Options for customizing the progress (see ``Options``).
59+
60+
## Example
61+
```swift
62+
ProgressUI(
63+
progress: $progress,
64+
options: .init(isRounded: true)
65+
)
66+
```
67+
*/
68+
internal init(
69+
progress: Binding<CGFloat>,
70+
options: Options? = nil
71+
) {
72+
self._progress = progress
73+
self.options = options ?? Options()
74+
self.statusType = nil
75+
}
76+
77+
internal var body: some View {
78+
GeometryReader { geometry in
79+
ZStack {
80+
let path = Path { path in
81+
let maxStrokeWidth = max(trackWidth, innerProgressWidth)
82+
let totalWidth: CGFloat = min(geometry.size.width, geometry.size.height)
83+
let availableWidth = totalWidth - (maxStrokeWidth * 2)
84+
let center = CGPoint(x: totalWidth / 2, y: totalWidth / 2)
85+
let radius = (availableWidth / 2) + (maxStrokeWidth / 2)
86+
87+
path.addRelativeArc(
88+
center: center,
89+
radius: radius,
90+
startAngle: Angle(degrees: -90),
91+
delta: Angle(degrees: options.isClockwise ? 360 : -360)
92+
)
93+
}
94+
95+
//MARK: - Track
96+
path
97+
.stroke(trackColor, style: StrokeStyle(lineWidth: trackWidth))
98+
99+
//MARK: - Progress
100+
path
101+
.trim(from: 0, to: progress)
102+
.stroke(
103+
color,
104+
style: StrokeStyle(
105+
lineWidth: (
106+
progress > (options.animationMaxValue ?? 0) ?
107+
trackWidth :
108+
animatedWidth(progress)
109+
),
110+
lineCap: options.isRounded ? .round : .butt
111+
)
112+
)
113+
.rotationEffect(rotationAngle)
114+
115+
//MARK: - Inner
116+
if let innerColor {
117+
path
118+
.trim(from: 0, to: progress)
119+
.stroke(
120+
innerColor,
121+
style: StrokeStyle(
122+
lineWidth: innerProgressWidth,
123+
lineCap: options.isRounded ? .round : .butt
124+
)
125+
)
126+
.rotationEffect(rotationAngle)
127+
}
128+
}
129+
}
130+
.aspectRatio(1, contentMode: .fit)
131+
.onChange(of: vm.timerTriggered) { _ in
132+
if rotationAngle.degrees >= 360 || rotationAngle.degrees <= -360 {
133+
rotationAngle = .degrees(0)
134+
return
135+
}
136+
137+
if options.isClockwise {
138+
rotationAngle = .degrees(rotationAngle.degrees + vm.pixelsPerStep)
139+
} else {
140+
rotationAngle = .degrees(rotationAngle.degrees - vm.pixelsPerStep)
141+
}
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)