diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index caff98d..1feb927 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -6,17 +6,36 @@ struct CircularProgressPreview: View { @State private var model = Self.initialModel @State private var currentValue: CGFloat = Self.initialValue + private let circularProgress = UKCircularProgress( + model: Self.initialModel + ) + private let timer = Timer .publish(every: 0.5, on: .main, in: .common) .autoconnect() - + var body: some View { VStack { + PreviewWrapper(title: "UIKit") { + self.circularProgress + .preview + .onAppear { + self.circularProgress.currentValue = Self.initialValue + self.circularProgress.model = Self.initialModel + } + .onChange(of: model) { newModel in + self.circularProgress.model = newModel + } + .onChange(of: self.currentValue) { newValue in + self.circularProgress.currentValue = newValue + } + } 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) @@ -56,8 +75,9 @@ struct CircularProgressPreview: View { private static var initialValue: Double { return 0.0 } + private static var initialModel = CircularProgressVM { - $0.label = "0" + $0.label = "0%" $0.style = .light } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift index 194dd8a..1c763ff 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -89,12 +89,14 @@ extension CircularProgressVM { 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 dx: CGFloat = rect.height * tan(radians) + for x in stride(from: 0, 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) @@ -118,19 +120,19 @@ extension CircularProgressVM { return max(0, min(1, normalized)) } - func backgroundArcStart(for normalized: CGFloat) -> CGFloat { + func stripedArcStart(for normalized: CGFloat) -> CGFloat { let gapValue = self.gap(for: normalized) return max(0, min(1, normalized + gapValue)) } - func backgroundArcEnd(for normalized: CGFloat) -> CGFloat { + func stripedArcEnd(for normalized: CGFloat) -> CGFloat { let gapValue = self.gap(for: normalized) return 1 - gapValue } } extension CircularProgressVM { - public func progress(for currentValue: CGFloat) -> CGFloat { + func progress(for currentValue: CGFloat) -> CGFloat { let range = self.maxValue - self.minValue guard range > 0 else { return 0 } let normalized = (currentValue - self.minValue) / range @@ -138,6 +140,48 @@ extension CircularProgressVM { } } +// MARK: - UIKit Helpers + +extension CircularProgressVM { + var isStripesLayerHidden: Bool { + switch self.style { + case .light: + return true + case .striped: + return false + } + } + var isBackgroundLayerHidden: Bool { + switch self.style { + case .light: + return false + case .striped: + return true + } + } + func stripesBezierPath(in rect: CGRect) -> UIBezierPath { + let center = CGPoint(x: rect.midX, y: rect.midY) + let path = UIBezierPath(cgPath: self.stripesCGPath(in: rect)) + var transform = CGAffineTransform.identity + transform = transform + .translatedBy(x: center.x, y: center.y) + .rotated(by: -CGFloat.pi / 2) + .translatedBy(x: -center.x, y: -center.y) + path.apply(transform) + return path + } + func shouldInvalidateIntrinsicContentSize(_ oldModel: Self) -> Bool { + return self.preferredSize != oldModel.preferredSize + } + func shouldUpdateText(_ oldModel: Self) -> Bool { + return self.label != oldModel.label + } + func shouldRecalculateProgress(_ oldModel: Self) -> Bool { + return self.minValue != oldModel.minValue + || self.maxValue != oldModel.maxValue + } +} + // MARK: - SwiftUI Helpers extension CircularProgressVM { diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift index 96ebcc5..cfae7e9 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -112,8 +112,8 @@ public struct SUCircularProgress: View { ) } .trim( - from: self.model.backgroundArcStart(for: self.progress), - to: self.model.backgroundArcEnd(for: self.progress) + from: self.model.stripedArcStart(for: self.progress), + to: self.model.stripedArcEnd(for: self.progress) ) .stroke( .clear, @@ -136,8 +136,8 @@ public struct SUCircularProgress: View { ) } .trim( - from: self.model.backgroundArcStart(for: self.progress), - to: self.model.backgroundArcEnd(for: self.progress) + from: self.model.stripedArcStart(for: self.progress), + to: self.model.stripedArcEnd(for: self.progress) ) .stroke( style: StrokeStyle( diff --git a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift new file mode 100644 index 0000000..880ec08 --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift @@ -0,0 +1,253 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a circular progress indicator. +open class UKCircularProgress: UIView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties for the circular progress. + public var model: CircularProgressVM { + didSet { + self.update(oldValue) + } + } + + /// The current progress value. + public var currentValue: CGFloat { + didSet { + self.updateProgress() + } + } + + // MARK: - Subviews + + /// The shape layer responsible for rendering the background of the circular progress indicator in a light style. + public let backgroundLayer = CAShapeLayer() + + /// The shape layer responsible for rendering the progress arc of the circular progress indicator. + public let progressLayer = CAShapeLayer() + + /// The shape layer responsible for rendering the striped effect in the circular progress indicator. + public let stripesLayer = CAShapeLayer() + + /// The shape layer that acts as a mask for `stripesLayer`, ensuring it has the intended shape. + public let stripesMaskLayer = CAShapeLayer() + + /// The label used to display text inside the circular progress indicator. + public let label = UILabel() + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.model.preferredSize + } + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - initialValue: The initial progress value. Defaults to `0`. + /// - model: The model that defines the appearance properties. + public init( + initialValue: CGFloat = 0, + model: CircularProgressVM = .init() + ) { + self.model = model + self.currentValue = initialValue + 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.layer.addSublayer(self.backgroundLayer) + self.layer.addSublayer(self.stripesLayer) + self.layer.addSublayer(self.progressLayer) + self.addSubview(self.label) + + self.stripesLayer.mask = self.stripesMaskLayer + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } + + let progress = self.model.progress(for: self.currentValue) + self.progressLayer.strokeEnd = progress + if !self.model.isStripesLayerHidden { + self.stripesMaskLayer.strokeStart = self.model.stripedArcStart(for: progress) + self.stripesMaskLayer.strokeEnd = self.model.stripedArcEnd(for: progress) + } + self.label.text = self.model.label + } + + // MARK: - Style + + private func style() { + Self.Style.backgroundLayer(self.backgroundLayer, model: self.model) + Self.Style.progressLayer(self.progressLayer, model: self.model) + Self.Style.label(self.label, model: self.model) + Self.Style.stripesLayer(self.stripesLayer, model: self.model) + Self.Style.stripesMaskLayer(self.stripesMaskLayer, model: self.model) + } + + // MARK: - Update + + public func update(_ oldModel: CircularProgressVM) { + guard self.model != oldModel else { return } + self.style() + self.updateShapePaths() + + if self.model.shouldUpdateText(oldModel) { + UIView.transition( + with: self.label, + duration: self.model.animationDuration, + options: .transitionCrossDissolve, + animations: { + self.label.text = self.model.label + }, + completion: nil + ) + } + if self.model.shouldRecalculateProgress(oldModel) { + self.updateProgress() + } + if self.model.shouldInvalidateIntrinsicContentSize(oldModel) { + self.invalidateIntrinsicContentSize() + } + } + + private func updateShapePaths() { + let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) + let circlePath = UIBezierPath( + arcCenter: center, + radius: self.model.radius, + startAngle: -CGFloat.pi / 2, + endAngle: -CGFloat.pi / 2 + 2 * .pi, + clockwise: true + ) + + self.backgroundLayer.path = circlePath.cgPath + self.progressLayer.path = circlePath.cgPath + self.stripesMaskLayer.path = circlePath.cgPath + self.stripesLayer.path = self.model.stripesBezierPath(in: self.bounds).cgPath + } + + private func updateProgress() { + let progress = self.model.progress(for: self.currentValue) + + CATransaction.begin() + CATransaction.setAnimationDuration(self.model.animationDuration) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .linear)) + self.progressLayer.strokeEnd = progress + if !self.model.isStripesLayerHidden { + self.stripesMaskLayer.strokeStart = self.model.stripedArcStart(for: progress) + self.stripesMaskLayer.strokeEnd = self.model.stripedArcEnd(for: progress) + } + CATransaction.commit() + } + + // MARK: - Layout + + private func layout() { + self.label.center() + } + + open override func layoutSubviews() { + super.layoutSubviews() + + self.backgroundLayer.frame = self.bounds + self.progressLayer.frame = self.bounds + self.stripesLayer.frame = self.bounds + self.stripesMaskLayer.frame = self.bounds + + self.updateShapePaths() + } + + // MARK: - UIView Methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let preferred = self.model.preferredSize + return CGSize( + width: min(size.width, preferred.width), + height: min(size.height, preferred.height) + ) + } + + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + private func handleTraitChanges() { + Self.Style.backgroundLayer(self.backgroundLayer, model: self.model) + Self.Style.progressLayer(self.progressLayer, model: self.model) + Self.Style.stripesLayer(self.stripesLayer, model: self.model) + Self.Style.stripesMaskLayer(self.stripesMaskLayer, model: self.model) + } +} + +// MARK: - Style Helpers + +extension UKCircularProgress { + fileprivate enum Style { + static func backgroundLayer( + _ layer: CAShapeLayer, + model: CircularProgressVM + ) { + layer.fillColor = UIColor.clear.cgColor + layer.strokeColor = model.color.background.uiColor.cgColor + layer.lineCap = .round + layer.lineWidth = model.circularLineWidth + layer.isHidden = model.isBackgroundLayerHidden + } + + static func progressLayer( + _ layer: CAShapeLayer, + model: CircularProgressVM + ) { + layer.fillColor = UIColor.clear.cgColor + layer.strokeColor = model.color.main.uiColor.cgColor + layer.lineCap = .round + layer.lineWidth = model.circularLineWidth + } + + static func label( + _ label: UILabel, + model: CircularProgressVM + ) { + label.textAlignment = .center + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.font = model.titleFont.uiFont + label.textColor = model.color.main.uiColor + } + + static func stripesLayer( + _ layer: CAShapeLayer, + model: CircularProgressVM + ) { + layer.isHidden = model.isStripesLayerHidden + layer.fillColor = model.color.main.uiColor.cgColor + } + + static func stripesMaskLayer( + _ layer: CAShapeLayer, + model: CircularProgressVM + ) { + layer.fillColor = UIColor.clear.cgColor + layer.strokeColor = model.color.background.uiColor.cgColor + layer.lineCap = .round + layer.lineWidth = model.circularLineWidth + } + } +}