Search code examples
swiftuishapes

How to create WedgePerimeter shape in SwiftUI


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

enter image description here

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
    }
}

Solution

  • 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.

    • The size of each dot will depend on the trim fraction.
    • The number of dots will depend on the size. I went for 10 times the maximum frame dimension. This should mean the distance between points is less than a pixel, even on screens with the highest resolution.
    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:

    Screenshot

    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))
    }
    

    Screenshot

    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
            }
        }
    

    Animation