0

I’m rewriting an old screen that used Storyboards into SwiftUI. One of the key UI elements I’m rebuilding is a radial menu with four buttons arranged around a central “Panic” button.

I want each surrounding button to look like a sector with curved top and bottom but with parallel vertical sides — like a rounded trapezoid or pill-shaped wedge — not a pizza slice that narrows toward the center.

Here’s a screenshot of what I want to achive:

enter image description here

And here is what I have now: enter image description here

There are four buttons positioned around the center circle, and when you compare them to the original screenshot, you'll notice that there is no uniform spacing around the buttons. My updated SwiftUI solution resembles a pizza style more closely.

I’m currently using a custom ArcShape and wrapping it inside a Button. Here’s the simplified code:

struct ArcShape: Shape {
    var startAngle: Angle
    var endAngle: Angle

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let outerRadius = min(rect.width, rect.height) / 2
        let innerRadius = outerRadius * 0.55

        var path = Path()
        path.addArc(center: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
        path.addArc(center: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: true)
        path.closeSubpath()
        return path
    }
}

And the button view:

struct CheckinButton: View {
    var iconName: String
    var label: String
    var color: Color
    var action: () -> Void
    var startAngle: Angle
    var endAngle: Angle

    var body: some View {
        Button(action: action) {
            ZStack {
                ArcShape(startAngle: startAngle, endAngle: endAngle)
                    .fill(color)
                    .frame(width: 320, height: 320)
                    .rotationEffect(.degrees((startAngle.degrees + endAngle.degrees) / 2))

                VStack(spacing: 4) {
                    Image(systemName: iconName)
                    if !label.isEmpty {
                        Text(label)
                    }
                }
                .foregroundColor(.white)
            }
        }
    }
}

How can I create a radial button in SwiftUI that has:

•   parallel vertical sides
•   curved top and bottom (circular segments)
•   positioned around a circular center (like a ring)
•   interactive with labels/icons?

Any guidance or examples using custom Shape would be really helpful!

1
  • 1
    I do have a solution for this, so I hope you'll be able to get it reopened. Commented Jun 18 at 20:29

1 Answer 1

4

The following techniques can be used to resolve the issues:

  • The parallel gap between the segments can be achieved by adjusting the angles for the arc shape. The angle represented by the gap can be computed from the gap width and the circle circumference. If you work in radians then the factor of 2*pi cancels out, so the formula for the gap angle (in radians) is then simply gapWidth / radius.

  • It shouldn't be necessary to apply a rotation effect to the arc shape if you supply the correct angles for the arc in the first place.

  • To align the button label with the shape, I would suggest shifting the arc shape to the center of the drawing area. This then makes it easy to apply the label as a layer in a ZStack, as you were doing already.

  • In order to restrict the tappable area of the button to the area of the shape, it is important to apply the same arc shape as .contentShape to the button.

  • It is also important to apply .buttonStyle(.plain), so that the button looks the same when button shapes is turned on in the accessibility settings.

Here is how the arc shape and the button definition can be updated to work this way:

struct ArcShape: Shape {
    let startAngle: Angle
    let endAngle: Angle
    let arcWidth: CGFloat
    let gapWidth: CGFloat

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let outerRadius = min(rect.width, rect.height) / 2
        let innerRadius = outerRadius - arcWidth
        let outerGapAngle = Angle(radians: gapWidth / outerRadius)
        let innerGapAngle = Angle(radians: gapWidth / innerRadius)
        let path = Path { path in
            path.addArc(
                center: center,
                radius: outerRadius,
                startAngle: startAngle + (outerGapAngle / 2),
                endAngle: endAngle - (outerGapAngle / 2),
                clockwise: false
            )
            path.addArc(
                center: center,
                radius: innerRadius,
                startAngle: endAngle - (innerGapAngle / 2),
                endAngle: startAngle + (innerGapAngle / 2),
                clockwise: true
            )
            path.closeSubpath()
        }
        // Shift the path back to the middle of the area
        let midRadius = outerRadius - (arcWidth / 2)
        let midAngle = startAngle + ((endAngle - startAngle) / 2)
        let xOffset = -midRadius * cos(midAngle.radians)
        let yOffset = -midRadius * sin(midAngle.radians)
        return path.offsetBy(dx: xOffset, dy: yOffset)
    }
}

struct ArcButton: View {
    let iconName: String
    var label: String = ""
    let startDegrees: Double
    let endDegrees: Double
    let arcWidth: CGFloat
    var gapWidth: CGFloat = 10
    var action: () -> Void

    var body: some View {
        let arcShape = ArcShape(
            startAngle: .degrees(startDegrees),
            endAngle: .degrees(endDegrees),
            arcWidth: arcWidth,
            gapWidth: gapWidth
        )
        Button(action: action) {
            ZStack {
                arcShape
                    .fill(.tint)
                VStack(spacing: 4) {
                    Image(systemName: iconName)
                    if !label.isEmpty {
                        Text(label)
                    }
                }
            }
        }
        .buttonStyle(.plain)
        .contentShape(arcShape)
    }
}

Testing in isolation:

ArcButton(
    iconName: "globe",
    label: "Checkin",
    startDegrees: 225,
    endDegrees: 315,
    arcWidth: 90
) {
    print("Checkin")
}
.tint(.green)
.frame(width: 320, height: 320)
.imageScale(.large)
.foregroundStyle(.white)
.overlay { HStack { Divider() } }
.overlay { VStack { Divider() } }
.border(.red)

Screenshot


To build the full view, the buttons can be applied as layers of a ZStack and then shifted into position by applying an .offset.

  • Since the arc shape for each button is centered and the buttons need to be positioned top, bottom, left and right of center, the buttons only need to be offset in one dimension (either horizontally or vertically).

  • The offset amount can be computed quite easily from the dial diameter and the arc width.

  • The red button in the middle could be built using a custom view too. This would probably be the best way to get the size exactly right.

  • As a slightly lazier approach, the red button in the example below uses .buttonBorderShape instead. I found that doing it this way, the size of the button is actually a little larger than the size being applied to the button label. So the size needed to be measured from a screenshot and then tweaked.

struct ContentView: View {
    let diameter: CGFloat = 320
    let arcWidth: CGFloat = 90

    var body: some View {
        ZStack {
            ArcButton(
                iconName: "globe",
                label: "Checkin",
                startDegrees: 225,
                endDegrees: 315,
                arcWidth: arcWidth
            ) {
                print("Checkin")
            }
            .tint(.green)
            .offset(y: (arcWidth - diameter) / 2)

            ArcButton(
                iconName: "minus.circle.fill",
                startDegrees: 135,
                endDegrees: 225,
                arcWidth: arcWidth
            ) {
                print("Minus")
            }
            .tint(.brown)
            .offset(x: (arcWidth - diameter) / 2)

            ArcButton(
                iconName: "plus.circle.fill",
                startDegrees: -45,
                endDegrees: 45,
                arcWidth: arcWidth
            ) {
                print("Plus")
            }
            .tint(.blue)
            .offset(x: (diameter - arcWidth) / 2)

            ArcButton(
                iconName: "microphone.fill",
                label: "Record",
                startDegrees: 45,
                endDegrees: 135,
                arcWidth: arcWidth
            ) {
                print("Record")
            }
            .tint(.cyan)
            .offset(y: (diameter - arcWidth) / 2)

            Button {
                print("Panic")
            } label: {
                VStack {
                    Image(systemName: "dot.radiowaves.left.and.right")
                    Text("Panic")
                }
                .frame(width: 104, height: 104)
            }
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.circle)
            .tint(.red)
        }
        .frame(width: diameter, height: diameter)
        .imageScale(.large)
        .foregroundStyle(.white)
    }
}

Animation

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

1 Comment

That's exactly what I need. I really appreciate your help!

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.