Skip to content

Improve circular progress styles #80

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 6 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct CircularProgressPreview: View {
@State private var currentValue: CGFloat = Self.initialValue

private let circularProgress = UKCircularProgress(
initialValue: Self.initialValue,
model: Self.initialModel
)

Expand Down Expand Up @@ -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<CGFloat>.none)
Text("2").tag(Optional<CGFloat>.some(2))
Text("4").tag(Optional<CGFloat>.some(4))
Text("8").tag(Optional<CGFloat>.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 {
Expand All @@ -71,7 +76,6 @@ struct CircularProgressPreview: View {

private static var initialModel = CircularProgressVM {
$0.label = "0%"
$0.style = .light
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
)
}
Expand All @@ -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
Expand All @@ -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)
}
}
Loading