Lab 067 – Exploring custom Layouts in SwiftUI
Taking a look at the RadialLayout example from Apple and mocking up a HoneycombLayout.
Overview
One of the most fascinating sessions from WWDC 2025 was Meet SwiftUI spatial layout. In that session, they showed off some features to build on SwiftUI Layouts in a more spatial manner. I’ve been adding example code for each of these new features. But I haven’t made time to learn my way around custom layouts.
This lab uses two custom layouts with some 2D SwiftUI content. I plan on building on these with some 3D content soon.
- RadialLayout – This was pulled directly from the Canyon Crosser sample project. Apple discussed this layout in detail in the session I mentioned above.
- HoneycombLayout – Using the RadialLayout as an example, I worked with Cursor to mock up this basic honeycomb grid. It needs some work, but it’s pretty neat.
See the new Spatial Layout features coming in visionOS 26. We’ll use these features along with the custom layouts from this lab to create some 3D layouts with Model3D and RealityView.
- Spatial SwiftUI: depthAlignment
- Spatial SwiftUI: rotation3DLayout
- Spatial SwiftUI: spatialOverlay
- Spatial SwiftUI: SpatialContainer
- Spatial SwiftUI: realityViewSizingBehavior
Demo Video
Lab Code
struct Lab067: View {
@State var nodes: Int = 3
@State var previousNodes: Int = 3
@State var useHoneycomb: Bool = false
var emoji: [String] = ["🌸", "🐸", "❤️", "🔥", "💻", "🐶", "🥸", "📱", "🎉", "🚀", "🤔", "🤓", "🧲", "💰", "🤩", "🍪", "🦉", "💡", "😎"]
var body: some View {
VStack {
if useHoneycomb {
HoneycombLayout {
ForEach(0..<nodes, id: \.self) { index in
ZStack {
Circle()
.fill(.stepGreen)
.frame(width: 60, height: 60)
Text(emoji[index])
.font(.system(size: 30))
}
}
}
} else {
RadialLayout {
ForEach(0..<nodes, id: \.self) { index in
ZStack {
Circle()
.fill(.stepGreen)
.frame(width: 60, height: 60)
Text(emoji[index])
.font(.system(size: 30))
}
}
}
}
}
.toolbar {
ToolbarItem(placement: .bottomOrnament, content: {
VStack(spacing: 16) {
// Layout toggle
HStack(spacing: 16) {
Button(action: {
withAnimation {
useHoneycomb = false
}
}, label: {
Text("Radial")
.padding()
.foregroundColor(useHoneycomb ? .gray : .white)
})
Button(action: {
withAnimation {
useHoneycomb = true
}
}, label: {
Text("Honeycomb")
.padding()
.foregroundColor(useHoneycomb ? .white : .gray)
})
}
// 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:60)
.contentTransition(.numericText(countsDown: nodes < previousNodes))
Button(action: {
withAnimation {
previousNodes = nodes
nodes += 1
}
}, label: {
Image(systemName: "plus.circle.fill")
})
.disabled(nodes >= 19)
}
}
})
}
}
}RadialLayout
// Taken from Canyon Crosser from WWDC 2025
// For information on custom layouts, watch https://developer.apple.com/videos/play/wwdc2022/10056.
fileprivate struct RadialLayout: Layout, Animatable {
var angleOffset: Angle = .zero
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)
}
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)
let angleIncrement = 2 * .pi / CGFloat(subviews.count)
let centerOffset = Double.pi / 2 // Centers the view.
for (index, subview) in subviews.enumerated() {
let angle = angleIncrement * CGFloat(index) + angleOffset.radians + centerOffset
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 }
}
}HoneycombLayout
// Honeycomb grid layout that grows from the inside out
// Cursor was very helpful for creating this layout. I gave it the RadialLayout as an example and described the structure I wanted. It took a few iterations, but the result is pretty neat.
fileprivate struct HoneycombLayout: Layout, Animatable {
var angleOffset: Angle = .zero
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)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let hexSize: CGFloat = 50
let hexRadius = hexSize / 2
// Calculate hexagon spacing (distance between centers)
let hexSpacing = hexRadius * sqrt(3) + 20
// Generate hexagon positions in a spiral pattern
var positions: [CGPoint] = []
positions.append(center) // Center hexagon
var ring = 1
while positions.count < subviews.count {
// For each ring, place hexagons at 60-degree intervals
for i in 0..<6 {
let angle = Double(i) * .pi / 3 + angleOffset.radians
let radius = Double(ring) * hexSpacing
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
positions.append(CGPoint(x: x, y: y))
}
// Fill in the gaps between the corners for larger rings
if ring > 1 {
for i in 0..<6 {
let startAngle = Double(i) * .pi / 3 + angleOffset.radians
let endAngle = Double(i + 1) * .pi / 3 + angleOffset.radians
let radius = Double(ring) * hexSpacing
// Add intermediate positions with adjusted radius for tighter honeycomb
for j in 1..<ring {
let angle = startAngle + (endAngle - startAngle) * Double(j) / Double(ring)
// Adjust radius for items that should be closer to center
// Items at the edges of each segment get pulled in slightly
let radiusAdjustment = 0.15 // Pull items in by 15%
let adjustedRadius = radius * (1.0 - radiusAdjustment)
let x = center.x + adjustedRadius * cos(angle)
let y = center.y + adjustedRadius * sin(angle)
positions.append(CGPoint(x: x, y: y))
}
}
}
ring += 1
}
// Place subviews at calculated positions
for (index, subview) in subviews.enumerated() {
if index < positions.count {
subview.place(
at: positions[index],
anchor: .center,
proposal: .init(width: hexSize, height: hexSize)
)
}
}
}
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