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.

Questions or feedback?