Creating a Capsule Style Progress in SwiftUI

Viewed 369

I'm trying to create a capsule style progress view in SwiftUI, basing my code on the popular circle progress style.

Here is the code:

struct PSOCapsuleProgressView: View{
    var body: some View{
        ZStack{
            Text("20/99")
                .font(.system(size: 12, weight: .light, design: .rounded))
               
            Capsule(style: .circular)
                .stroke(lineWidth: 3)
                .foregroundColor(.red)
                .padding(4)
                .opacity(0.3)
            
            Capsule(style: .circular)
                .trim(from: 0.0, to: 0.5)
                .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
                .foregroundColor(.red)
                .padding(4)
        }
    }
}

This is how it looks:

enter image description here

My only issue is how to make the progress start from the middle top? The circle progress view's that I've seen use a rotation effect but in this case it won't work because its not circular.

Any help is greatly appreciated.

2 Answers

To solve this you need to construct your own Shape. The trim will start from where the shape starts so you have to start drawing your Shape from the top middle. This gives us something like this:

struct MyCapsule: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        let halfHeight = rect.height / 2
        let halfWidth = rect.width / 2

        path.move(to: CGPoint(x: halfWidth, y: 0))

        path.addLine(to: CGPoint(x: rect.width - halfHeight, y: 0))

        path.addCurve(to: CGPoint(x: rect.width, y: halfHeight),
                      control1: CGPoint(x: rect.width, y: 0),
                      control2: CGPoint(x: rect.width, y: halfHeight))

        path.addCurve(to: CGPoint(x: rect.width - halfHeight, y: rect.height),
                      control1: CGPoint(x: rect.width, y: halfHeight),
                      control2: CGPoint(x: rect.width, y: rect.height))

        path.addLine(to: CGPoint(x: halfHeight, y: rect.height))

        path.addCurve(to: CGPoint(x: 0, y: halfHeight),
                      control1: CGPoint(x: 0, y: rect.height),
                      control2: CGPoint(x: 0, y: halfHeight))

        path.addCurve(to: CGPoint(x: halfHeight, y: 0),
                      control1: CGPoint(x: 0, y: 0),
                      control2: CGPoint(x: halfHeight, y: 0))

        path.addLine(to: CGPoint(x: halfWidth, y: 0))

        return path
    }
}

Then we can set up our ContentView like this:

struct ContentView: View {

    @State private var trim: CGFloat = 0

    var body: some View {
        ZStack {
            MyCapsule()
                .stroke(Color.red)
                .opacity(0.6)

            MyCapsule()
                .trim(from: 0, to: trim)
                .stroke(Color.red,
                        style: StrokeStyle(lineWidth: 4,
                                           lineCap: .round,
                                           lineJoin: .round))

            Button(action: {
                withAnimation(Animation.easeIn(duration: 2)) {
                    trim = trim == 0 ? 1 : 0
                }
            }, label: {
                Text("Animate in")
            })

        }
        .frame(width: 200, height: 100)
    }
}

This gives the following result:

enter image description here

Note because it is a Shape it will be greedy and fill up as much of the View as it can, so make sure you set a frame around it.

It is a bit kludgy, but it appears the only way to do this is to add another Capsule and have the second Capsule run from 0.75 to 1.0, and the other to run from 0.0 to 0.25:

struct PSOCapsuleProgressView: View{
        var body: some View{
            ZStack{
                Text("20/99")
                    .font(.system(size: 12, weight: .light, design: .rounded))
                   
                Capsule(style: .circular)
                    .stroke(lineWidth: 3)
                    .foregroundColor(.red)
                    .padding(4)
                    .opacity(0.3)
                
                Capsule(style: .circular)
                    .trim(from: 0.75, to: 1.0)
                    .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
                    .foregroundColor(.red)
                    .padding(4)
                
                Capsule(style: .circular)
                    .trim(from: 0.0, to: 0.25)
                    .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
                    .foregroundColor(.red)
                    .padding(4)
            }
        }
    }

You will have to manage which capsule responds to the data. It may be possible, depending upon how your updates work to send the data into a ForEach as an array and create as many Capsules as you need in smaller increments.

Related