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](https://cdn.statically.io/img/i.sstatic.net/vTc3CXRo.png)
For iOS 17 or earlier, union
is not available, but you can just work with CGPath
s 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...
.round
and the other to be.butt
, right? Your screenshot shows both ends being.butt
though..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 :)