Skip to content

Building Custom Layout: Basics (SwiftUI)

A SwiftUI guide for coding agents. Also covers layout protocol flow layout, tag cloud wrapping stack, sizethatfits placesubviews, proposedviewsize subviews proxy, custom container without geometryreader.

The Layout protocol (iOS 16) opens the layout system without GeometryReader: implement sizeThatFits to report your size and placeSubviews to position children. The worked example is a flow layout, an HStack that wraps to a new line when the width runs out.

Measure in sizeThatFits

Ask each subview for its ideal size with sizeThatFits(.unspecified) (.zero and .infinity return the minimum and maximum instead). Walk the sizes, wrap when a line exceeds the proposed width, and return the total.

struct FlowLayout: Layout {
  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

    var totalHeight: CGFloat = 0
    var totalWidth: CGFloat = 0
    var lineWidth: CGFloat = 0
    var lineHeight: CGFloat = 0

    for size in sizes {
      if lineWidth + size.width > proposal.width ?? 0 {
        totalHeight += lineHeight
        lineWidth = size.width
        lineHeight = size.height
      } else {
        lineWidth += size.width
        lineHeight = max(lineHeight, size.height)
      }
      totalWidth = max(totalWidth, lineWidth)
    }
    totalHeight += lineHeight
    return .init(width: totalWidth, height: totalHeight)
  }
}

Place in placeSubviews

The bounds rectangle is where your layout lives, and its origin is not zero: always start from bounds.minX and bounds.minY, never from the origin. Place each child through the Subviews proxy.

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
  let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

  var lineX = bounds.minX
  var lineY = bounds.minY
  var lineHeight: CGFloat = 0

  for index in subviews.indices {
    if lineX + sizes[index].width > (proposal.width ?? 0) {
      lineY += lineHeight
      lineHeight = 0
      lineX = bounds.minX
    }

    subviews[index].place(
      at: .init(x: lineX + sizes[index].width / 2, y: lineY + sizes[index].height / 2),
      anchor: .center,
      proposal: ProposedViewSize(sizes[index])
    )

    lineHeight = max(lineHeight, sizes[index].height)
    lineX += sizes[index].width
  }
}

Use It Like a Stack

A Layout type is called like a container view.

FlowLayout {
  ForEach(tags) { tag in
    TagView(tag: tag)
  }
}

Available on iOS 16, macOS 13, tvOS 16, and watchOS 9. Follow-up docs cover caching, custom spacing, and LayoutValueKey.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-building-custom-layout-basics" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems