Skip to content

Instantly share code, notes, and snippets.

@mattyoung
Last active December 23, 2022 19:18
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mattyoung/3dcdbaedd762a8e78de2baea4a28c14b to your computer and use it in GitHub Desktop.
Save mattyoung/3dcdbaedd762a8e78de2baea4a28c14b to your computer and use it in GitHub Desktop.
// 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