diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift index d52d5e44..ae6b0356 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift @@ -3,14 +3,28 @@ import SwiftUI import UIKit struct ProgressBarPreview: View { - @State private var model = ProgressBarVM() - @State private var currentValue: CGFloat = 0 + @State private var model = Self.initialModel + @State private var currentValue: CGFloat = Self.initialValue + + private let progressBar = UKProgressBar(initialValue: Self.initialValue, model: Self.initialModel) + private let timer = Timer .publish(every: 0.1, on: .main, in: .common) .autoconnect() var body: some View { VStack { + PreviewWrapper(title: "UIKit") { + self.progressBar + .preview + .onAppear { + self.progressBar.currentValue = self.currentValue + self.progressBar.model = Self.initialModel + } + .onChange(of: self.model) { newValue in + self.progressBar.model = newValue + } + } PreviewWrapper(title: "SwiftUI") { SUProgressBar(currentValue: self.$currentValue, model: self.model) } @@ -33,8 +47,19 @@ struct ProgressBarPreview: View { } else { self.currentValue = self.model.minValue } + + self.progressBar.currentValue = self.currentValue } } + + // MARK: - Helpers + + private static var initialValue: Double { + return 0.0 + } + private static var initialModel: ProgressBarVM { + return .init() + } } #Preview { diff --git a/Sources/ComponentsKit/ProgressBar/Models/ProgressBarStyle.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift similarity index 100% rename from Sources/ComponentsKit/ProgressBar/Models/ProgressBarStyle.swift rename to Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift diff --git a/Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift similarity index 72% rename from Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift rename to Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift index 9bb13912..be1d3c1b 100644 --- a/Sources/ComponentsKit/ProgressBar/Models/ProgressBarVM.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift @@ -35,11 +35,7 @@ public struct ProgressBarVM: ComponentVM { // MARK: - Shared Helpers extension ProgressBarVM { - var innerBarPadding: CGFloat { - return 3 - } - - var barHeight: CGFloat { + var backgroundHeight: CGFloat { switch self.style { case .light: switch size { @@ -62,25 +58,42 @@ extension ProgressBarVM { } } - var computedCornerRadius: CGFloat { + var progressHeight: CGFloat { + return self.backgroundHeight - self.progressPadding + } + + func cornerRadius(for height: CGFloat) -> CGFloat { switch self.cornerRadius { case .none: - return 0.0 + return 0 case .small: - return self.barHeight / 3.5 + return height / 3.5 case .medium: - return self.barHeight / 3.0 + return height / 3.0 case .large: - return self.barHeight / 2.5 + return height / 2.5 case .full: - return self.barHeight / 2.0 + return height / 2.0 case .custom(let value): - return min(value, self.barHeight / 2) + return min(value, height / 2) } } - var innerCornerRadius: CGFloat { - return max(0, self.computedCornerRadius - self.innerBarPadding) + var animationDuration: TimeInterval { + return 0.2 + } + + var progressPadding: CGFloat { + switch self.style { + case .light: + return 0 + case .filled, .striped: + return 3 + } + } + + var lightBarSpacing: CGFloat { + return 4 } var backgroundColor: UniversalColor { @@ -101,10 +114,6 @@ extension ProgressBarVM { } } - 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 @@ -127,14 +136,35 @@ extension ProgressBarVM { } return path } +} - public func stripesPath(in rect: CGRect) -> Path { - return Path(self.stripesCGPath(in: rect)) +extension ProgressBarVM { + 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)) } +} - public func stripesBezierPath(in rect: CGRect) -> UIBezierPath { +// MARK: - UIKit Helpers + +extension ProgressBarVM { + func stripesBezierPath(in rect: CGRect) -> UIBezierPath { return UIBezierPath(cgPath: self.stripesCGPath(in: rect)) } + + func shouldUpdateLayout(_ oldModel: Self) -> Bool { + return self.style != oldModel.style || self.size != oldModel.size + } +} + +// MARK: - SwiftUI Helpers + +extension ProgressBarVM { + func stripesPath(in rect: CGRect) -> Path { + return Path(self.stripesCGPath(in: rect)) + } } // MARK: - Validation diff --git a/Sources/ComponentsKit/ProgressBar/SUProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift similarity index 58% rename from Sources/ComponentsKit/ProgressBar/SUProgressBar.swift rename to Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift index 8f1c8544..2343f7fa 100644 --- a/Sources/ComponentsKit/ProgressBar/SUProgressBar.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift @@ -10,14 +10,7 @@ public struct SUProgressBar: View { @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)) + self.model.progress(for: self.currentValue) } // MARK: - Initializer @@ -40,49 +33,52 @@ public struct SUProgressBar: View { GeometryReader { geometry in switch self.model.style { case .light: - HStack(spacing: 4) { - RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + HStack(spacing: self.model.lightBarSpacing) { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) .foregroundStyle(self.model.barColor.color) .frame(width: geometry.size.width * self.progress) - RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) .foregroundStyle(self.model.backgroundColor.color) .frame(width: geometry.size.width * (1 - self.progress)) } case .filled: ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) .foregroundStyle(self.model.color.main.color) .frame(width: geometry.size.width) - RoundedRectangle(cornerRadius: self.model.innerCornerRadius) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) .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) + .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) + .padding(.vertical, self.model.progressPadding) + .padding(.horizontal, self.model.progressPadding) } case .striped: ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: self.model.computedCornerRadius) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) .foregroundStyle(self.model.color.main.color) .frame(width: geometry.size.width) - RoundedRectangle(cornerRadius: self.model.innerCornerRadius) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) .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) + .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) + .padding(.vertical, self.model.progressPadding) + .padding(.horizontal, self.model.progressPadding) StripesShape(model: self.model) .foregroundStyle(self.model.color.main.color) - .cornerRadius(self.model.computedCornerRadius) + .cornerRadius(self.model.cornerRadius(for: self.model.progressHeight)) .clipped() } } } - .animation(.spring, value: self.progress) - .frame(height: self.model.barHeight) + .animation( + Animation.linear(duration: self.model.animationDuration), + value: self.progress + ) + .frame(height: self.model.backgroundHeight) .onAppear { self.model.validateMinMaxValues() } diff --git a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift new file mode 100644 index 00000000..3a14cc31 --- /dev/null +++ b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift @@ -0,0 +1,218 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a progress bar. +open class UKProgressBar: UIView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: ProgressBarVM { + didSet { + self.update(oldValue) + } + } + + /// The current progress value for the progress bar. + public var currentValue: CGFloat { + didSet { + self.updateProgressWidthAndAppearance() + } + } + + // MARK: - Subviews + + /// The background view of the progress bar. + public let backgroundView = UIView() + + /// The view that displays the current progress. + public let progressView = UIView() + + /// A shape layer used to render striped styling. + public let stripedLayer = CAShapeLayer() + + // MARK: - Layout Constraints + + private var backgroundViewLightLeadingConstraint: NSLayoutConstraint? + private var backgroundViewFilledLeadingConstraint: NSLayoutConstraint? + private var progressViewConstraints: LayoutConstraints = .init() + + // MARK: - Private Properties + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.sizeThatFits(UIView.layoutFittingExpandedSize) + } + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - initialValue: The initial progress value. Defaults to `0`. + /// - model: A model that defines the appearance properties. + public init( + initialValue: CGFloat = 0, + model: ProgressBarVM = .init() + ) { + self.currentValue = initialValue + self.model = model + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.backgroundView) + self.addSubview(self.progressView) + + self.progressView.layer.addSublayer(self.stripedLayer) + } + + // MARK: - Style + + private func style() { + Self.Style.backgroundView(self.backgroundView, model: self.model) + Self.Style.progressView(self.progressView, model: self.model) + Self.Style.stripedLayer(self.stripedLayer, model: self.model) + } + + // MARK: - Layout + + private func layout() { + self.backgroundView.vertically() + self.backgroundView.trailing() + self.backgroundViewLightLeadingConstraint = self.backgroundView.after( + self.progressView, + padding: self.model.lightBarSpacing + ).leading + self.backgroundViewFilledLeadingConstraint = self.backgroundView.leading().leading + + switch self.model.style { + case .light: + self.backgroundViewFilledLeadingConstraint?.isActive = false + case .filled, .striped: + self.backgroundViewLightLeadingConstraint?.isActive = false + } + + self.progressViewConstraints = .merged { + self.progressView.leading(self.model.progressPadding) + self.progressView.vertically(self.model.progressPadding) + self.progressView.width(0) + } + } + + // MARK: - Update + + public func update(_ oldModel: ProgressBarVM) { + guard self.model != oldModel else { return } + + self.style() + + if self.model.shouldUpdateLayout(oldModel) { + switch self.model.style { + case .light: + self.backgroundViewFilledLeadingConstraint?.isActive = false + self.backgroundViewLightLeadingConstraint?.isActive = true + case .filled, .striped: + self.backgroundViewLightLeadingConstraint?.isActive = false + self.backgroundViewFilledLeadingConstraint?.isActive = true + } + + self.progressViewConstraints.leading?.constant = self.model.progressPadding + self.progressViewConstraints.top?.constant = self.model.progressPadding + self.progressViewConstraints.bottom?.constant = -self.model.progressPadding + + self.invalidateIntrinsicContentSize() + self.setNeedsLayout() + } + + UIView.performWithoutAnimation { + self.updateProgressWidthAndAppearance() + } + } + + private func updateProgressWidthAndAppearance() { + if self.model.style == .striped { + self.stripedLayer.frame = self.bounds + self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath + } + + let totalHorizontalPadding: CGFloat = switch self.model.style { + case .light: self.model.lightBarSpacing + case .filled, .striped: self.model.progressPadding * 2 + } + let totalWidth = self.bounds.width - totalHorizontalPadding + let progressWidth = totalWidth * self.progress + + self.progressViewConstraints.width?.constant = max(0, progressWidth) + + UIView.animate( + withDuration: self.model.animationDuration, + animations: { + self.layoutIfNeeded() + } + ) + } + + // MARK: - Layout + + open override func layoutSubviews() { + super.layoutSubviews() + + self.backgroundView.layer.cornerRadius = self.model.cornerRadius(for: self.backgroundView.bounds.height) + self.progressView.layer.cornerRadius = self.model.cornerRadius(for: self.progressView.bounds.height) + + self.updateProgressWidthAndAppearance() + + self.model.validateMinMaxValues() + } + + // MARK: - UIView methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let width = self.superview?.bounds.width ?? size.width + return CGSize( + width: min(size.width, width), + height: min(size.height, self.model.backgroundHeight) + ) + } +} + +// MARK: - Style Helpers + +extension UKProgressBar { + fileprivate enum Style { + static func backgroundView(_ view: UIView, model: ProgressBarVM) { + view.backgroundColor = model.backgroundColor.uiColor + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + } + + static func progressView(_ view: UIView, model: ProgressBarVM) { + view.backgroundColor = model.barColor.uiColor + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + view.layer.masksToBounds = true + } + + static func stripedLayer(_ layer: CAShapeLayer, model: ProgressBarVM) { + layer.fillColor = model.color.main.uiColor.cgColor + switch model.style { + case .light, .filled: + layer.isHidden = true + case .striped: + layer.isHidden = false + } + } + } +}