Skip to content

iOS: TextView collapses to its longest-line width inside flexible SwiftUI containers #26

Description

@felipefdl

Summary

On iOS, TextView sizes itself to the width of its longest line instead of the width SwiftUI proposes. Inside a flexible container (for example ScrollView + LazyVStack with .frame(maxWidth: .infinity, alignment: .leading)), content whose lines are short collapses into a narrow column. With MarkdownText from MarkdownView 3.0.0 (which renders through TextView), an ordered list like:

1. Apple
2. Banana
3. Cherry
4. Date
5. Elderberry

produces a text view of about 98 pt in a 390 pt window. The list paragraph styles then apply their head indents inside that sliver, so item text wraps character by character (Ba- / nana) and trailing items can end up drawn outside the laid-out region entirely, which also truncates what copy(_:) returns.

macOS is not affected: the AppKit path sizes through intrinsicContentSize (height only), so the view stretches to the container width. Issue #9 describes the mirror-image behavior there.

Root cause

_TextView_UIKit.sizeThatFits(_:uiView:context:) returns uiView.sizeThatFits(...) directly:

func sizeThatFits(
    _ proposal: ProposedViewSize,
    uiView: InlineAttachmentTextView,
    context: Context
) -> CGSize? {
    uiView.sizeThatFits(
        proposal.replacingUnspecifiedDimensions(
            by: CGSize(
                width: UIView.noIntrinsicMetric,
                height: UIView.noIntrinsicMetric
            )
        )
    )
}

UITextView.sizeThatFits returns the used (snug) width, not the proposed width. SwiftUI takes that as the definitive size for the representable, so even an explicit .frame(width: 358) on the SwiftUI side still yields a 98 pt view; the frame is wide, the text view inside is not. Long prose masks the bug because its used width equals the proposal.

Suggested fix

Honor a concrete width proposal and only use the fitted width when the proposal has none:

let fitted = uiView.sizeThatFits(
    proposal.replacingUnspecifiedDimensions(
        by: CGSize(
            width: UIView.noIntrinsicMetric,
            height: UIView.noIntrinsicMetric
        )
    )
)
if let width = proposal.width, width > 0 {
    return CGSize(width: width, height: fitted.height)
}
return fitted

With this change applied locally (RichText main, a2fb191), the same list lays out at the full 358 pt, prose is unchanged, and copy returns the complete text. Streaming updates through MarkdownView's StreamingMarkdownReader also stay correct.

Reproduction

Self-contained XCTest against an iOS 26 simulator (iPhone 17 Pro), in a package depending on MarkdownView 3.0.0 (which pulls RichText; the same collapse reproduces with TextView directly):

import MarkdownView
import SwiftUI
import UIKit
import XCTest

@MainActor
final class WidthCollapseTests: XCTestCase {
  func testListRowGetsFullWidth() async throws {
    let markdown = "1. Apple\n2. Banana\n3. Cherry\n4. Date\n5. Elderberry"
    let host = UIHostingController(rootView: ScrollView {
      LazyVStack(alignment: .leading, spacing: 16) {
        Text("another row").padding()
        MarkdownText(markdown)
          .frame(maxWidth: .infinity, alignment: .leading)
      }
      .padding(.horizontal, 16)
    })
    let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 900))
    window.rootViewController = host
    window.makeKeyAndVisible()
    host.view.layoutIfNeeded()
    var textView: UITextView?
    for _ in 0..<40 {
      try await Task.sleep(for: .milliseconds(50))
      textView = Self.find(in: host.view)
      if let textView, textView.bounds.width > 300 { break }
    }
    let found = try XCTUnwrap(textView)
    // Fails on current main: width is 98.0
    XCTAssertGreaterThan(found.bounds.width, 300, "collapsed to \(found.bounds.width) pt")
  }

  private static func find(in view: UIView) -> UITextView? {
    if let textView = view as? UITextView { return textView }
    for sub in view.subviews {
      if let found = find(in: sub) { return found }
    }
    return nil
  }
}

Observed on current main: bounds.width == 98.0, layoutManager.usedRect width about 75 pt. Expected: the proposed width (358 pt here).

Happy to open a PR with the one-line change if you want it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions