Skip to content

Instantly share code, notes, and snippets.

@frankfka
Created April 29, 2020 05:22
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save frankfka/2517d69da68ef041e3257d5cfd27fe5d to your computer and use it in GitHub Desktop.
Save frankfka/2517d69da68ef041e3257d5cfd27fe5d to your computer and use it in GitHub Desktop.
iOS Activity Ring in SwiftUI
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())
@Streebor
Copy link

Awesome work! Thank you for this :)

@alamodey
Copy link

Hi, are you able to customise the code so it can have multiple rings like up to 6?

@MadeByDouglas
Copy link

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?

@moskaliukzhanna
Copy link

Thanks for really cool work here!

@app4g
Copy link

app4g commented Aug 30, 2022

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment