diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift new file mode 100644 index 00000000..d52d5e44 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift @@ -0,0 +1,42 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct ProgressBarPreview: View { + @State private var model = ProgressBarVM() + @State private var currentValue: CGFloat = 0 + private let timer = Timer + .publish(every: 0.1, on: .main, in: .common) + .autoconnect() + + var body: some View { + VStack { + PreviewWrapper(title: "SwiftUI") { + SUProgressBar(currentValue: self.$currentValue, model: self.model) + } + Form { + ComponentColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 2px").tag(ComponentRadius.custom(2)) + } + SizePicker(selection: self.$model.size) + Picker("Style", selection: self.$model.style) { + Text("Light").tag(ProgressBarVM.Style.light) + Text("Filled").tag(ProgressBarVM.Style.filled) + Text("Striped").tag(ProgressBarVM.Style.striped) + } + } + } + .onReceive(self.timer) { _ in + if self.currentValue < self.model.maxValue { + self.currentValue += (self.model.maxValue - self.model.minValue) / 100 + } else { + self.currentValue = self.model.minValue + } + } + } +} + +#Preview { + ProgressBarPreview() +} diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index 1ffaa23f..380cd80a 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -29,6 +29,9 @@ struct App: View { NavigationLinkWithTitle("Loading") { LoadingPreview() } + NavigationLinkWithTitle("Progress Bar") { + ProgressBarPreview() + } NavigationLinkWithTitle("Modal (Bottom)") { BottomModalPreview() } diff --git a/Sources/ComponentsKit/ProgressBar/Models/ProgressBarStyle.swift b/Sources/ComponentsKit/ProgressBar/Models/ProgressBarStyle.swift new file mode 100644 index 00000000..08c0639c --- /dev/null +++ b/Sources/ComponentsKit/ProgressBar/Models/ProgressBarStyle.swift @@ -0,0 +1,10 @@ +import Foundation + +extension ProgressBarVM { + /// Defines the visual styles for the progress bar component. + public enum Style { + case light + case filled + case striped + } +} diff --git a/Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift b/Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift new file mode 100644 index 00000000..9bb13912 --- /dev/null +++ b/Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift @@ -0,0 +1,148 @@ +import SwiftUI + +/// A model that defines the appearance properties for a a progress bar component. +public struct ProgressBarVM: ComponentVM { + /// The color of the progress bar. + /// + /// Defaults to `.accent`. + public var color: ComponentColor = .accent + + /// The visual style of the progress bar component. + /// + /// Defaults to `.striped`. + public var style: Style = .striped + + /// The size of the progress bar. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The minimum value of the progress bar. + public var minValue: CGFloat = 0 + + /// The maximum value of the progress bar. + public var maxValue: CGFloat = 100 + + /// The corner radius of the progress bar. + /// + /// Defaults to `.medium`. + public var cornerRadius: ComponentRadius = .medium + + /// Initializes a new instance of `ProgressBarVM` with default values. + public init() {} +} + +// MARK: - Shared Helpers + +extension ProgressBarVM { + var innerBarPadding: CGFloat { + return 3 + } + + var barHeight: CGFloat { + switch self.style { + case .light: + switch size { + case .small: + return 4 + case .medium: + return 8 + case .large: + return 12 + } + case .filled, .striped: + switch self.size { + case .small: + return 20 + case .medium: + return 32 + case .large: + return 42 + } + } + } + + var computedCornerRadius: CGFloat { + switch self.cornerRadius { + case .none: + return 0.0 + case .small: + return self.barHeight / 3.5 + case .medium: + return self.barHeight / 3.0 + case .large: + return self.barHeight / 2.5 + case .full: + return self.barHeight / 2.0 + case .custom(let value): + return min(value, self.barHeight / 2) + } + } + + var innerCornerRadius: CGFloat { + return max(0, self.computedCornerRadius - self.innerBarPadding) + } + + var backgroundColor: UniversalColor { + switch style { + case .light: + return self.color.background + case .filled, .striped: + return self.color.main + } + } + + var barColor: UniversalColor { + switch style { + case .light: + return self.color.main + case .filled, .striped: + return self.color.contrast + } + } + + func shouldUpdateLayout(_ oldModel: Self) -> Bool { + return self.size != oldModel.size + } + + private func stripesCGPath(in rect: CGRect) -> CGMutablePath { + let stripeWidth: CGFloat = 2 + let stripeSpacing: CGFloat = 4 + 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 + } + + public func stripesPath(in rect: CGRect) -> Path { + return Path(self.stripesCGPath(in: rect)) + } + + public func stripesBezierPath(in rect: CGRect) -> UIBezierPath { + return UIBezierPath(cgPath: self.stripesCGPath(in: rect)) + } +} + +// MARK: - Validation + +extension ProgressBarVM { + func validateMinMaxValues() { + if self.minValue > self.maxValue { + assertionFailure("Min value must be less than max value") + } + } +} diff --git a/Sources/ComponentsKit/ProgressBar/SUProgressBar.swift b/Sources/ComponentsKit/ProgressBar/SUProgressBar.swift new file mode 100644 index 00000000..8f1c8544 --- /dev/null +++ b/Sources/ComponentsKit/ProgressBar/SUProgressBar.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// A SwiftUI component that displays a progress bar. +public struct SUProgressBar: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: ProgressBarVM + /// A binding to control the current value. + @Binding public var currentValue: CGFloat + + private var progress: CGFloat { + let range = self.model.maxValue - self.model.minValue + + guard range > 0 else { + return 0 + } + + let progress = (self.currentValue - self.model.minValue) / range + return max(0, min(1, progress)) + } + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - currentValue: A binding to the current value. + /// - model: A model that defines the appearance properties. + public init( + currentValue: Binding, + model: ProgressBarVM = .init() + ) { + self._currentValue = currentValue + self.model = model + } + + // MARK: - Body + + public var body: some View { + GeometryReader { geometry in + switch self.model.style { + case .light: + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + .foregroundStyle(self.model.barColor.color) + .frame(width: geometry.size.width * self.progress) + RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + .foregroundStyle(self.model.backgroundColor.color) + .frame(width: geometry.size.width * (1 - self.progress)) + } + + case .filled: + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + .foregroundStyle(self.model.color.main.color) + .frame(width: geometry.size.width) + + RoundedRectangle(cornerRadius: self.model.innerCornerRadius) + .foregroundStyle(self.model.color.contrast.color) + .frame(width: (geometry.size.width - self.model.innerBarPadding * 2) * self.progress) + .padding(.vertical, self.model.innerBarPadding) + .padding(.horizontal, self.model.innerBarPadding) + } + + case .striped: + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + .foregroundStyle(self.model.color.main.color) + .frame(width: geometry.size.width) + + RoundedRectangle(cornerRadius: self.model.innerCornerRadius) + .foregroundStyle(self.model.color.contrast.color) + .frame(width: (geometry.size.width - self.model.innerBarPadding * 2) * self.progress) + .padding(.vertical, self.model.innerBarPadding) + .padding(.horizontal, self.model.innerBarPadding) + + StripesShape(model: self.model) + .foregroundStyle(self.model.color.main.color) + .cornerRadius(self.model.computedCornerRadius) + .clipped() + } + } + } + .animation(.spring, value: self.progress) + .frame(height: self.model.barHeight) + .onAppear { + self.model.validateMinMaxValues() + } + } +} + +// MARK: - Helpers + +struct StripesShape: Shape { + var model: ProgressBarVM + + func path(in rect: CGRect) -> Path { + self.model.stripesPath(in: rect) + } +}