How to create Activity Ring in SwiftUI

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:

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 view

Exercise # 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 color Circle view with a gradient color ZStack to present the second Circle view on top of the first Circle 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.

Debug > View Debugging > Capture View Hierarchy

Another way is to click the Debug view hierarchy button in the debug area.

Enter debug view hierarchy from debug area

Both methods will bring you to view hierarchy debugging.

Debug view hierarchy

That'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 )

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 point

It looks great, but if you set progress to 1 , you would see another problem popping up.

The new Circle cover the ending part of the ring view

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

The last iteration #

. This is the last thing we are going to fix.

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.

Related Resources #

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.

Become a patron

Tweet

Share

← Home