Stacks & Frames
VStack - Vertical Stacking
VStack arranges views from top to bottom. It accepts alignment and spacing parameters.
VStack(alignment: .leading, spacing: 12) {
Text("Title")
.font(.title)
Text("Subtitle")
.font(.subheadline)
.foregroundColor(.secondary)
Divider()
Text("Body content goes here with multiple lines of text.")
.font(.body)
}
Alignment options: .leading, .center (default), .trailing. You can also use Spacer() to push views apart within a stack.
HStack - Horizontal Stacking
HStack arranges views from leading to trailing (left to right in LTR languages).
HStack(spacing: 16) {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("Rating: 4.8")
.font(.headline)
Spacer()
Text("(2,340 reviews)")
.font(.caption)
.foregroundColor(.secondary)
}
ZStack - Layered Stacking
ZStack layers views on top of each other, with later views appearing on top.
ZStack(alignment: .bottomTrailing) {
Image("background")
.resizable()
.aspectRatio(contentMode: .fill)
LinearGradient(
colors: [.clear, .black.opacity(0.7)],
startPoint: .center,
endPoint: .bottom
)
VStack(alignment: .leading) {
Text("Overlay Title")
.font(.title)
.foregroundColor(.white)
Text("Description")
.foregroundColor(.white.opacity(0.8))
}
.padding()
}
Frame Modifier
The .frame() modifier proposes a specific size to the view:
// Fixed dimensions
Text("Fixed")
.frame(width: 200, height: 100)
// Flexible with constraints
Text("Flexible")
.frame(minWidth: 100, idealWidth: 200, maxWidth: .infinity, minHeight: 44)
// Width only
Text("Wide")
.frame(maxWidth: .infinity)
.infinity tells SwiftUI to take all available space in that dimension. This is commonly used for full-width buttons or backgrounds.
Spacer
Spacer takes up all available space along the stack axis, pushing other views apart:
HStack {
Text("Left")
Spacer() // pushes Text("Left") to leading edge
Text("Right") // pushed to trailing edge
}
VStack {
Text("Top")
Spacer() // pushes Text("Top") to top
Text("Bottom") // pushed to bottom
}
Spacer can also be given a minimum length:
Spacer(minLength: 20)
Alignment Guides
For precise alignment control within stacks, use alignment guides:
VStack(alignment: .leading) {
Text("Short")
Text("A much longer text that wraps")
.alignmentGuide(.leading) { d in d[.trailing] }
}
Nested Stacks
Real UIs use nested stacks for complex layouts:
HStack(spacing: 12) {
Image(systemName: "person.circle.fill")
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("John Doe")
.font(.headline)
Text("Software Engineer")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
GeometryReader
What GeometryReader Does
GeometryReader provides access to the size and coordinate space of the view it contains. It acts as a flexible container that takes up all available space and provides its dimensions to its content closure.
GeometryReader { geometry in
VStack {
Text("Width: \(Int(geometry.size.width))")
Text("Height: \(Int(geometry.size.height))")
}
}
GeometryReader is greedy -- it expands to fill all available space. This makes it useful for reading dimensions but can cause layout issues if used carelessly.
Reading Safe Area Insets
GeometryReader provides access to safe area insets, which is critical for full-screen designs:
GeometryReader { geometry in
let safeTop = geometry.safeAreaInsets.top
let safeBottom = geometry.safeAreaInsets.bottom
VStack {
Text("Safe top: \(safeTop)")
Spacer()
Text("Safe bottom: \(safeBottom)")
}
}
Using Geometry for Responsive Layouts
GeometryReader enables layouts that adapt to different screen sizes:
struct ResponsiveLayout: View {
var body: some View {
GeometryReader { geometry in
let isWide = geometry.size.width > 600
if isWide {
HStack(spacing: 20) {
SidePanel()
MainContent()
}
} else {
VStack {
MainContent()
}
}
}
}
}
Coordinate Spaces
GeometryReader can read positions relative to named coordinate spaces, which is useful for scroll effects and parallax:
ScrollView {
VStack(spacing: 0) {
ForEach(items) { item in
ItemRow(item: item)
.background(GeometryReader { geo in
Color.clear.preference(
key: ViewOffsetKey.self,
value: geo.frame(in: .named("scroll")).minY
)
})
}
}
.coordinateSpace(name: "scroll")
}
.onPreferenceChange(ViewOffsetKey.self) { offset in
// React to scroll position
}
Common GeometryReader Patterns
Parallax header:
ScrollView {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
Image("hero")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width, height: 300 + max(0, offset))
.offset(y: offset > 0 ? offset * 0.5 : 0)
.clipped()
}
.frame(height: 300)
// ... rest of content
}
Aspect ratio container:
struct AspectRatioContainer<Content: View>: View {
let ratio: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
GeometryReader { geo in
content()
.frame(
width: geo.size.width,
height: geo.size.width * ratio
)
}
}
}
Limitations
- GeometryReader takes up all available space and cannot be used to measure a view's natural size
- It does not trigger layout updates when the content inside changes size
- For measuring natural size, consider using
Backgroundwith GeometryReader or.backgroundPreferenceValue()
Custom Layouts
The Layout Protocol
SwiftUI provides the Layout protocol for creating entirely custom layout containers. This is useful when stacks, grids, and other built-in layouts do not meet your needs.
struct CircularLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let radius = min(bounds.width, bounds.height) / 2
let angleStep = 2 * .pi / CGFloat(subviews.count)
for (index, subview) in subviews.enumerated() {
let angle = angleStep * CGFloat(index) - .pi / 2
let point = CGPoint(
x: bounds.midX + radius * cos(angle),
y: bounds.midY + radius * sin(angle)
)
subview.place(at: point, proposal: .unspecified)
}
}
}
// Usage
CircularLayout {
ForEach(0..<8) { i in
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
}
}
.frame(width: 300, height: 300)
Proposed and Remaining Size
The layout protocol uses a size negotiation system:
- The parent proposes a size to the layout
- The layout asks each subview what size it needs (
sizeThatFits) - The layout places subviews within the bounds it was given
struct FlowLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = layout(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = layout(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(
x: bounds.minX + position.x,
y: bounds.minY + position.y
), proposal: .unspecified)
}
}
private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
let maxWidth = proposal.replacingUnspecifiedDimensions().width
n var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth {
currentX = 0
currentY += lineHeight
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width
}
return (positions, CGSize(width: maxWidth, height: currentY + lineHeight))
}
}
Layout with Animations
Custom layouts can animate changes to their subviews:
struct WaveLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for (index, subview) in subviews.enumerated() {
let x = bounds.minX + (bounds.width / CGFloat(subviews.count + 1)) * CGFloat(index + 1)
let yOffset = sin(Double(index) * 0.8) * 20
subview.place(at: CGPoint(x: x, y: bounds.midY + yOffset), proposal: .unspecified)
}
}
func updateCache(_ cache: inout (), subviews: Subviews) {
// Update animation state here
}
}
When to Use Custom Layouts
- Grid with custom spacing: Not uniform like LazyVGrid
- Circular or radial arrangements: Items around a center point
- Flow/wrap layouts: Items that wrap to the next row
- Overlapping layouts: Items with intentional overlap
- Animation-heavy layouts: Custom placement animations
For most common layouts, prefer built-in options (VStack, HStack, ZStack, Grid, LazyVGrid). Custom layouts are for cases where built-ins cannot express your desired behavior.
Quiz
1. What does GeometryReader do?
2. Why does GeometryReader take up all available space?
3. What method must you implement in the Layout protocol?
4. What does Spacer() do inside a VStack?
5. How does SwiftUI size negotiation work?
Flashcards
Question
What are the three stack types and their axes?
Click to reveal answer
Answer
VStack (vertical), HStack (horizontal), ZStack (depth/layered).
Question
What does .infinity do in a frame modifier?
Click to reveal answer
Answer
It tells SwiftUI to take all available space in that dimension, creating a full-width or full-height view.
Question
What is GeometryReader's layout behavior?
Click to reveal answer
Answer
It is greedy -- expands to fill all available space and reports its dimensions to the content closure.
Question
What two methods does the Layout protocol require?
Click to reveal answer
Answer
sizeThatFits(proposal:subviews:cache:) to report needed size, and placeSubviews(in:proposal:subviews:cache:) to position children.
Question
When should you use ZStack vs VStack?
Click to reveal answer
Answer
ZStack layers views on top of each other (overlapping). VStack stacks views vertically without overlap.
Revision Notes
Key Takeaways
- 1. Stacks are the primary layout mechanism; use Spacer to push views apart
- 2. GeometryReader reads container dimensions but always expands to fill available space
- 3. The Layout protocol enables fully custom layout behaviors
- 4. SwiftUI uses a propose-and-report size negotiation system
- 5. Nested stacks handle most real-world layout needs
Interview Tips
- • Explain the size negotiation process: propose -> report -> place
- • Know why GeometryReader is greedy and how to constrain it
- • Be ready to discuss when to use custom Layout vs built-in stacks/grids
- • Understand how Spacer works within different stack orientations
Cheat Sheet
VStack: vertical, HStack: horizontal, ZStack: layered.
Spacer() fills remaining space along the stack axis.
.frame() proposes fixed/flexible dimensions; .infinity takes all available space.
GeometryReader reads size and safe area insets; it is greedy and expands to fill space.
Layout protocol requires sizeThatFits and placeSubviews for custom layouts.
Size negotiation: parent proposes -> child reports -> parent places.