Why SwiftUI multiline wrapping Text in macOS works in Preview, but not in real app?

Viewed 134

I have a SwiftUI view which shows image and text.

import SwiftUI
#if canImport(UIKit)
typealias AppColor = UIColor
#else
typealias AppColor = NSColor
#endif

struct ReportErrorUI: View {

   enum Order {
      case even, odd
   }

   let error: String
   let order: Order

   init(error: String, order: Order) {
      self.error = error
      self.order = order
   }

   var body: some SwiftUI.View {

      let bgColor = (order == .even ? AppColor.systemGreen : .systemRed).withAlphaComponent(0.5)

      return VStack(alignment: .leading, spacing: 0, content: {
         HStack(alignment: .center, spacing: 8, content: {
            Image("icon.error")
            Text(error).font(.body)
         }).padding(8)
         Color(.magenta).frame(height: 2)
      }).background(Color(bgColor))
   }
}

Now I can preview it:

import SwiftUI

@available(OSX 11.0, *)
struct ReportErrorUI_Previews: PreviewProvider {

   static let shortText = "Etiam habebis sem dicantur magna mollis euismod."
   static let longText = "Tityre, tu patulae recubans sub tegmine fagi  dolor. Idque Caesaris facere voluntate liceret: sese habere. Unam incolunt Belgae, aliam Aquitani, tertiam."

   static var previews: some SwiftUI.View {
      Group {
         VStack(spacing: 0) {
            ReportErrorUI(error: longText, order: .even)
            ReportErrorUI(error: shortText, order: .odd)
         }.previewLayout(.sizeThatFits)
         .background(Color.white).padding().frame(width: 320)
      }
   }
}

Here is it looks like:

As you can see the Text view works as expected – long text is wrapped.

Swift UI Preview

Now I am adding this ReportErrorUI alongside with old-school NSTextField multiline label, into existing AppKit application via NSHostingController or NSHostingView. Both UI elements added into NSStackView.

class MainViewController: ReusableViewController {

   private lazy var stackView = NSStackView()

   override func setupUI() {
      view.addSubview(stackView)
      stackView.translatesAutoresizingMaskIntoConstraints = false

      stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 8).isActive = true
      stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8).isActive = true
      stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).isActive = true
      stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8).isActive = true

      stackView.widthAnchor.constraint(greaterThanOrEqualToConstant: 120).isActive = true
      stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: 80).isActive = true

      stackView.orientation = .vertical
      stackView.spacing = 8
      stackView.distribution = .fill
      stackView.alignment = .leading

      // Wrappable multiline label. See: https://stackoverflow.com/a/35920653/1418981
      let label = NSTextField()
      label.stringValue = "Excepteur sint obcaecat cupiditat non proident culpa. A communi observantia non est recedendum."
      label.setContentHuggingPriority(.init(1), for: .horizontal)
      label.setContentCompressionResistancePriority(.init(1), for: .horizontal)
      label.isEditable = false
      label.backgroundColor = NSColor.magenta.withAlphaComponent(0.4)
      label.isBezeled = false
      label.isSelectable = true
      //> Below 4 lines seems not needed.
      // label.usesSingleLineMode = false
      // label.lineBreakMode = .byWordWrapping
      // label.cell?.wraps = true
      // label.cell?.isScrollable = false
      //<
      stackView.addArrangedSubview(label)

      do {
         let ui = ReportErrorUI(error: "Excepteur sint obcaecat cupiditat non proident culpa. A communi observantia non est recedendum.", order: .even)
         let view = NSHostingView(rootView: ui)
         view.setContentHuggingPriority(.init(1), for: .horizontal)
         view.setContentCompressionResistancePriority(.init(1), for: .horizontal)
         view.setContentCompressionResistancePriority(.required, for: .vertical)
         stackView.addArrangedSubview(view)
      }

      do {
         let ui = ReportErrorUI(error: "Excepteur sint obcaecat cupiditat non proident culpa. A communi observantia non est recedendum.", order: .odd)
         let vc = NSHostingController(rootView: ui)
         addChild(vc)
         vc.view.setContentHuggingPriority(.init(1), for: .horizontal)
         vc.view.setContentCompressionResistancePriority(.init(1), for: .horizontal)
         vc.view.setContentCompressionResistancePriority(.required, for: .vertical)
         stackView.addArrangedSubview(vc.view)
      }

      do {
         let view = ReusableView()
         view.backgroundColor = .yellow
         view.heightAnchor.constraint(greaterThanOrEqualToConstant: 12).isActive = true
         stackView.addArrangedSubview(view)
      }

   }
}

As result old-school NSTextField works as expected - multiline text wraps itself on Application window resize. But text inside SwiftUI Text view is not wrapped.

Real App

Tricks from UIKit world, like lineLimit(nil) or .fixedSize(horizontal: false, vertical: true), either not working or breaking Application window layout.

Here for instance how .fixedSize(horizontal: false, vertical: true) works - Application window layout is broken. Application window cannot be vertically resized.

Broken layout

Any combination of setContentCompressionResistancePriority or setContentHuggingPriority for horizontal, vertical axes set on NSHostingView or NSHostingController not helping.

How to make multiline wrapping SwiftUI Text working correctly on macOS?

1 Answers

The solution for macOS.

Thanks to this article: https://zalogatomek.medium.com/swiftui-missing-intrinsic-content-size-how-to-get-it-6eca8178a71f

struct IntrinsicContentSizePreferenceKey: PreferenceKey {
   static let defaultValue: CGSize = .zero

   static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
      value = nextValue()
   }
}

extension View {
   func readIntrinsicContentSize(to size: Binding<CGSize>) -> some View {
      background(GeometryReader { proxy in
         Color.clear.preference(
            key: IntrinsicContentSizePreferenceKey.self,
            value: proxy.size
         )
      })
      .onPreferenceChange(IntrinsicContentSizePreferenceKey.self) {
         size.wrappedValue = $0
      }
   }
}

struct MultilineText: View {

   @State private var textSize: CGSize = .zero
   let text: String

   init(_ text: String) {
      self.text = text
   }

   var body: some SwiftUI.View {
      Text(text).readIntrinsicContentSize(to: $textSize).fixedSize(horizontal: false, vertical: true).frame(height: textSize.height)
   }
}
Related