macosswiftuinstextview

How do I vertically center an NSTextView in a SwiftUI view with NSViewRepresentable?


I've got a simple NSViewRepresentable that wraps an NSTextView.

struct TextView: NSViewRepresentable {
  typealias NSViewType = NSTextView

  var text: NSAttributedString

  func makeNSView(context: Context) -> NSTextView {
    let view = NSTextView()
    // set background color to show view bounds
    view.backgroundColor = NSColor.systemBlue
    view.drawsBackground = true
    view.isEditable = false
    view.isSelectable = false
    return view
  }

  func updateNSView(_ nsView: NSTextView, context: Context) {
    nsView.textStorage?.setAttributedString(text)
  }
}

I want to center it vertically, so I'm using a VStack with Spacers above and below. I want to leave left and right margins around it proportional to the window size, so I've wrapped it in a GeometryReader and frame().

struct ContentView: View {
  func textView() -> some View {
    let text = """
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod \
      tempor incididunt ut labore et dolore magna aliqua.
      """

    // Note: real application has more complex text than this, *not* using
    // NSAttributedString isn't really an option, at least till SwiftUI has
    // proper rich text support.

    let size: CGFloat = 18
    let font = NSFont(name: "LeagueSpartan-Bold", size: size) 
    let attrString = NSAttributedString(
      string: text,
      attributes: [ .foregroundColor: NSColor.systemYellow,
                    .font: font ?? NSFont.systemFont(ofSize: size) ])
    return TextView(text: attrString)
  }

  var body: some View {
    let textView: some View = self.textView()
    return ZStack {
      // use ZStack to provide window background color
      Color(NSColor.systemTeal)
      VStack {
        Spacer()
        GeometryReader { m2 in
          textView.frame(width: m2.size.width / 1.618)
        }
        Spacer()
      }
    }
  }
}

The horizontal frame works fine, but the vertical spacing isn't working at all:

initial state

(initial state)

And resizing the window produces shenanigans:

resize from bottom

(resize from bottom)

resize from bottom to top, then down again

(resize from bottom to top, then down again)

Oh, and the Preview is completely bananas:

Preview

If I replace my custom TextView with a SwiftUI native Text, the layout works fine, which suggests that the problem is in TextView. (Note also that the default window size is smaller.)

native Text

It seems likely that the size of the NSTextView isn't getting set properly. If I add nsView.sizeToFit() to updateNSView():

    func updateNSView(_ nsView: NSTextView, context: Context) {
        nsView.textStorage?.setAttributedString(text)
        nsView.sizeToFit()
    }

this gets me the smaller default window size, and stops the text from bouncing to the bottom of the window when I resize up and down, but the text is still pinned near the top of the window, the preview is still broken, and resizing from the bottom still gets the NSTextView temporarily filling most of the height of the window.

Other things I've tried fiddling with: isVerticallyResizable, setContentCompressionResistancePriority, autoresizingMask and translatesAutoresizingMaskIntoConstraints, invalidateIntrinsicContentSize. None of these seem to make any obvious difference.

It seems like what I want is to update the NSTextView size when the containing SwiftUI views resize, but there doesn't seem to be any obvious way to do that, and I might anyway be wrong.


Solution

  • If you just need to show NSAttributedString, as I understood, then approach based on NSTextField, as shown below, is more appropriate, because NSTextView does not have default internal layout and requires explicit external frame.

    demo

    Here is modified representable, ContentView does not require changes.

    struct TextView: NSViewRepresentable {
      typealias NSViewType = NSTextField
    
      var text: NSAttributedString
    
      func makeNSView(context: Context) -> NSTextField {
        let view = NSTextField()
        // set background color to show view bounds
        view.backgroundColor = NSColor.systemBlue
        view.drawsBackground = true
        view.isEditable = false
        view.isSelectable = false
        view.lineBreakMode = .byWordWrapping
        view.maximumNumberOfLines = 0
        view.translatesAutoresizingMaskIntoConstraints = false
        view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        view.setContentCompressionResistancePriority(.required, for: .vertical)
        return view
      }
    
      func updateNSView(_ nsView: NSTextField, context: Context) {
        nsView.attributedStringValue = text
      }
    }