2

I'm trying to implement a circular progress view that looks like so:

enter image description here

struct SpeedometerView: View {
    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea(.all)
            
            speedProgressView(width: 300)
        }
    }
    
    private func speedProgressView(width: CGFloat) -> some View {
        ZStack {
            Circle()
                .trim(from: 0, to: 0.2)
                .stroke(.red, style: StrokeStyle(lineWidth: 4, lineCap: .round))
                .shadow(color: .green, radius: 10, x: 0, y: 0)
                .shadow(color: .green, radius: 2, x: 0, y: 0)
        }
        .frame(width: width)
        .rotationEffect(.degrees(100))
    }
}

#Preview {
    SpeedometerView()
}

Both ends are rounded as of now due to the stroke style. How do I trim/clip only the circle end that progresses clockwise unless progress is 100% ? Any help is appreciated.

2
  • You want only one end of the line to be .round and the other to be .butt, right? Your screenshot shows both ends being .butt though.
    – Sweeper
    Commented Jul 7 at 9:42
  • Yes, that's right. Both ends should be .round at 100% progress, else only one end should. The screenshot was just to illustrate the point, I couldn't figure out how to create rounded ends in Preview :)
    – batman
    Commented Jul 7 at 9:46

3 Answers 3

2

I would write this as a Shape that is filled, instead of a Path that is stroked.

The path of the Shape is the union of:

  • a path created by the strokedPath of a (trimmed) circular path, with the .butt line cap.
  • a circular path with the radius of the line width, at where the trimmed path ends.
struct OneEndRoundedCircle: Shape {
    var trim: CGFloat
    let lineWidth: CGFloat
    
    func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height) / 2
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let circlePath = Path(
            ellipseIn: CGRect(origin: center, size: .zero)
                .insetBy(dx: -radius, dy: -radius)
        ).trimmedPath(from: 0, to: trim)
        let stroked = circlePath.strokedPath(StrokeStyle(lineWidth: lineWidth, lineCap: .butt))

        // this is where we want the rounded end to be. 
        // If your path starts somewhere else, adjust the angle accordingly
        let roundedPoint = center.applying(.init(
            translationX: radius * sin(.pi / 2),
            y: radius * cos(.pi / 2)
        ))
        let littleCircle = Path(
            ellipseIn: .init(origin: roundedPoint, size: .zero)
                .insetBy(dx: -lineWidth / 2, dy: -lineWidth / 2)
        )
        return stroked.union(littleCircle)
    }
}

// Animatable conformance in case you want to animate the amount trimmed
extension OneEndRoundedCircle: Animatable {
    var animatableData: CGFloat {
        get { trim }
        set { trim = newValue }
    }
}
private func speedProgressView(width: CGFloat) -> some View {
    ZStack {
        // intentionally increased lineWidth to make the rounded end more visible
        OneEndRoundedCircle(trim: 0.2, lineWidth: 10)
            .fill(.red)
            .shadow(color: .green, radius: 10, x: 0, y: 0)
            .shadow(color: .green, radius: 2, x: 0, y: 0)
    }
    .frame(width: width)
    .rotationEffect(.degrees(100))
}

Example output:

Example output

For iOS 17 or earlier, union is not available, but you can just work with CGPaths instead, and use its union method instead.


To achieve the result in the screenshot, where both ends are rounded when the speedometer is full, you can just check the value of trim and return early.

// assuming you want to leave the bottom 20 degrees of the circle always empty,
// the speedometer is full when the trim ends at 17/18
if abs(trim - 17 / 18) < 0.00001 {
    return circlePath.strokedPath(StrokeStyle(lineWidth: lineWidth, lineCap: .round))
}
let stroked = circlePath.strokedPath(StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
// add the little circle like before...
1

This effect can be created just by superimposing a round dot over the start of the line:

  • If the percentage is less than 100%, use a butt line-cap, otherwise use a round line-cap so that the end is rounded too.
  • The round dot at the start can be achieved by superimposing another line with a round line-cap but minimal size.
  • Having a dot at the start is probably a good thing when the percentage is 0, but you could make it conditional on the percentage being positive, if preferred.
  • Apply .compositingGroup() before applying the shadow effect, so that the shadow effect applies to the combined shape.
struct SpeedometerView: View {
    let lineWidth: CGFloat = 10
    @Binding var fraction: Double

    private var speedProgressView: some View {
        ZStack {
            Circle()
                .trim(from: 0, to: fraction * (340 / 360))
                .stroke(.red, style: StrokeStyle(lineWidth: lineWidth, lineCap: fraction == 1 ? .round : .butt))
            Circle()
                .trim(from: 0, to: 0.001)
                .stroke(.red, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
        }
        .compositingGroup()
        .shadow(color: .green, radius: 10, x: 0, y: 0)
        .shadow(color: .green, radius: 2, x: 0, y: 0)
        .rotationEffect(.degrees(100))
    }

    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea(.all)
            speedProgressView
                .frame(width: 300)
        }
    }
}

struct ContentView: View {
    @State private var fraction = 0.0

    var body: some View {
        VStack {
            SpeedometerView(fraction: $fraction)
            Slider(value: $fraction, in: 0...1)
                .padding()
        }
    }
}

Animation

1

If you want only one end of the line to be .round and the other to be .butt. So you need Double Circle to do it. I hope it helps

struct SpeedometerView: View {
    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea(.all)
            speedProgressView(width: 100)
        }
    }
    
    private func speedProgressView(width: CGFloat) -> some View {
        ZStack {
            
            Circle()
                .trim(from: 0, to: 0.1)
                .stroke(Color.red, style: StrokeStyle(lineWidth: 4, lineCap: .round))
                .shadow(color: .green, radius: 10, x: 0, y: 0)
                .shadow(color: .green, radius: 2, x: 0, y: 0)
            
            
            Circle()
                .trim(from: 0.1, to: 0.2)
                .stroke(Color.red, style: StrokeStyle(lineWidth: 4, lineCap: .butt))
                .shadow(color: .green, radius: 10, x: 0, y: 0)
                .shadow(color: .green, radius: 2, x: 0, y: 0)
        }
        .frame(width: width)
        .rotationEffect(.degrees(100))
    }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.