diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index 40c9edee..436dcb68 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -7,6 +7,7 @@ struct CircularProgressPreview: View { @State private var currentValue: CGFloat = Self.initialValue private let circularProgress = UKCircularProgress( + initialValue: Self.initialValue, model: Self.initialModel ) @@ -36,17 +37,21 @@ struct CircularProgressPreview: View { Form { ComponentColorPicker(selection: self.$model.color) CaptionFontPicker(selection: self.$model.font) + Picker("Line Cap", selection: self.$model.lineCap) { + Text("Rounded").tag(CircularProgressVM.LineCap.rounded) + Text("Square").tag(CircularProgressVM.LineCap.square) + } 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) + Picker("Shape", selection: self.$model.shape) { + Text("Circle").tag(CircularProgressVM.Shape.circle) + Text("Arc").tag(CircularProgressVM.Shape.arc) } + SizePicker(selection: self.$model.size) } .onReceive(self.timer) { _ in if self.currentValue < self.model.maxValue { @@ -71,7 +76,6 @@ struct CircularProgressPreview: View { private static var initialModel = CircularProgressVM { $0.label = "0%" - $0.style = .light } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift new file mode 100644 index 00000000..72a86b8c --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift @@ -0,0 +1,38 @@ +import SwiftUI +import UIKit + +extension CircularProgressVM { + /// Defines the style of line endings. + public enum LineCap { + /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. + case rounded + /// The line ends exactly at the endpoint with a flat edge. + case square + } +} + +// MARK: - UIKit Helpers + +extension CircularProgressVM.LineCap { + var shapeLayerLineCap: CAShapeLayerLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} + +// MARK: - SwiftUI Helpers + +extension CircularProgressVM.LineCap { + var cgLineCap: CGLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift new file mode 100644 index 00000000..7c4e4e1c --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift @@ -0,0 +1,11 @@ +import Foundation + +extension CircularProgressVM { + /// Defines the shapes for the circular progress component. + public enum Shape { + /// Renders a complete circle to represent the progress. + case circle + /// Renders only a portion of the circle (an arc) to represent progress. + case arc + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift deleted file mode 100644 index 3c0588d7..00000000 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift +++ /dev/null @@ -1,9 +0,0 @@ -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 index a032d056..768c487e 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -7,34 +7,37 @@ public struct CircularProgressVM: ComponentVM { /// Defaults to `.accent`. public var color: ComponentColor = .accent - /// The style of the circular progress indicator. - /// - /// Defaults to `.light`. - public var style: Style = .light + /// The font used for the circular progress label text. + public var font: UniversalFont? - /// The size of the circular progress. - /// - /// Defaults to `.medium`. - public var size: ComponentSize = .medium + /// An optional label to display inside the circular progress. + public var label: String? - /// The minimum value of the circular progress. - /// - /// Defaults to `0`. - public var minValue: CGFloat = 0 + /// The style of line endings. + public var lineCap: LineCap = .rounded + + /// The width of the circular progress stroke. + public var lineWidth: CGFloat? /// 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? + /// The minimum value of the circular progress. + /// + /// Defaults to `0`. + public var minValue: CGFloat = 0 - /// An optional label to display inside the circular progress. - public var label: String? + /// The shape of the circular progress indicator. + /// + /// Defaults to `.circle`. + public var shape: Shape = .circle - /// The font used for the circular progress label text. - public var font: UniversalFont? + /// The size of the circular progress. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium /// Initializes a new instance of `CircularProgressVM` with default values. public init() {} @@ -68,6 +71,22 @@ extension CircularProgressVM { y: self.preferredSize.height / 2 ) } + var startAngle: CGFloat { + switch self.shape { + case .circle: + return -0.5 * .pi + case .arc: + return 0.75 * .pi + } + } + var endAngle: CGFloat { + switch self.shape { + case .circle: + return 1.5 * .pi + case .arc: + return 2.25 * .pi + } + } var titleFont: UniversalFont { if let font { return font @@ -81,44 +100,6 @@ extension CircularProgressVM { return .lgCaption } } - var stripeWidth: CGFloat { - return 0.5 - } - private func stripesCGPath(in rect: CGRect) -> CGMutablePath { - let stripeSpacing: CGFloat = 3 - let stripeAngle: Angle = .degrees(135) - - let path = CGMutablePath() - let step = stripeWidth + stripeSpacing - let radians = stripeAngle.radians - - 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 bottomRight = CGPoint(x: x + dx, y: rect.height) - - path.move(to: topLeft) - path.addLine(to: bottomRight) - path.closeSubpath() - } - return path - } -} - -extension CircularProgressVM { - func gap(for normalized: CGFloat) -> CGFloat { - return normalized > 0 ? 0.05 : 0 - } - - func stripedArcStart(for normalized: CGFloat) -> CGFloat { - let gapValue = self.gap(for: normalized) - return max(0, min(1, normalized + gapValue)) - } - - func stripedArcEnd(for normalized: CGFloat) -> CGFloat { - let gapValue = self.gap(for: normalized) - return 1 - gapValue - } } extension CircularProgressVM { @@ -133,33 +114,6 @@ 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 } @@ -170,12 +124,7 @@ extension CircularProgressVM { return self.minValue != oldModel.minValue || self.maxValue != oldModel.maxValue } -} - -// MARK: - SwiftUI Helpers - -extension CircularProgressVM { - func stripesPath(in rect: CGRect) -> Path { - Path(self.stripesCGPath(in: rect)) + func shouldUpdateShape(_ oldModel: Self) -> Bool { + return self.shape != oldModel.shape } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift index 0ba970ac..993c4dee 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component that displays a circular progress. +/// A SwiftUI component that displays the progress of a task or operation in a circular form. public struct SUCircularProgress: View { // MARK: - Properties @@ -33,14 +33,22 @@ public struct SUCircularProgress: View { public var body: some View { ZStack { // Background part - Group { - switch self.model.style { - case .light: - self.lightBackground - case .striped: - self.stripedBackground - } + Path { path in + path.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) } + .stroke( + self.model.color.background.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) + ) .frame( width: self.model.preferredSize.width, height: self.model.preferredSize.height @@ -51,8 +59,8 @@ public struct SUCircularProgress: View { path.addArc( center: self.model.center, radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), clockwise: false ) } @@ -61,10 +69,9 @@ public struct SUCircularProgress: View { self.model.color.main.color, style: StrokeStyle( lineWidth: self.model.circularLineWidth, - lineCap: .round + lineCap: self.model.lineCap.cgLineCap ) ) - .rotationEffect(.degrees(-90)) .frame( width: self.model.preferredSize.width, height: self.model.preferredSize.height @@ -82,62 +89,4 @@ public struct SUCircularProgress: View { 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 { - StripesShapeCircularProgress(model: self.model) - .stroke( - self.model.color.main.color, - style: StrokeStyle(lineWidth: self.model.stripeWidth) - ) - .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.stripedArcStart(for: self.progress), - to: self.model.stripedArcEnd(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) - } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift index e910e623..e68f5001 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift @@ -1,7 +1,7 @@ import AutoLayout import UIKit -/// A UIKit component that displays a circular progress indicator. +/// A UIKit component that displays the progress of a task or operation in a circular form. open class UKCircularProgress: UIView, UKComponent { // MARK: - Properties @@ -21,19 +21,13 @@ open class UKCircularProgress: UIView, UKComponent { // MARK: - Subviews - /// The shape layer responsible for rendering the background of the circular progress indicator in a light style. + /// The shape layer responsible for rendering the background. public let backgroundLayer = CAShapeLayer() - /// The shape layer responsible for rendering the progress arc of the circular progress indicator. + /// The shape layer responsible for rendering the progress arc. 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. + /// The label used to display text. public let label = UILabel() // MARK: - UIView Properties @@ -69,24 +63,16 @@ open class UKCircularProgress: UIView, UKComponent { 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.progressLayer.strokeEnd = self.model.progress(for: self.currentValue) self.label.text = self.model.label } @@ -96,8 +82,6 @@ open class UKCircularProgress: UIView, UKComponent { 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 @@ -105,7 +89,6 @@ open class UKCircularProgress: UIView, UKComponent { public func update(_ oldModel: CircularProgressVM) { guard self.model != oldModel else { return } self.style() - self.updateShapePaths() if self.model.shouldUpdateText(oldModel) { UIView.transition( @@ -121,6 +104,9 @@ open class UKCircularProgress: UIView, UKComponent { if self.model.shouldRecalculateProgress(oldModel) { self.updateProgress() } + if self.model.shouldUpdateShape(oldModel) { + self.updateShapePaths() + } if self.model.shouldInvalidateIntrinsicContentSize(oldModel) { self.invalidateIntrinsicContentSize() } @@ -128,31 +114,25 @@ open class UKCircularProgress: UIView, UKComponent { private func updateShapePaths() { let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) + let minSide = min(self.bounds.width, self.bounds.height) + let radius = (minSide - self.model.circularLineWidth) / 2 let circlePath = UIBezierPath( arcCenter: center, - radius: self.model.radius, - startAngle: -CGFloat.pi / 2, - endAngle: -CGFloat.pi / 2 + 2 * .pi, + radius: radius, + startAngle: self.model.startAngle, + endAngle: self.model.endAngle, 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) - } + self.progressLayer.strokeEnd = self.model.progress(for: self.currentValue) CATransaction.commit() } @@ -167,8 +147,6 @@ open class UKCircularProgress: UIView, UKComponent { self.backgroundLayer.frame = self.bounds self.progressLayer.frame = self.bounds - self.stripesLayer.frame = self.bounds - self.stripesMaskLayer.frame = self.bounds self.updateShapePaths() } @@ -191,8 +169,6 @@ open class UKCircularProgress: UIView, UKComponent { 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) } } @@ -205,10 +181,9 @@ extension UKCircularProgress { model: CircularProgressVM ) { layer.fillColor = UIColor.clear.cgColor - layer.strokeColor = model.color.background.uiColor.cgColor - layer.lineCap = .round + layer.strokeColor = model.color.background.cgColor + layer.lineCap = model.lineCap.shapeLayerLineCap layer.lineWidth = model.circularLineWidth - layer.isHidden = model.isBackgroundLayerHidden } static func progressLayer( @@ -217,7 +192,7 @@ extension UKCircularProgress { ) { layer.fillColor = UIColor.clear.cgColor layer.strokeColor = model.color.main.uiColor.cgColor - layer.lineCap = .round + layer.lineCap = model.lineCap.shapeLayerLineCap layer.lineWidth = model.circularLineWidth } @@ -226,29 +201,8 @@ extension UKCircularProgress { 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.strokeColor = model.color.main.uiColor.cgColor - layer.lineWidth = model.stripeWidth - } - - 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 - } } }