diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift new file mode 100644 index 00000000..caff98de --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -0,0 +1,67 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct CircularProgressPreview: View { + @State private var model = Self.initialModel + @State private var currentValue: CGFloat = Self.initialValue + + private let timer = Timer + .publish(every: 0.5, on: .main, in: .common) + .autoconnect() + + var body: some View { + VStack { + PreviewWrapper(title: "SwiftUI") { + SUCircularProgress(currentValue: self.currentValue, model: self.model) + } + Form { + ComponentColorPicker(selection: self.$model.color) + Picker("Font", selection: self.$model.font) { + Text("Default").tag(Optional.none) + Text("Small").tag(UniversalFont.smButton) + Text("Medium").tag(UniversalFont.mdButton) + Text("Large").tag(UniversalFont.lgButton) + Text("Custom: system bold of size 16").tag(UniversalFont.system(size: 16, weight: .bold)) + } + Picker("Line Width", selection: self.$model.lineWidth) { + Text("Default").tag(Optional.none) + Text("2").tag(Optional.some(2)) + Text("4").tag(Optional.some(4)) + Text("8").tag(Optional.some(8)) + } + SizePicker(selection: self.$model.size) + Picker("Style", selection: self.$model.style) { + Text("Light").tag(CircularProgressVM.Style.light) + Text("Striped").tag(CircularProgressVM.Style.striped) + } + } + .onReceive(self.timer) { _ in + if self.currentValue < self.model.maxValue { + let step = (self.model.maxValue - self.model.minValue) / 100 + self.currentValue = min( + self.model.maxValue, + self.currentValue + CGFloat(Int.random(in: 1...20)) * step + ) + } else { + self.currentValue = self.model.minValue + } + self.model.label = "\(Int(self.currentValue))%" + } + } + } + + // MARK: - Helpers + + private static var initialValue: Double { + return 0.0 + } + private static var initialModel = CircularProgressVM { + $0.label = "0" + $0.style = .light + } +} + +#Preview { + CircularProgressPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift index ae6b0356..19589326 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift @@ -9,7 +9,7 @@ struct ProgressBarPreview: View { private let progressBar = UKProgressBar(initialValue: Self.initialValue, model: Self.initialModel) private let timer = Timer - .publish(every: 0.1, on: .main, in: .common) + .publish(every: 0.5, on: .main, in: .common) .autoconnect() var body: some View { @@ -43,7 +43,11 @@ struct ProgressBarPreview: View { } .onReceive(self.timer) { _ in if self.currentValue < self.model.maxValue { - self.currentValue += (self.model.maxValue - self.model.minValue) / 100 + let step = (self.model.maxValue - self.model.minValue) / 100 + self.currentValue = min( + self.model.maxValue, + self.currentValue + CGFloat(Int.random(in: 1...20)) * step + ) } else { self.currentValue = self.model.minValue } diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index c3d5e00c..42d78710 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -26,6 +26,9 @@ struct App: View { NavigationLinkWithTitle("Checkbox") { CheckboxPreview() } + NavigationLinkWithTitle("Circular Progress") { + CircularProgressPreview() + } NavigationLinkWithTitle("Countdown") { CountdownPreview() } diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift new file mode 100644 index 00000000..3c0588d7 --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +extension CircularProgressVM { + public enum Style { + /// Defines the visual styles for the circular progress component. + case light + case striped + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift new file mode 100644 index 00000000..194dd8ad --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -0,0 +1,147 @@ +import SwiftUI + +/// A model that defines the appearance properties for a circular progress component. +public struct CircularProgressVM: ComponentVM { + /// The color of the circular progress. + /// + /// Defaults to `.accent`. + public var color: ComponentColor = .accent + + /// The style of the circular progress indicator. + /// + /// Defaults to `.light`. + public var style: Style = .light + + /// The size of the circular progress. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The minimum value of the circular progress. + /// + /// Defaults to `0`. + public var minValue: CGFloat = 0 + + /// The maximum value of the circular progress. + /// + /// Defaults to `100`. + public var maxValue: CGFloat = 100 + + /// The width of the circular progress stroke. + public var lineWidth: CGFloat? + + /// An optional label to display inside the circular progress. + public var label: String? + + /// The font used for the circular progress label text. + public var font: UniversalFont? + + /// Initializes a new instance of `CircularProgressVM` with default values. + public init() {} +} + +// MARK: Shared Helpers + +extension CircularProgressVM { + var animationDuration: TimeInterval { + return 0.2 + } + var circularLineWidth: CGFloat { + return self.lineWidth ?? max(self.preferredSize.width / 8, 2) + } + var preferredSize: CGSize { + switch self.size { + case .small: + return CGSize(width: 48, height: 48) + case .medium: + return CGSize(width: 64, height: 64) + case .large: + return CGSize(width: 80, height: 80) + } + } + var radius: CGFloat { + return self.preferredSize.height / 2 - self.circularLineWidth / 2 + } + var center: CGPoint { + return .init( + x: self.preferredSize.width / 2, + y: self.preferredSize.height / 2 + ) + } + var titleFont: UniversalFont { + if let font { + return font + } + switch self.size { + case .small: + return .smCaption + case .medium: + return .mdCaption + case .large: + return .lgCaption + } + } + private func stripesCGPath(in rect: CGRect) -> CGMutablePath { + let stripeWidth: CGFloat = 0.5 + let stripeSpacing: CGFloat = 3 + let stripeAngle: Angle = .degrees(135) + + let path = CGMutablePath() + let step = stripeWidth + stripeSpacing + let radians = stripeAngle.radians + let dx = rect.height * tan(radians) + for x in stride(from: dx, through: rect.width + rect.height, by: step) { + let topLeft = CGPoint(x: x, y: 0) + let topRight = CGPoint(x: x + stripeWidth, y: 0) + let bottomLeft = CGPoint(x: x + dx, y: rect.height) + let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height) + path.move(to: topLeft) + path.addLine(to: topRight) + path.addLine(to: bottomRight) + path.addLine(to: bottomLeft) + path.closeSubpath() + } + return path + } +} + +extension CircularProgressVM { + func gap(for normalized: CGFloat) -> CGFloat { + normalized > 0 ? 0.05 : 0 + } + + func progressArcStart(for normalized: CGFloat) -> CGFloat { + return 0 + } + + func progressArcEnd(for normalized: CGFloat) -> CGFloat { + return max(0, min(1, normalized)) + } + + func backgroundArcStart(for normalized: CGFloat) -> CGFloat { + let gapValue = self.gap(for: normalized) + return max(0, min(1, normalized + gapValue)) + } + + func backgroundArcEnd(for normalized: CGFloat) -> CGFloat { + let gapValue = self.gap(for: normalized) + return 1 - gapValue + } +} + +extension CircularProgressVM { + public func progress(for currentValue: CGFloat) -> CGFloat { + let range = self.maxValue - self.minValue + guard range > 0 else { return 0 } + let normalized = (currentValue - self.minValue) / range + return max(0, min(1, normalized)) + } +} + +// MARK: - SwiftUI Helpers + +extension CircularProgressVM { + func stripesPath(in rect: CGRect) -> Path { + Path(self.stripesCGPath(in: rect)) + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift new file mode 100644 index 00000000..96ebcc5d --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// A SwiftUI component that displays a circular progress. +public struct SUCircularProgress: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: CircularProgressVM + + /// The current progress value. + public var currentValue: CGFloat + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - currentValue: Current progress. + /// - model: A model that defines the appearance properties. + public init( + currentValue: CGFloat = 0, + model: CircularProgressVM = .init() + ) { + self.currentValue = currentValue + self.model = model + } + + // MARK: - Body + + public var body: some View { + ZStack { + // Background part + Group { + switch self.model.style { + case .light: + self.lightBackground + case .striped: + self.stripedBackground + } + } + .frame( + width: self.model.preferredSize.width, + height: self.model.preferredSize.height + ) + + // Foreground part + Path { path in + path.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: false + ) + } + .trim(from: 0, to: self.progress) + .stroke( + self.model.color.main.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + .frame( + width: self.model.preferredSize.width, + height: self.model.preferredSize.height + ) + + // Optional label + if let label = self.model.label { + Text(label) + .font(self.model.titleFont.font) + .foregroundColor(self.model.color.main.color) + } + } + .animation( + Animation.linear(duration: self.model.animationDuration), + value: self.progress + ) + } + + // MARK: - Subviews + + var lightBackground: some View { + Path { path in + path.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: false + ) + } + .stroke( + self.model.color.background.color, + lineWidth: self.model.circularLineWidth + ) + } + + var stripedBackground: some View { + Path { path in + path.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: false + ) + } + .trim( + from: self.model.backgroundArcStart(for: self.progress), + to: self.model.backgroundArcEnd(for: self.progress) + ) + .stroke( + .clear, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: .round + ) + ) + .overlay { + StripesShapeCircularProgress(model: self.model) + .foregroundColor(self.model.color.main.color) + .mask { + Path { maskPath in + maskPath.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: false + ) + } + .trim( + from: self.model.backgroundArcStart(for: self.progress), + to: self.model.backgroundArcEnd(for: self.progress) + ) + .stroke( + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: .round + ) + ) + } + } + .rotationEffect(.degrees(-90)) + } +} + +// MARK: - Helpers + +struct StripesShapeCircularProgress: Shape, @unchecked Sendable { + var model: CircularProgressVM + + func path(in rect: CGRect) -> Path { + self.model.stripesPath(in: rect) + } +}