swiftswiftui

How to create custom button shapes?


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!


Solution

  • The following techniques can be used to resolve the issues:

    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.

    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