Last active
July 17, 2024 09:21
-
-
Save mattyoung/3dcdbaedd762a8e78de2baea4a28c14b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://sarunw.com/posts/swiftui-circular-progress-bar/ | |
// | |
// CircularProgressView.swift | |
// SwiftUI30WWDC2021 | |
// | |
// Created by Mateo on 5/6/22. | |
// | |
// dependency: https://gist.github.com/mattyoung/5f5c0f13c06d980e823481e0334c00fe | |
import SwiftUI | |
struct AnimatablePercentModifier: AnimatableModifier { | |
let animatableData: Double | |
let label: Text | |
init(number: Double) { | |
animatableData = number | |
label = Text(Self.applyingFormatStyle(number)) | |
} | |
static let font = Font.system(size: 250, weight: .bold, design: .rounded) | |
static let fontSmall = Font.system(size: 150, weight: .bold, design: .rounded) | |
static func applyingFormatStyle(_ value: Double) -> AttributedString { | |
var s = value.formatted(.percent.precision(.fractionLength(1...2)).attributed) | |
var integerRange: Range<AttributedString.Index>? | |
var decimalSeparatorRange: Range<AttributedString.Index>? | |
var fractionRange: Range<AttributedString.Index>? | |
var percentRange: Range<AttributedString.Index>? | |
s.runs.forEach { run in | |
if let numberRun = run.numberPart { | |
switch numberRun { | |
case .integer: | |
assert(integerRange == nil, "Seen integer part already form input \(value)") | |
integerRange = run.range | |
case .fraction: | |
assert(fractionRange == nil, "Seen fraction part already form input \(value)") | |
fractionRange = run.range | |
@unknown default: | |
break | |
} | |
} | |
if let symbolRun = run.numberSymbol { | |
switch symbolRun { | |
case .decimalSeparator: | |
assert(decimalSeparatorRange == nil, "Seen decimalSeparator part already form input \(value)") | |
decimalSeparatorRange = run.range | |
case .percent: | |
assert(percentRange == nil, "Seen percent part already form input \(value)") | |
percentRange = run.range | |
case .groupingSeparator: | |
break | |
case .sign: | |
break | |
case .currency: | |
break | |
@unknown default: | |
break | |
} | |
} | |
} | |
guard let integerRange, let decimalSeparatorRange, let fractionRange, let percentRange else { | |
fatalError("This AttributedString is not in percent format") | |
} | |
s[integerRange].font = font | |
s[decimalSeparatorRange].font = fontSmall | |
s[fractionRange].font = fontSmall | |
s[percentRange].font = font | |
return s | |
} | |
func body(content: Content) -> some View { | |
content | |
.overlay(label) | |
} | |
} | |
extension View { | |
func animatingPercent(for number: Double) -> some View { | |
modifier(AnimatablePercentModifier(number: number)) | |
} | |
} | |
struct CircularProgressView<RingStyle: ShapeStyle, TextStyle: ShapeStyle>: View { | |
let progress: Double // 0 to 1 display as percent | |
let ringStyle: RingStyle | |
let textStyle: TextStyle | |
// "SE-0347 Type inference from default expressions" will allow default values: | |
// init(progress: Double, ringStyle: RingStyle = .tint, textStyle: TextStyle = .primary) { | |
// as of now, we use conditional extension to provide these defaults | |
init(progress: Double, ringStyle: RingStyle = .tint, textStyle: TextStyle = .primary) { | |
self.progress = progress | |
self.ringStyle = ringStyle | |
self.textStyle = textStyle | |
} | |
@State private var viewSize = CGSize.zero | |
var lineWidth: CGFloat { | |
min(viewSize.width, viewSize.height) * 0.13 | |
} | |
var body: some View { | |
Circle() | |
.trim(from: 0, to: progress) | |
.stroke(ringStyle, style: .init(lineWidth: lineWidth * 0.7, lineCap: .round)) | |
.rotationEffect(.degrees(-90)) | |
.overlay { | |
Text(1.0, format: .percent.precision(.fractionLength(0))) // invisible template view for Text sizing | |
.font(AnimatablePercentModifier.font) | |
.hidden() | |
.padding(.horizontal, min(viewSize.width, viewSize.height) * 0.125) | |
.frame(width: min(viewSize.width, viewSize.height)) | |
.animatingPercent(for: progress) | |
.foregroundStyle(textStyle) | |
.lineLimit(1) | |
.minimumScaleFactor(0.01) | |
} | |
.background { | |
Circle() | |
.stroke(ringStyle, lineWidth: lineWidth) | |
.opacity(0.5) | |
.rotationEffect(.degrees(-90)) | |
} | |
.animation(.easeOut(duration: 0.8), value: progress) | |
.readSize($into: $viewSize) | |
.padding() | |
} | |
} | |
//// provide init parameters default values for ringStye = .tint and/or textStyle = .primary | |
//extension CircularProgressView where RingStyle == TintShapeStyle, TextStyle == HierarchicalShapeStyle { | |
// init(progress: Double) { | |
// self.init(progress: progress, ringStyle: .tint, textStyle: .primary) | |
// } | |
//} | |
// | |
//extension CircularProgressView where TextStyle == HierarchicalShapeStyle { | |
// init(progress: Double, ringStyle: RingStyle) { | |
// self.init(progress: progress, ringStyle: ringStyle, textStyle: .primary) | |
// } | |
//} | |
// | |
//extension CircularProgressView where RingStyle == TintShapeStyle { | |
// init(progress: Double, textStyle: TextStyle) { | |
// self.init(progress: progress, ringStyle: .tint, textStyle: textStyle) | |
// } | |
//} | |
struct CircularProgressViewDemo: View { | |
@State private var progress = 0.0 | |
var body: some View { | |
GeometryReader { proxy in | |
VStack(spacing: 0) { | |
let angularGradient = AngularGradient(colors: [.green, .yellow, .orange, .red, .purple, .blue, .green], center: .center) | |
let ellipticalGradient = EllipticalGradient(colors: [.green, .yellow, .orange, .red, .purple, .blue]) | |
CircularProgressView(progress: progress, ringStyle: angularGradient, textStyle: ellipticalGradient) | |
.frame(height: proxy.size.height / 2.5) | |
HStack { | |
CircularProgressView(progress: 1 - progress, ringStyle: .red) | |
CircularProgressView(progress: progress, textStyle: .red) | |
} | |
.frame(height: proxy.size.height / 3) | |
HStack { | |
CircularProgressView(progress: progress) | |
CircularProgressView(progress: progress, ringStyle: .indigo) | |
CircularProgressView(progress: progress, textStyle: .green) | |
CircularProgressView(progress: progress, ringStyle: .orange, textStyle: .orange) | |
} | |
Slider(value: $progress, in: 0...1) | |
.padding() | |
Button { | |
progress = .random(in: 0...1) | |
} label: { | |
Label("Random", systemImage: "camera.filters") | |
.frame(maxWidth: .infinity, alignment: .center) | |
} | |
.padding(.horizontal) | |
.buttonStyle(.borderedProminent) | |
.controlSize(.large) | |
} | |
} | |
} | |
} | |
struct CircularProgressViewDemo_Previews: PreviewProvider { | |
static var previews: some View { | |
CircularProgressViewDemo() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment