Lab 076 – ArcLayout
Adapting the RadialLayout from Apple into an ArcLayout.
I first took at look at custom layouts in SwiftUI in Lab 067, then I started building on it for 3D in Labs 068 and 069. Today I decided to adapt this layout to distribute views along the path of an arc instead of a full circle. I’m not great at math like this, so Cursor was a huge help with this one.
Usage: Just like with RadialLayout, we can pass in angleOffset to tell SwiftUI where to start drawing content. ArcLayout adds two more values. We can pass in a number representing the degrees of the arc we want to draw. We can also set shouldAutoCenter to true if we want the layout to grow outwards from the center.
ArcLayout(angleOffset: .degrees(angleOffsetDegrees), degrees: arcDegrees, shouldAutoCenter: shouldAutoCenter) {
ForEach(0..<nodes, id: \.self) { index in
ModelViewEmoji(name: "UISphere01", emoji: emoji[index], bundle: realityKitContentBundle)
}
}Video demo:
Full Lab Code
struct Lab076: View {
@State var nodes: Int = 12
@State var previousNodes: Int = 3
@State var arcDegrees: Double = 180
@State var angleOffsetDegrees: Double = 0
@State var shouldAutoCenter = true
var emoji: [String] = ["🌸", "🐸", "❤️", "🔥", "💻", "🐶", "🥸", "📱", "🎉", "🚀", "🤔", "🤓", "🧲", "💰", "🤩", "🍪", "🦉", "💡", "😎"]
var body: some View {
VStack {
ArcLayout(angleOffset: .degrees(angleOffsetDegrees), degrees: arcDegrees, shouldAutoCenter: shouldAutoCenter) {
ForEach(0..<nodes, id: \.self) { index in
ModelViewEmoji(name: "UISphere01", emoji: emoji[index], bundle: realityKitContentBundle)
}
}
.debugBorder3D(.white)
}
.ornament(attachmentAnchor: .scene(.trailing), contentAlignment: .leading, ornament: {
VStack(spacing: 6) {
// Node controls
HStack(spacing: 24) {
Button(action: {
withAnimation {
previousNodes = nodes
nodes -= 1
}
}, label: {
Image(systemName: "minus.circle.fill")
})
.disabled(nodes <= 3)
Text("\(nodes)")
.frame(width:80)
.contentTransition(.numericText(countsDown: nodes < previousNodes))
Button(action: {
withAnimation {
previousNodes = nodes
nodes += 1
}
}, label: {
Image(systemName: "plus.circle.fill")
})
.disabled(nodes >= 19)
}
// Arc controls
HStack(spacing: 24) {
Button(action: {
withAnimation {
arcDegrees = max(45, arcDegrees - 45)
}
}, label: {
Image(systemName: "minus.circle.fill")
})
.disabled(arcDegrees <= 45)
Text("Arc: \(Int(arcDegrees))°")
.frame(width: 80)
Button(action: {
withAnimation {
arcDegrees = min(360, arcDegrees + 45)
}
}, label: {
Image(systemName: "plus.circle.fill")
})
.disabled(arcDegrees >= 225)
}
// Angle offset controls
HStack(spacing: 24) {
Button(action: {
withAnimation {
angleOffsetDegrees -= 45
}
}, label: {
Image(systemName: "minus.circle.fill")
})
Text("Offset: \(Int(angleOffsetDegrees))°")
.frame(width: 80)
Button(action: {
withAnimation {
angleOffsetDegrees += 45
}
}, label: {
Image(systemName: "plus.circle.fill")
})
}
Button(action: {
withAnimation {
shouldAutoCenter.toggle()
}
}, label: {
Label("Auto-center", systemImage: shouldAutoCenter ? "circle.circle.fill" : "circle")
})
}
.padding()
.glassBackgroundEffect()
})
}
}
// Adapted from Example 051 - Spatial SwiftUI: Model3D
fileprivate struct ModelViewEmoji: View {
@State var name: String = ""
let emoji: String
let bundle: Bundle
var body: some View {
Model3D(named: name, bundle: bundle)
{ phase in
if let model = phase.model {
model
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 90)
.spatialOverlay(alignment: .center) {
Text(emoji)
.font(.system(size: 42))
}
} else if phase.error != nil {
Text(emoji) // just load the emoji without the model
} else {
ProgressView()
}
}
}
}
// Adapted from the RadialLayout that Apple included in from Canyon Crosser from WWDC 2025
fileprivate struct ArcLayout: Layout, Animatable {
var angleOffset: Angle = .zero
var degrees: Double = 180 // Default to 180 degrees (half circle)
var shouldAutoCenter: Bool = false // Whether to automatically center the arc
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let updatedProposal = proposal.replacingUnspecifiedDimensions()
let minDim = min(updatedProposal.width, updatedProposal.height)
return CGSize(width: minDim, height: minDim)
}
// Computed property to calculate auto-centering offset
private var autoCenterOffset: Angle {
guard shouldAutoCenter else { return .zero }
// For auto-centering, we want the arc centered around 0° (horizontal center)
let targetCenterAngle = 0.0
let currentStartAngle = -90.0 // Our arc starts at -90° (top)
let arcCenterAngle = currentStartAngle + (degrees / 2) // Current center of the arc
let offsetNeeded = targetCenterAngle - arcCenterAngle
return .degrees(offsetNeeded)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count > 1 else {
subviews[0].place(
at: .init(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: proposal)
return
}
let minDimension = min(bounds.width, bounds.height)
let subViewDim = minDimension / CGFloat((subviews.count / 2) + 1)
let radius = min(bounds.width, bounds.height) / 2
let placementRadius = radius - (subViewDim / 2)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
// Convert degrees to radians and calculate angle increment for the arc
let arcRadians = degrees * .pi / 180.0
// For a full circle (360°), use radial layout logic
// For partial arcs, span the specified degrees
let angleIncrement: CGFloat
let startRadians: CGFloat
if degrees >= 359.9 { // Full circle - use original radial logic
angleIncrement = 2 * .pi / CGFloat(subviews.count)
startRadians = -.pi / 2 // Start from top (-90 degrees)
} else { // Partial arc - span the specified degrees
angleIncrement = arcRadians / CGFloat(max(1, subviews.count - 1))
startRadians = -.pi / 2 // Start from top (-90 degrees)
}
for (index, subview) in subviews.enumerated() {
// Calculate angle within the arc range
let angle = startRadians + (angleIncrement * CGFloat(index)) + angleOffset.radians + autoCenterOffset.radians
let xPosition = center.x + (placementRadius * cos(angle))
let yPosition = center.y + (placementRadius * sin(angle))
let point = CGPoint(x: xPosition, y: yPosition)
subview.place(
at: point, anchor: .center,
proposal: .init(width: subViewDim, height: subViewDim))
}
}
var animatableData: Angle.AnimatableData {
get { angleOffset.animatableData }
set { angleOffset.animatableData = newValue }
}
}Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more labs from Step Into Vision.

Follow Step Into Vision