-
-
Save frankfka/2517d69da68ef041e3257d5cfd27fe5d to your computer and use it in GitHub Desktop.
import SwiftUI | |
import PlaygroundSupport | |
extension Double { | |
func toRadians() -> Double { | |
return self * Double.pi / 180 | |
} | |
func toCGFloat() -> CGFloat { | |
return CGFloat(self) | |
} | |
} | |
// https://liquidcoder.com/swiftui-ring-animation/ | |
struct RingShape: Shape { | |
// Helper function to convert percent values to angles in degrees | |
static func percentToAngle(percent: Double, startAngle: Double) -> Double { | |
(percent / 100 * 360) + startAngle | |
} | |
private var percent: Double | |
private var startAngle: Double | |
private let drawnClockwise: Bool | |
// This allows animations to run smoothly for percent values | |
var animatableData: Double { | |
get { | |
return percent | |
} | |
set { | |
percent = newValue | |
} | |
} | |
init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) { | |
self.percent = percent | |
self.startAngle = startAngle | |
self.drawnClockwise = drawnClockwise | |
} | |
// This draws a simple arc from the start angle to the end angle | |
func path(in rect: CGRect) -> Path { | |
let width = rect.width | |
let height = rect.height | |
let radius = min(width, height) / 2 | |
let center = CGPoint(x: width / 2, y: height / 2) | |
let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle)) | |
return Path { path in | |
path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise) | |
} | |
} | |
} | |
struct PercentageRing: View { | |
private static let ShadowColor: Color = Color.black.opacity(0.2) | |
private static let ShadowRadius: CGFloat = 5 | |
private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2 | |
private let ringWidth: CGFloat | |
private let percent: Double | |
private let backgroundColor: Color | |
private let foregroundColors: [Color] | |
private let startAngle: Double = -90 | |
private var gradientStartAngle: Double { | |
self.percent >= 100 ? relativePercentageAngle - 360 : startAngle | |
} | |
private var absolutePercentageAngle: Double { | |
RingShape.percentToAngle(percent: self.percent, startAngle: 0) | |
} | |
private var relativePercentageAngle: Double { | |
// Take into account the startAngle | |
absolutePercentageAngle + startAngle | |
} | |
private var firstGradientColor: Color { | |
self.foregroundColors.first ?? .black | |
} | |
private var lastGradientColor: Color { | |
self.foregroundColors.last ?? .black | |
} | |
private var ringGradient: AngularGradient { | |
AngularGradient( | |
gradient: Gradient(colors: self.foregroundColors), | |
center: .center, | |
startAngle: Angle(degrees: self.gradientStartAngle), | |
endAngle: Angle(degrees: relativePercentageAngle) | |
) | |
} | |
init(ringWidth: CGFloat, percent: Double, backgroundColor: Color, foregroundColors: [Color]) { | |
self.ringWidth = ringWidth | |
self.percent = percent | |
self.backgroundColor = backgroundColor | |
self.foregroundColors = foregroundColors | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
ZStack { | |
// Background for the ring | |
RingShape() | |
.stroke(style: StrokeStyle(lineWidth: self.ringWidth)) | |
.fill(self.backgroundColor) | |
// Foreground | |
RingShape(percent: self.percent, startAngle: self.startAngle) | |
.stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round)) | |
.fill(self.ringGradient) | |
// End of ring with drop shadow | |
if self.getShowShadow(frame: geometry.size) { | |
Circle() | |
.fill(self.lastGradientColor) | |
.frame(width: self.ringWidth, height: self.ringWidth, alignment: .center) | |
.offset(x: self.getEndCircleLocation(frame: geometry.size).0, | |
y: self.getEndCircleLocation(frame: geometry.size).1) | |
.shadow(color: PercentageRing.ShadowColor, | |
radius: PercentageRing.ShadowRadius, | |
x: self.getEndCircleShadowOffset().0, | |
y: self.getEndCircleShadowOffset().1) | |
} | |
} | |
} | |
// Padding to ensure that the entire ring fits within the view size allocated | |
.padding(self.ringWidth / 2) | |
} | |
private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) { | |
// Get angle of the end circle with respect to the start angle | |
let angleOfEndInRadians: Double = relativePercentageAngle.toRadians() | |
let offsetRadius = min(frame.width, frame.height) / 2 | |
return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat()) | |
} | |
private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) { | |
let angleForOffset = absolutePercentageAngle + (self.startAngle + 90) | |
let angleForOffsetInRadians = angleForOffset.toRadians() | |
let relativeXOffset = cos(angleForOffsetInRadians) | |
let relativeYOffset = sin(angleForOffsetInRadians) | |
let xOffset = relativeXOffset.toCGFloat() * PercentageRing.ShadowOffsetMultiplier | |
let yOffset = relativeYOffset.toCGFloat() * PercentageRing.ShadowOffsetMultiplier | |
return (xOffset, yOffset) | |
} | |
private func getShowShadow(frame: CGSize) -> Bool { | |
let circleRadius = min(frame.width, frame.height) / 2 | |
let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat() | |
if self.percent >= 100 { | |
return true | |
} else if circleRadius * remainingAngleInRadians <= self.ringWidth { | |
return true | |
} | |
return false | |
} | |
} | |
struct PreviewView: View { | |
var body: some View { | |
Group { | |
PercentageRing( | |
ringWidth: 50, percent: 5 , | |
backgroundColor: Color.green.opacity(0.2), | |
foregroundColors: [.green, .blue] | |
) | |
.frame(width: 300, height: 300) | |
.previewLayout(.sizeThatFits) | |
} | |
} | |
} | |
PlaygroundPage.current.setLiveView(PreviewView()) |
Hi, are you able to customise the code so it can have multiple rings like up to 6?
hey great stuff thanks for sharing, one problem I have encountered maybe you can help me. Right now when I animate the rings from 0 to over 100% say 150% or 250% all the animations start at same time, meaning the little circle dot for the shadow at end of the tip appears before it should as a little dot; is there a way to have this wait for the ring to finish animating?
Thanks for really cool work here!
hey great stuff thanks for sharing, one problem I have encountered maybe you can help me. Right now when I animate the rings from 0 to over 100% say 150% or 250% all the animations start at same time, meaning the little circle dot for the shadow at end of the tip appears before it should as a little dot; is there a way to have this wait for the ring to finish animating?
Did you get this resolved?
Awesome work! Thank you for this :)