How to create Activity Ring in SwiftUI
source link: https://sarunw.com/posts/how-to-create-activity-ring-in-swiftui/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
How to create Activity Ring in SwiftUI
Table of Contents
In this article, I'm going to guide you through my thinking process of how to replicate the Activity Ring (The one you see in Apple Watch) in SwiftUI.
I encourage you to think along and use this article as the answer key. To be able to create this activity ring, I have written articles of basic SwiftUI components that you should know here:
You can easily support sarunw.com by checking out this sponsor.
Decomposition
The first thing you need to do before creating a custom view is to figure out how your custom views are composed of. Try to break down a complex view into smaller and simpler views. If you can do this, you are half-way done.
An activity ring viewExercise
Try to figure out how many views do you need in this case. You might not be able to get it right on the first try (neither can I).
Three to one
For me, this view composes of three identical rings. The only differences are their size and colors, so I will focus on replicate one ring and make sure I have an option to set its colors and size.
Choose your stack
The ring composes of two identical circular views. One is sitting on top of another. The bottom and the subtle one use when there is no progress and the vibrant one to fill the progress. These are enough for me to get the first version.
Here are my initial components:
Circle
view with solid colorCircle
view with a gradient colorZStack
to present the secondCircle
view on top of the firstCircle
view
You can also use .overlay
instead of ZStack
. There are many ways to accomplish this activity ring. If your solution isn't the same as mine, that's ok.
Ring up the curtain (1st iteration)
After you got all the components, let's put it back together. The following is my first draft.
Color extension to use in this article.
extension Color {
public static var outlineRed: Color {
return Color(decimalRed: 34, green: 0, blue: 3)
}
public static var darkRed: Color {
return Color(decimalRed: 221, green: 31, blue: 59)
}
public static var lightRed: Color {
return Color(decimalRed: 239, green: 54, blue: 128)
}
public init(decimalRed red: Double, green: Double, blue: Double) {
self.init(red: red / 255, green: green / 255, blue: blue / 255)
}
}
Our custom view.
struct ActivityRingView: View {
var colors: [Color] = [Color.darkRed, Color.lightRed]
var body: some View {
ZStack {
Circle()
.stroke(Color.outlineRed, lineWidth: 20)
Circle()
.stroke(
AngularGradient(
gradient: Gradient(colors: colors),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
}.frame(idealWidth: 300, idealHeight: 300, alignment: .center)
}
}
Put it in use.
struct ContentView: View {
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
ActivityRingView()
.fixedSize()
}
}
}
Here is our result.
An activity ring view (1)Debugging Tip
As you can see, the top Circle is cover the entire bottom Circle, which is hard to see whether everything works as expected or not. Luckily, Xcode has the capability to debug view hierarchy.
Just go to the menu Debug
> View Debugging
> Capture View Hierarchy
to enter view hierarchy debugging.
Another way is to click the Debug view hierarchy button in the debug area.
Enter debug view hierarchy from debug areaBoth methods will bring you to view hierarchy debugging.
Debug view hierarchyThat's good enough on our first try, next steps we would make progress adjust based on progress
parameter.
Exercise
Try making the ring adjust based on progress
variable.
Make progress
Adding progress is an easy task if you know a little bit of SwiftUI data. If you didn't, you could read my three-part articles about it.
struct ActivityRingView: View {
@Binding var progress: CGFloat
var colors: [Color] = [Color.darkRed, Color.lightRed]
var body: some View {
ZStack {
Circle()
.stroke(Color.outlineRed, lineWidth: 20)
Circle()
.trim(from: 0, to: progress)
.stroke(
AngularGradient(
gradient: Gradient(colors: colors),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
}.frame(idealWidth: 300, idealHeight: 300, alignment: .center)
}
}
With two lines of code @Binding
and .trim
, our view is now progressible.
To use this, the caller needs to provide bindable CGFloat
.
struct ContentView: View {
@State private var progress: CGFloat = 0.3
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
ActivityRingView2(progress: $progress)
.fixedSize()
}
}
}
Run the above example and get the following result.
An activity ring view with progress (2)Run rings around (2nd iteration)
Our activity ring is now supporting setting progress, but there are a few things we need to fix.
The problems are:
- Progress start from the rightmost
- The rounding cap at starting position show end color (
.lightRed
)
Exercise
Try solving the following problems.
- Progress start from the rightmost
- The rounding cap at starting position show end color (
.lightRed
)
Rotation
The first problem is quite easy to solve with .rotationEffect
. Apply -90
degree to the view.
struct ActivityRingView: View {
@Binding var progress: CGFloat
var colors: [Color] = [Color.darkRed, Color.lightRed]
var body: some View {
ZStack {
Circle()
.stroke(Color.outlineRed, lineWidth: 20)
Circle()
.trim(from: 0, to: progress)
.stroke(
AngularGradient(
gradient: Gradient(colors: colors),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
).rotationEffect(.degrees(-90))
}.frame(idealWidth: 300, idealHeight: 300, alignment: .center)
}
}
An activity ring view with rotation (3)Fixing the color
AngularGradient applies the color as the angle changes; you define the start and end angle which gradient will be applied to. This makes you see the starting and ending colors at the very top, which is the starting and ending point where both colors meet.
AngularGradient starting and ending color meet at the top (progress = 1)AngularGradient starting and ending color meet at the top (progress = 0.3)I overcome this by place another Circle view at the starting position.
If you failed to finish the previous exercise, try again as I gave you a new hint.
To fix this, I create another Circle view with the same size as lineWidth
(20
), the same color as the starting point (.darkRed
), and positioned at the starting point (-150
which is the distance of the circle radius).
Circle()
.frame(width: 20, height: 20)
.foregroundColor(Color.darkRed)
.offset(y: -150)
struct ActivityRingView: View {
@Binding var progress: CGFloat
var colors: [Color] = [Color.darkRed, Color.lightRed]
var body: some View {
ZStack {
Circle()
.stroke(Color.outlineRed, lineWidth: 20)
Circle()
.trim(from: 0, to: progress)
.stroke(
AngularGradient(
gradient: Gradient(colors: colors),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
).rotationEffect(.degrees(-90))
Circle()
.frame(width: 20, height: 20)
.foregroundColor(Color.darkRed)
.offset(y: -150)
}.frame(idealWidth: 300, idealHeight: 300, alignment: .center)
}
}
Run it to see the result.
The new Circle cover the starting pointIt looks great, but if you set progress
to 1
, you would see another problem popping up.
Let's revisit the final design that we want. The ending part will be over the starting part with a shadow (the inner ring).
The ending part will be over the starting part with a shadow. This is the last thing we are going to fix.The last iteration
To fix the last problem, I use the same similar technique that I had used in the previous problem. I create another Circle
on the topmost, which move along with the current progress.
Exercise
With all the knowledge you have learned so far, I encourage you to try to do the last fix yourself. Let's see how far you can go.
I create another Circle view with the same size as lineWidth
(20
), the same color as ending point (.lightRed
), and positioned at the starting point (-150
which is the distance of the circle radius). These steps are similar to our previous solution.
Then we add a .shadow
and apply .rotationEffect
based on progress
, so it moves along with the current progress.
Circle()
.frame(width: 20, height: 20)
.offset(y: -150)
.foregroundColor(Color.lightRed)
.rotationEffect(Angle.degrees(360 * Double(progress)))
.shadow(Color.black.opacity(0.1))
The result looks good.
But if the progress
less than 1
, you will see the odd. The end color stands out when the progress
is less than 1
.
I fix this with some if
condition. I apply shadow and show end color only when progress
almost reaches 1
.
Circle()
.frame(width: 20, height: 20)
.foregroundColor(progress > 0.95 ? Color.lightRed: Color.lightRed.opacity(0))
.offset(y: -150)
.rotationEffect(Angle.degrees(360 * Double(progress)))
.shadow(color: progress > 0.95 ? Color.black.opacity(0.1): Color.clear, radius: 3, x: 4, y: 0)
The following is the final code and result.
struct ActivityRingView: View {
@Binding var progress: CGFloat
var colors: [Color] = [Color.darkRed, Color.lightRed]
var body: some View {
ZStack {
Circle()
.stroke(Color.outlineRed, lineWidth: 20)
Circle()
.trim(from: 0, to: progress)
.stroke(
AngularGradient(
gradient: Gradient(colors: colors),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
).rotationEffect(.degrees(-90))
Circle()
.frame(width: 20, height: 20)
.foregroundColor(Color.darkRed)
.offset(y: -150)
Circle()
.frame(width: 20, height: 20)
.foregroundColor(progress > 0.95 ? Color.lightRed: Color.lightRed.opacity(0))
.offset(y: -150)
.rotationEffect(Angle.degrees(360 * Double(progress)))
.shadow(color: progress > 0.96 ? Color.black.opacity(0.1): Color.clear, radius: 3, x: 4, y: 0)
}.frame(idealWidth: 300, idealHeight: 300, alignment: .center)
}
}
I also add .animation(.spring(response: 0.6, dampingFraction: 1.0, blendDuration: 1.0))
to the very end of ActivityRingView
to make the view supports animation.
For more information about animation, visit SwiftUI Animation
Ring down the curtain
There are still improvements you can make upon this, e.g., make .offset
dynamic with the frame (currently it hard code to 150
), pack three rings to make an identical as Apple Activity ring, or make the ring supports progress
more than 1
.
I encourage you to implement those improvements yourself. I may write a follow-up of this article to cover those things.
If you love this article, Subscribe or Follow me on Twitter to get more posts like this. Sharing this with your friends is greatly appreciated.
You can easily support sarunw.com by checking out this sponsor.
Related Resources
You may also like
Initializing an NSViewController without nib isn't straightforward as UIViewController. Trying to do so would result in a runtime error. Let's learn how to do that.
SwiftUISetting background color in SwiftUI is not as straightforward as UIKit. Let's learn how to do it.
SwiftUIRead more article about SwiftUI
or see all available topic
Enjoy the read?
If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.
If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.
Testing Remote Push Notification in iOS simulator
A new and easier way to test Apple push notification on iOS simulator.
How to create a new Xcode project without Storyboard
Modify AppDelegate or SceneDelegate to support a non-storyboard approach.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK