1

How to create Activity Ring in SwiftUI

 2 years ago
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.
neoserver,ios ssh client

How to create Activity Ring in SwiftUI

17 Feb 2020 ⋅ 9 min read ⋅ 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.

essential-dev-image.png Sponsor sarunw.com and reach thousands of iOS developers.

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 viewAn 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:

  1. Circle view with solid color
  2. Circle view with a gradient color
  3. 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.

The first version of activity ring viewAn 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 hierarchy from menuDebug > View Debugging > Capture View Hierarchy

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

Debug view hierarchy from debug areaEnter debug view hierarchy from debug area

Both methods will bring you to view hierarchy debugging.

Debug view hierarchyDebug 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.

The second version of activity ring viewAn 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:

  1. Progress start from the rightmost
  2. The rounding cap at starting position show end color (.lightRed)

Exercise

Try solving the following problems.

  1. Progress start from the rightmost
  2. 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)
}
}
The third version of activity ring viewAn 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 topAngularGradient starting and ending color meet at the top (progress = 1)AngularGradientAngularGradient 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 pointThe 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 viewThe 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 shadowThe 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.

activity-ring-shadow-complete.png

But if the progress less than 1, you will see the odd. The end color stands out when the progress is less than 1.

activity-ring-shadow-bug.png

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)
}
}
activity-ring-complete.png

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.

essential-dev-image.png Sponsor sarunw.com and reach thousands of iOS developers.

Related Resources


You may also like

How to initialize NSViewController programmatically without nib

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.

SwiftUI
How to use UIKit in SwiftUI

Using UIView and UIViewController in SwiftUI

SwiftUI
How to set a screen's background color in SwiftUI

Setting background color in SwiftUI is not as straightforward as UIKit. Let's learn how to do it.

SwiftUI

Read 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.

Become a patron

Buy me a coffee

Tweet

Share

Previous
Testing Remote Push Notification in iOS simulator

A new and easier way to test Apple push notification on iOS simulator.

Next
How to create a new Xcode project without Storyboard

Modify AppDelegate or SceneDelegate to support a non-storyboard approach.

← Home


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK