Skip to content
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
@@ -0,0 +1,67 @@
import ComponentsKit
import SwiftUI
import UIKit

struct CircularProgressPreview: View {
@State private var model = Self.initialModel
@State private var currentValue: CGFloat = Self.initialValue

private let timer = Timer
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()

var body: some View {
VStack {
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<UniversalFont>.none)
Text("Small").tag(UniversalFont.smButton)
Text("Medium").tag(UniversalFont.mdButton)
Text("Large").tag(UniversalFont.lgButton)
Text("Custom: system bold of size 16").tag(UniversalFont.system(size: 16, weight: .bold))
}
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)
}
}
.onReceive(self.timer) { _ in
if self.currentValue < self.model.maxValue {
let step = (self.model.maxValue - self.model.minValue) / 100
self.currentValue = min(
self.model.maxValue,
self.currentValue + CGFloat(Int.random(in: 1...20)) * step
)
} else {
self.currentValue = self.model.minValue
}
self.model.label = "\(Int(self.currentValue))%"
}
}
}

// MARK: - Helpers

private static var initialValue: Double {
return 0.0
}
private static var initialModel = CircularProgressVM {
$0.label = "0"
$0.style = .light
}
}

#Preview {
CircularProgressPreview()
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct ProgressBarPreview: View {
private let progressBar = UKProgressBar(initialValue: Self.initialValue, model: Self.initialModel)

private let timer = Timer
.publish(every: 0.1, on: .main, in: .common)
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()

var body: some View {
Expand Down Expand Up @@ -43,7 +43,11 @@ struct ProgressBarPreview: View {
}
.onReceive(self.timer) { _ in
if self.currentValue < self.model.maxValue {
self.currentValue += (self.model.maxValue - self.model.minValue) / 100
let step = (self.model.maxValue - self.model.minValue) / 100
self.currentValue = min(
self.model.maxValue,
self.currentValue + CGFloat(Int.random(in: 1...20)) * step
)
} else {
self.currentValue = self.model.minValue
}
Expand Down
3 changes: 3 additions & 0 deletions Examples/DemosApp/DemosApp/Core/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct App: View {
NavigationLinkWithTitle("Checkbox") {
CheckboxPreview()
}
NavigationLinkWithTitle("Circular Progress") {
CircularProgressPreview()
}
NavigationLinkWithTitle("Countdown") {
CountdownPreview()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension CircularProgressVM {
public enum Style {
/// Defines the visual styles for the circular progress component.
case light
case striped
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import SwiftUI

/// A model that defines the appearance properties for a circular progress component.
public struct CircularProgressVM: ComponentVM {
/// The color of the circular progress.
///
/// Defaults to `.accent`.
public var color: ComponentColor = .accent

/// The style of the circular progress indicator.
///
/// Defaults to `.light`.
public var style: Style = .light

/// The size of the circular progress.
///
/// Defaults to `.medium`.
public var size: ComponentSize = .medium

/// The minimum value of the circular progress.
///
/// Defaults to `0`.
public var minValue: CGFloat = 0

/// 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?

/// An optional label to display inside the circular progress.
public var label: String?

/// The font used for the circular progress label text.
public var font: UniversalFont?

/// Initializes a new instance of `CircularProgressVM` with default values.
public init() {}
}

// MARK: Shared Helpers

extension CircularProgressVM {
var animationDuration: TimeInterval {
return 0.2
}
var circularLineWidth: CGFloat {
return self.lineWidth ?? max(self.preferredSize.width / 8, 2)
}
var preferredSize: CGSize {
switch self.size {
case .small:
return CGSize(width: 48, height: 48)
case .medium:
return CGSize(width: 64, height: 64)
case .large:
return CGSize(width: 80, height: 80)
}
}
var radius: CGFloat {
return self.preferredSize.height / 2 - self.circularLineWidth / 2
}
var center: CGPoint {
return .init(
x: self.preferredSize.width / 2,
y: self.preferredSize.height / 2
)
}
var titleFont: UniversalFont {
if let font {
return font
}
switch self.size {
case .small:
return .smCaption
case .medium:
return .mdCaption
case .large:
return .lgCaption
}
}
private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
let stripeWidth: CGFloat = 0.5
let stripeSpacing: CGFloat = 3
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
}
}

extension CircularProgressVM {
func gap(for normalized: CGFloat) -> CGFloat {
normalized > 0 ? 0.05 : 0
}

func progressArcStart(for normalized: CGFloat) -> CGFloat {
return 0
}

func progressArcEnd(for normalized: CGFloat) -> CGFloat {
return max(0, min(1, normalized))
}

func backgroundArcStart(for normalized: CGFloat) -> CGFloat {
let gapValue = self.gap(for: normalized)
return max(0, min(1, normalized + gapValue))
}

func backgroundArcEnd(for normalized: CGFloat) -> CGFloat {
let gapValue = self.gap(for: normalized)
return 1 - gapValue
}
}

extension CircularProgressVM {
public 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))
}
}

// MARK: - SwiftUI Helpers

extension CircularProgressVM {
func stripesPath(in rect: CGRect) -> Path {
Path(self.stripesCGPath(in: rect))
}
}
Loading