1

I would like to draw a circle in SwiftUI where its border has subtle rounded corners. I think the picture is worth more than a 1000 words in this case.

enter image description here

So far I'm able to draw circles with "straight" corners as follows:

let gradient = AngularGradient(
      gradient: Gradient(colors: [color2, color1]),
      center: .center,
      startAngle: .degrees(0),
      endAngle: .degrees(endFraction*360))

ZStack {
  Circle()
    .stroke(lineWidth: 12)
    .foregroundColor(.darkSelect)
  
  Circle()
    .trim(from: 0, to: endFraction)
    .stroke(gradient, style: StrokeStyle(lineWidth: 12, lineCap: .butt))
    .rotationEffect(.degrees(-90))
  Circle()
    .trim(from: 0, to: endFraction2)
    .stroke(style: StrokeStyle(lineWidth: 12, lineCap: .butt))
    .foregroundColor(.white.opacity(0.15))
    .rotationEffect(.degrees(-90))
}

However, the effect is obviously not what is intended in the design. I was wondering if there's any way in SwiftUI to style the stroke/circle in this way, or shall I just go with custom path entirely?

enter image description here

1
  • I revised my answer to show how a custom arc shape can be drawn. I think it is better to use a proper shape in this way, instead of using a shape to paint the gaps, as I had it before. Commented Jun 24 at 14:01

2 Answers 2

7

Unfortunately, there is no LineCap style that provides this kind of effect for free.

I would suggest, the best way to solve the problem is to bite the bullet and create a custom arc shape. The benefits of doing it this way:

  • The shape can be filled in all the usual ways.
  • The shape outline can be stroked, for example, to show the border in a different color.
  • The shape can also be used for masking, for clipping or as the content shape for a button.

Some aspects of this approach are discussed below.


Gaps between segments

When drawing the outer and inner arcs, the angles need to take the gap between segments into consideration.

In your screenshot, you will notice that the gap between the segments has a constant width. In other words, it is not a V shape.

In order that the gap remains constant, the adjustment to the arc angle will depend on the radius. As a close approximation, the adjustment can be computed as a fraction of the circle circumference, as follows:

gap angle in radians = (gap width / circumference) * 2 * pi

The circumference of a circle is given by radius * 2 * pi. So the factor 2 * pi cancels out and the angle in radians can be computed simply as gap width / radius.

Building the path

The diagram below illustrates how the path of the shape can be built by combining six separate arcs:

Diagram

The arcs can be drawn using two different addArc functions:

The points at the ends of the blue arcs need to be computed by adjusting the start and end angles, to take the corner radius into consideration. This can be done in exactly the same way as the adjustment for the gap between segments, as explained above.


The custom shape

Based on the diagram and explanation above, here is an example implementation of a custom arc shape:

struct RoundedArcShape: Shape {
    let startAngle: Angle
    let endAngle: Angle
    let arcWidth: CGFloat
    let gapWidth: CGFloat
    var cornerRadius: CGFloat = 6
    var centered: Bool = false

    private func pointOnArc(center: CGPoint, radius: CGFloat, angle: Angle) -> CGPoint {
        let x = center.x + (radius * cos(angle.radians))
        let y = center.y + (radius * sin(angle.radians))
        return CGPoint(x: x, y: y)
    }

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)

        let outerRadius = min(rect.width, rect.height) / 2
        let outerGapAngle = Angle(radians: gapWidth / outerRadius)
        let outerStartAngle = startAngle + (outerGapAngle / 2)
        let outerEndAngle = endAngle - (outerGapAngle / 2)
        let outerCornerAdjustment = Angle(radians: cornerRadius / outerRadius)
        let pointA = pointOnArc(center: center, radius: outerRadius, angle: outerStartAngle)
        let pointB = pointOnArc(center: center, radius: outerRadius, angle: outerStartAngle + outerCornerAdjustment)
        let pointD = pointOnArc(center: center, radius: outerRadius, angle: outerEndAngle)

        let innerRadius = outerRadius - arcWidth
        let innerGapAngle = Angle(radians: gapWidth / innerRadius)
        let innerStartAngle = startAngle + (innerGapAngle / 2)
        let innerEndAngle = endAngle - (innerGapAngle / 2)
        let innerCornerAdjustment = Angle(radians: cornerRadius / innerRadius)
        let pointE = pointOnArc(center: center, radius: innerRadius, angle: innerEndAngle)
        let pointF = pointOnArc(center: center, radius: innerRadius, angle: innerEndAngle - innerCornerAdjustment)
        let pointH = pointOnArc(center: center, radius: innerRadius, angle: innerStartAngle)

        let pointZ = CGPoint(
            x: pointA.x + (pointH.x - pointA.x) / 2,
            y: pointA.y + (pointH.y - pointA.y) / 2
        )

        let path = Path { path in
            path.move(to: pointZ)
            path.addArc(
                tangent1End: pointA,
                tangent2End: pointB,
                radius: cornerRadius
            )
            path.addArc(
                center: center,
                radius: outerRadius,
                startAngle: outerStartAngle + outerCornerAdjustment, // B
                endAngle: outerEndAngle - outerCornerAdjustment, // C
                clockwise: false
            )
            path.addArc(
                tangent1End: pointD,
                tangent2End: pointE,
                radius: cornerRadius
            )
            path.addArc(
                tangent1End: pointE,
                tangent2End: pointF,
                radius: cornerRadius
            )
            path.addArc(
                center: center,
                radius: innerRadius,
                startAngle: innerEndAngle - innerCornerAdjustment, // F
                endAngle: innerStartAngle + innerCornerAdjustment, // G
                clockwise: true
            )
            path.addArc(
                tangent1End: pointH,
                tangent2End: pointA,
                radius: cornerRadius
            )
            path.closeSubpath()
        }
        // Shift the path to the middle of the area, if required
        let result: Path
        if centered {
            let midAngle = startAngle + ((endAngle - startAngle) / 2)
            let midRadius = outerRadius - (arcWidth / 2)
            let midArcPoint = pointOnArc(center: center, radius: midRadius, angle: midAngle)
            result = path.offsetBy(dx: center.x - midArcPoint.x, dy: center.y - midArcPoint.y)
        } else {
            result = path
        }
        return result
    }
}

You will notice that this implementation includes the option to shift the path to the middle of the drawing area. This can be useful when the shape is used as a button, see this answer for an example of where this is the case.


Using the shape

Here is an adapted version of your example to show how the custom shape can be used. A ZStack is used to combine the three separate segments, as you were doing before.

struct ContentView: View {
    let arcWidth: CGFloat = 12
    let gapWidth: CGFloat = 1.5
    let cornerRadius: CGFloat = 3
    let bgColor = Color(red: 0.15, green: 0.15, blue: 0.37)
    let lightSelect = Color(red: 0.63, green: 0.88, blue: 0.92)
    let mediumSelect = Color(red: 0.35, green: 0.37, blue: 0.55)
    let darkSelect = Color(red: 0.23, green: 0.26, blue: 0.47)
    let fraction1 = 0.53
    let fraction2 = 0.22

    var body: some View {
        let angle0 = Angle.degrees(-90)
        let angle1 = angle0 + .degrees(fraction1 * 360)
        let angle2 = angle1 + .degrees(fraction2 * 360)
        ZStack {
            RoundedArcShape(
                startAngle: angle0,
                endAngle: angle1,
                arcWidth: arcWidth,
                gapWidth: gapWidth,
                cornerRadius: cornerRadius
            )
            .fill(lightSelect)

            RoundedArcShape(
                startAngle: angle1,
                endAngle: angle2,
                arcWidth: arcWidth,
                gapWidth: gapWidth,
                cornerRadius: cornerRadius
            )
            .fill(mediumSelect)

            RoundedArcShape(
                startAngle: angle2,
                endAngle: angle0,
                arcWidth: arcWidth,
                gapWidth: gapWidth,
                cornerRadius: cornerRadius
            )
            .fill(darkSelect)
        }
        .frame(width: 100, height: 100)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(bgColor)
    }
}

Screenshot

Sign up to request clarification or add additional context in comments.

2 Comments

The rounding is perceived as too inconsistent. Wouldn't it be better to use another type of curve since it comes from the shape curve and not from a polygon, that is, it should be a smooth rounding?
Is this because you are using a large corner radius, perhaps in an attempt to emulate a rounded line cap? If so, it might be simpler not to use RoundedArcShape and just stroke the form instead, using a StrokeStyle with lineCap: .round. If not the case, a reproducible example would be helpful - you could consider posting as a new question.
0

Another solution worked for me

// MARK: Helpers
/// Corner radius for the range arc on click-wheel
struct CapView: View {
    let angle: Double
    let cornerRadius: CGFloat
    let color: Color
    let ringWidth: CGFloat
    let radius: CGFloat

    var body: some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .fill(color)
            .frame(width: ringWidth, height: cornerRadius * 2)
            .offset(x: radius)
            .rotationEffect(.degrees(angle * 360))
    }
}

Place this is a ZStack with respective start and end angles

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.