Not being what that geometric shape is called, I am trying to recreate it in a Shape, but I can't make one side smaller than the other
struct WedgePerimeter: Shape {
var startAngle: Angle
var endAngle: Angle
var lineMaxWidth: CGFloat
var lineMinWidth: CGFloat
var animatableData: AnimatablePair<AnimatablePair<Double, Double>, AnimatablePair<Double, Double>> {
get {
AnimatablePair(
AnimatablePair(startAngle.radians, endAngle.radians),
AnimatablePair(Double(lineMaxWidth), Double(lineMinWidth))
)
}
set {
startAngle = Angle(radians: newValue.first.first)
endAngle = Angle(radians: newValue.first.second)
lineMaxWidth = CGFloat(newValue.second.first)
lineMinWidth = CGFloat(newValue.second.second)
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
// Outer Arc
path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
// Line to the inner radius at endAngle
path.addLine(to: CGPoint(x: center.x + (radius - 0) * CGFloat(cos(endAngle.radians)),
y: center.y + (radius - 0) * CGFloat(sin(endAngle.radians))))
// Inner Arc
path.addArc(center: center, radius: radius - lineMinWidth, startAngle: endAngle, endAngle: startAngle, clockwise: true)
// Line to the outer radius at startAngle
path.addLine(to: CGPoint(x: center.x + radius * CGFloat(cos(startAngle.radians)),
y: center.y + radius * CGFloat(sin(startAngle.radians))))
//path.closeSubpath()
return path
}
}
It looks like the shape is based on a circle, but with the linewidth dependent on the position along the path. In other words, the linewidth is tapered.
Based on this premise, the basic shape can be drawn by progressively trimming a circle, then drawing a dot at the end of the path.
private func taperedPath(trimAt: Double, maxLineWidth: CGFloat) -> some View {
GeometryReader { proxy in
let rect = CGRect(origin: .zero, size: proxy.size)
let path = Circle().path(in: rect)
let nSteps = Int(max(proxy.size.width, proxy.size.height)) * 10
ForEach(0..<nSteps, id: \.self) { i in
let fraction = Double(i) / Double(nSteps)
let trimFraction = fraction * trimAt
let dotSize = (1 - fraction) * maxLineWidth
if dotSize > 0, let position = path.trimmedPath(from: 0, to: trimFraction).currentPoint {
Circle()
.frame(width: dotSize, height: dotSize)
.position(position)
}
}
}
}
This gives the following form:
This can now be used as a .mask
for a circle that is stroked in the regular way. You might like to use a gradient for this. Finally, a .rotationEffect
can be applied to the result, so that the start and end points are equally spaced around a vertical line through the center:
var body: some View {
Circle()
.trim(from: 0, to: 0.8)
.stroke(style: .init(lineWidth: 30, lineCap: .butt))
.foregroundStyle(
AngularGradient(colors: [.red, .orange, .yellow], center: .center)
)
.mask {
taperedPath(trimAt: 0.8, maxLineWidth: 30)
}
.frame(width: 300, height: 300)
.rotationEffect(.degrees(126))
}
If you wanted to animate this, you could animate the trim fraction before stroking:
@State private var progressFraction = 0.0
Circle()
.trim(from: 0, to: 0.8 * progressFraction)
// ...other modifiers as before
.onAppear {
withAnimation(.linear(duration: 5).repeatForever()) {
progressFraction = 1.0
}
}