Skip to content

.sizeThatFits renders SwiftUI views at minimum compressed size instead of ideal size #1070

@abrahamduran

Description

@abrahamduran

Describe the bug
.sizeThatFits layout renders SwiftUI views at their minimum compressed size instead of their ideal intrinsic size. Text gets truncated, views with flexible layout collapse, and the resulting snapshot is too small to show the actual content.

To Reproduce

@MainActor
@Suite(.snapshots(record: .missing), .serialized)
struct SizeThatFitsBugTests {
    @Test func simpleText() {
        let view = Text("Hello, World!")
            .padding()

        assertSnapshot(of: view, as: .image(layout: .sizeThatFits))
    }

    @Test func viewWithSpacer() {
        let view = HStack {
            Text("Left")
            Spacer()
            Text("Right")
        }
        .padding()

        assertSnapshot(of: view, as: .image(layout: .sizeThatFits))
    }
}

Expected behavior
Snapshot images sized to the view's ideal content size — text fully visible, flexible layouts expanded to their natural width.

Screenshots

Simple Text With Spacer
Image Image

There's an image there, I swear!

Environment

  • swift-snapshot-testing version: 1.19.1
  • Xcode: 26.3
  • Swift: 6.0
  • OS: iOS 26.3.1 (iPhone 17 simulator)

Additional context

Why it's broken

The .sizeThatFits code path in SwiftUIView.swift (line 76-77) calls:

let maxSize = CGSize(width: 0.0, height: 0.0)
config.size = hostingController.sizeThatFits(in: maxSize)

UIHostingController.sizeThatFits(in:) interprets the CGSize as a layout proposal — not a request for intrinsic size. Passing CGSize.zero means "you have zero space available," so the hosting controller returns the view's minimum compressed size (truncated text, collapsed spacers). This is correct behavior from UIHostingController.

The fundamental problem is that CGSize cannot express "unspecified" dimensions. SwiftUI's ProposedViewSize supports nil for width/height (meaning "use your ideal size"), but there's no CGSize equivalent. So there's no value you can pass to UIHostingController.sizeThatFits(in:) that means "ideal size":

Proposal Meaning Result
CGSize.zero Minimum size Compressed/truncated content
CGSize(.infinity, .infinity) Maximum size Spacer() expands unbounded
CGSize(screenWidth, .infinity) Phone-width constrained Works, but device-dependent

Quick fix

Replace the zero proposal with a screen-width-constrained one:

let screenWidth = UIScreen.main.bounds.width
let proposed = CGSize(width: screenWidth, height: .greatestFiniteMagnitude)
config.size = hostingController.sizeThatFits(in: proposed)
Simple Text With Spacer
Image Image

This matches how Xcode's .previewLayout(.sizeThatFits) behaves — it proposes the device width and lets height wrap content. Verified working locally. The trade-off is that the output becomes device-dependent.

Proper fix: use ImageRenderer instead of UIHostingController

The current rendering pipeline goes through UIKit:

  1. Wrap SwiftUI view in UIHostingController
  2. Call sizeThatFits(in:) (CGSize — can't express "ideal")
  3. Add to UIWindow, layout
  4. Capture via view.layer.render(in:) / UIGraphicsImageRenderer

ImageRenderer (iOS 16+) renders SwiftUI views directly and has proposedSize: ProposedViewSize which supports nil dimensions:

let renderer = ImageRenderer(content: view)
renderer.scale = UIScreen.main.scale
// ProposedViewSize with nil dimensions = "use your ideal size"
// This is exactly what .sizeThatFits should mean
renderer.proposedSize = ProposedViewSize(width: nil, height: nil)
let image = renderer.uiImage
Simple Text With Spacer
Image Image

ProposedViewSize(width: nil, height: nil) is the SwiftUI-native way to ask "what's your ideal size?" — the exact semantic that .sizeThatFits intends but can't achieve through UIHostingController.sizeThatFits(in:).

This would also simplify the rendering pipeline by removing the UIHostingControllerUIWindowlayer.render chain entirely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions