Fix: SwiftUI Popover Text Truncated Instead of Wrapping

Fix: SwiftUI Popover Text Truncated Instead of Wrapping

Written by

in

Quick answer: A popover sizes itself from the ideal size of its content, and the ideal size of Text is a single unbroken line, so long text gets truncated instead of wrapped. Wrap the content in a custom Layout that measures the text, grants it its ideal width when that fits, and otherwise proposes your maximum width.

Why this happens

A .popover determines its size from the ideal size of the content you put inside it. For Text, the ideal size is the size the text takes when it is laid out on a single line. So when you hand a popover a long paragraph, SwiftUI measures it as one very wide line, the popover tries to become that wide, and the system clamps it — which shows up as truncation rather than wrapping.

Modifiers that seem like they should help don’t, because they act on the wrong side of the measurement:

  • .frame(maxWidth: .infinity) proposes unlimited width, so the text still has no reason to wrap.
  • .fixedSize(horizontal: false, vertical: true) fixes the height to the ideal height — which is still the single-line height.
  • A plain ScrollView inside the popover doesn’t wrap either; it just scrolls the one-line text.

The key insight: Text needs a width constraint before it can decide where to break lines. If you give it a hard .frame(width: 350) it wraps, but then short text gets a popover that’s far too wide. You want something in between: use the text’s natural width when it’s short, and cap it at a maximum when it’s long.

How to fix it

1. Wrap the content in a custom Layout that measures first, then caps

The Layout protocol lets you control exactly how the popover’s content is measured. The strategy:

  1. In makeCache, ask the subview for its ideal size (sizeThatFits(.unspecified)).
  2. If the ideal width is within your maximum, use it — short text gets a snug popover.
  3. If it’s wider than the maximum, re-measure the subview with a proposal of that maximum width, so the text wraps and the height grows to fit.
  4. Cache the resulting size and report it from sizeThatFits, then place the subview at that size.
struct PopoverLayout: Layout {
    typealias Cache = CGSize
    let maxWidth: CGFloat

    func makeCache(subviews: Subviews) -> CGSize {
        guard let first = subviews.first else { return .zero }
        let ideal = first.sizeThatFits(.unspecified)
        if ideal.width <= maxWidth {
            return ideal
        }
        return first.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
    }

    func sizeThatFits(proposal: ProposedViewSize,
                      subviews: Subviews,
                      cache: inout CGSize) -> CGSize {
        cache
    }

    func placeSubviews(in bounds: CGRect,
                       proposal: ProposedViewSize,
                       subviews: Subviews,
                       cache: inout CGSize) {
        subviews.first?.place(
            at: bounds.origin,
            proposal: ProposedViewSize(cache)
        )
    }
}

This implementation assumes a single subview, which is exactly what a text popover needs. Put your content (including padding) inside it:

struct ContentView: View {
    @State private var popoverIsPresented = false

    var body: some View {
        Button("Enter") {
            popoverIsPresented = true
        }
        .buttonStyle(.borderedProminent)
        .popover(isPresented: $popoverIsPresented) {
            PopoverLayout(maxWidth: 350) {
                Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, " +
                     "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
                    .padding()
            }
            .presentationCompactAdaptation(.popover)
        }
    }
}

Two details worth noting:

  • The padding goes inside the PopoverLayout, so the measured size includes it and the text isn’t flush against the popover edge.
  • .presentationCompactAdaptation(.popover) keeps the content presenting as a real popover on compact-width devices (iPhones, or a split view’s narrow column) instead of expanding to a sheet. Without it, the popover adapts to a sheet on iPhone and the sizing discussion is moot there.

With this in place, a short string produces a small popover sized to the text, and a long paragraph wraps at your maximum width and grows vertically — no hardcoded width: anywhere.

2. Make the maximum width relative to the screen

A fixed maxWidth: 350 is fine for a known layout, but if you want the cap to scale across devices, derive it from the available width with a GeometryReader:

var body: some View {
    GeometryReader { geometry in
        Button("Enter") {
            popoverIsPresented = true
        }
        .buttonStyle(.borderedProminent)
        .popover(isPresented: $popoverIsPresented) {
            PopoverLayout(maxWidth: geometry.size.width * 0.85) {
                Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
                    .padding()
            }
            .presentationCompactAdaptation(.popover)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

The outer .frame(maxWidth: .infinity, maxHeight: .infinity) makes the GeometryReader fill its container so geometry.size.width reflects the real available width rather than collapsing to zero (a GeometryReader is greedy, but only if it’s given room).

3. The quick-and-dirty alternative (and why it falls short)

If you don’t want a custom Layout, the minimal workaround is to give the text some width constraint so it has something to wrap against:

.popover(isPresented: $popoverIsPresented) {
    Text(longParagraph)
        .frame(maxWidth: 350, alignment: .leading)
        .padding()
}

This does wrap the text. The trade-off is that the constraint is one-sided: maxWidth alone doesn’t shrink the popover for short text the way the Layout approach does, because the popover still sizes from the content’s ideal size and the frame’s influence on that measurement is limited. It’s acceptable when every popover in your app carries roughly the same amount of text; use the Layout version when popovers hold anything from a word to a paragraph.

How to prevent it

  • Remember the rule: a popover sizes from its content’s ideal size, and Text‘s ideal size is one line. Any time you put variable-length text in a popover, plan for wrapping explicitly.
  • Route all text popovers through the PopoverLayout wrapper (or a shared view that uses it) so the behavior is consistent app-wide.
  • Prefer a screen-relative maximum width over a magic number when the app runs on both iPhone and iPad.
  • Add .presentationCompactAdaptation(.popover) when you specifically want a popover, not an adapted sheet, on compact devices.
  • Test with both a very short string and a very long one — the failure mode only appears at one end of the range.

FAQ

Why does .fixedSize(horizontal: false, vertical: true) not fix the height?

It fixes the view at its ideal height, but the ideal height of unwrapped text is the single-line height. The text has to wrap first for the ideal height to grow, and wrapping requires a width constraint — which is what the custom Layout provides.

Does this work for content other than Text?

Yes. The Layout measures whatever single subview it contains, so a VStack of text and an image, or a Form-like column, is sized the same way: ideal size if it fits, capped at the maximum otherwise. Just keep it to one subview, as the implementation assumes.

Why does my popover show as a full sheet on iPhone instead?

On compact width, SwiftUI adapts popovers to sheets by default. Apply .presentationCompactAdaptation(.popover) to the popover content to force the actual popover appearance, keeping in mind that a small popover on a narrow screen can be awkward — sometimes the sheet adaptation is the better UX on iPhone.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.