Created
April 29, 2020 05:22
-
-
Save frankfka/2517d69da68ef041e3257d5cfd27fe5d to your computer and use it in GitHub Desktop.
iOS Activity Ring in SwiftUI
This file contains hidden or 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
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()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Did you get this resolved?