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