How to render multiline text in SwiftUI List with correct height?

I would like to have a SwiftUI view that shows many lines of text, with the following requirements:


  • Works on both macOS and iOS.
  • Shows a large number of strings (each string is backed by a separate model object).
  • I can do arbitrary styling to the multiline text.
  • Each string of text can be of arbitrary length, possibly spanning multiple lines and paragraphs.
  • The maximum width of each string of text is fixed to the width of the container. Height is variable according to the actual length of text.
  • There is no scrolling for each individual text, only the whole list.
  • Links in the text must be tappable/clickable.
  • Text is read-only, does not have to be editable.


Feels like the most appropriate solution would be to have a List view, with the individual rows wrapping native UITextView/NSTextView.


Here’s what I have so far. It implements most of the requirements EXCEPT having the correct height for the rows.


import SwiftUI

let number = 20

struct ListWithNativeTexts: View {
  var body: some View {
  List(texts(count: number), id: \.self) { text in
  NativeTextView(string: text)
  }
  }
}

struct ListWithNativeTexts_Previews: PreviewProvider {
  static var previews: some View {
  ListWithNativeTexts()
  }
}

func texts(count: Int) -> [String] {
  return (1...count).map {
  (1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
  }
}

#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor

struct NativeTextView: UIViewRepresentable {

  var string: String

  func makeUIView(context: Context) -> UITextView {
  let textView = UITextView()

  textView.isEditable = false
  textView.isScrollEnabled = false
  textView.dataDetectorTypes = .link
  textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
  textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  textView.textContainer.lineFragmentPadding = 0

  let attributed = attributedString(for: string)
  textView.attributedText = attributed

  return textView
  }

  func updateUIView(_ textView: UITextView, context: Context) {
  }

}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor

struct NativeTextView: NSViewRepresentable {

  var string: String

  func makeNSView(context: Context) -> NSTextView {
  let textView = NSTextView()
  textView.isEditable = false
  textView.isAutomaticLinkDetectionEnabled = true
  textView.isAutomaticDataDetectionEnabled = true
  textView.textContainer?.lineFragmentPadding = 0
  textView.backgroundColor = NSColor.clear

  textView.textStorage?.append(attributedString(for: string))
  textView.isEditable = true
  textView.checkTextInDocument(nil) // make links clickable
  textView.isEditable = false

  return textView
  }

  func updateNSView(_ textView: NSTextView, context: Context) {

  }

}
#endif

func attributedString(for string: String) -> NSAttributedString {
  let attributedString = NSMutableAttributedString(string: string)
  let paragraphStyle = NSMutableParagraphStyle()
  paragraphStyle.lineSpacing = 4
  let range = NSMakeRange(0, (string as NSString).length)

  attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
  attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
  attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
  attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
  return attributedString
}


This mostly does what I need, except wrapping the text to multiple lines and scaling the height of each list row. Each list row remains at its default height.


How do I get this solution to wrap the text to multiple lines and size the text views with correct heights?


One approach that I have tried, but not shown here, is to give the height “from outside in” - to specify the height on the list row itself with frame. I can calculate the height of an NSAttributedString when I know the width, which I can obtain with geoReader. This almost works, but is buggy, and does not feel right, so I’m not showing it here.

Answered by Jaanus in 619031022
Update, July 2020, after WWDC 2020.

We got new LazyVStack API, which means we can implement this now with scrollview instead of list. So here is a solution based on ScrollView and attributed string height measuring. I discussed this with Apple engineers during a SwiftUI who said that this is if not the best/official practice, then at least a pretty good solution. It works well for me in both macOS and iOS.

(I got an error from the forum system that I have too many characters, therefore including only the iOS native code. macOS NSTextView stuff is very similar in spirit.)

Code Block swift
//
// Created by Jaanus Kase on 11.05.2020.
// Copyright © 2020 Jaanus Kase. All rights reserved.
//
import SwiftUI
struct NativeTextsWithManagedHeight: View {
let rows = texts(count: 1000)
var body: some View {
GeometryReader { geometry in
ScrollViewReader { scrollViewProxy in
VStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0..<self.rows.count, id: \.self) { i in
self.makeView(geometry, text: self.rows[i])
}
}
}
Button("Scroll to row 3") {
print("Something")
withAnimation {
scrollViewProxy.scrollTo("Hello https://example.com: 1 2 3", anchor: .center)
}
}.padding()
}
}
}
}
func makeView(_ geometry: GeometryProxy, text: String) -> some View {
let attributed = attributedString(for: text)
let height = attributed.height(containerWidth: geometry.size.width)
return NativeTextView(string: text).frame(width: geometry.size.width, height: height).id(text)
}
}
struct NativeTextsWithManagedHeight_Previews: PreviewProvider {
static var previews: some View {
NativeTextsWithManagedHeight(lazyStack: true)
}
}
#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor
struct NativeTextView: UIViewRepresentable {
var string: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isScrollEnabled = false
textView.dataDetectorTypes = .link
textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.textContainer.lineFragmentPadding = 0
let attributed = attributedString(for: string)
textView.attributedText = attributed
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
}
}
#endif
func attributedString(for string: String) -> NSAttributedString {
let attributedString = NSMutableAttributedString(string: string)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let range = NSMakeRange(0, (string as NSString).length)
attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
return attributedString
}
extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.height)
}
}
func texts(count: Int) -> [String] {
return (1...count).map {
(1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
}
}

How do I get this solution to wrap the text to multiple lines and size the text views with correct heights?


Did you try to set

textView.lineBreakMode = .byWordWrapping

This doesn’t make a difference because .byWordWrapping is the default value for lineBreakMode. The text views wrap lines correctly if given enough space. If I set an explicit height on the row, I see everything is wrapped correctly on word breaks:


NativeTextView(string: text).frame(height: 128)

This feels like a hack and against the spirit of the framework. I’m hoping there is a cleaner solution that I have missed, that doesn’t involve leaking anything out of the ViewRepresentable. I also can’t quite figure out what would be a good moment to capture the frame/height info in my solution - should I subclass UITextView/NSTextView and intercept the frame/bounds in some of the view layout methods? Uhh.

Unfortunaltely (please note it's a personal opinion), there are still of lot of rough edges in SwiftUI which is not as advanced as UIKit.


And may force to use some workaround (kacks) to get it work as intended.

By the time I see 4 options:

- accept a hack, mark it in code to modify once SwiftUI has evolved

- no kack, encapsulate a UIKit object and implement behavior there

- no kack,give up the requirement (here select a fixed size), but that's not always feasible

- hope someone comes with a brilliant solution on the forum (that's always a possibility…)


Good luck.


Maybe a hint to a solution.

Could you have a state var where you compute or evaluate how many lines needed for each cell text.

Then use this state var to set the frame of the cell accordingly.

When text changes, state var would be updated, forcing a redraw…

I agree with you about two things.

  • SwiftUI is still obviously very raw and has many rough edges. It will not be equal to AppKit or UIKit any time soon, and this is why bridging the frameworks with these ViewRepresentable things is quite elegant.
  • Sometimes, hacks are needed for workarounds.


I am still hoping for a cleaner solution to my problem, and a clearer and deeper explanation from someone who knows more about the internals, or better docs from Apple. This shouldn’t be an edge case, but rather common. And for my own education, I really want to understand why and how it works internally and how do the different pieces interact with one another. There are multiple complex pieces interacting with each other here. UI/NSTextView is quite complex, and its layout behavior seems to differ significantly based on if it is scrollable or not. I don’t entirely understand how SwiftUI sizes its list row heights in this case. UI/NSTextView layout inside the ViewRepresentable can be done with autolayout or resizing mask, all of which have different behavior and performance characteristics. Etc. I do hope to eventually get to the bottom of this and arrive at a clean, elegant, performant solution.


I’m keeping my fingers crossed that maybe they bring some updates or better docs/examples at WWDC which is not too far away. And maybe in the meantime, someone can offer a cleaner solution.


In the meantime, I came up with this. It meets my requirements in the sense that it sizes the list row heights correctly in most cases. It is ugly because when e.g adding rows to a list, heights get messed up because of the async call, and the performance seems to be not great, causing more redraws/layouts than is necessary. Scrolling is not great. Nevertheless, I leave it here as an interim solution.


import SwiftUI

let number = 200

struct ListWithNativeTexts: View {
    var body: some View {
        List(texts(count: number), id: \.self) { text in
            NativeTextViewWrapper(string: text)
        }
    }
}

struct NativeTextViewWrapper: View {
    
    @State var textViewHeight: CGFloat = 0
    var string: String
    
    var body: some View {
        NativeTextView(string: string, textViewHeight: $textViewHeight).frame(height: textViewHeight)
    }
}

struct ListWithNativeTexts_Previews: PreviewProvider {
    static var previews: some View {
        ListWithNativeTexts()
    }
}

func texts(count: Int) -> [String] {
    return (1...count).map {
        (1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
    }
}

#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor
typealias NativeView = UIView

struct NativeTextView: UIViewRepresentable {
    
    var string: String
    @Binding var textViewHeight: CGFloat
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()

        textView.isEditable = false
        textView.isScrollEnabled = false
        textView.dataDetectorTypes = .link
        textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        textView.textContainer.lineFragmentPadding = 0

        let attributed = attributedString(for: string)
        textView.attributedText = attributed
        
        measureHeight(view: textView)
        
        return textView
    }
    
    func updateUIView(_ textView: UITextView, context: Context) {
        measureHeight(view: textView)
    }
    
}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor
typealias NativeView = NSView

struct NativeTextView: NSViewRepresentable {
    
    var string: String
    @Binding var textViewHeight: CGFloat
    
    func makeNSView(context: Context) -> NSTextView {
        let textView = NSTextView()
        textView.isEditable = false
        textView.isAutomaticLinkDetectionEnabled = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.textContainer?.lineFragmentPadding = 0
        textView.backgroundColor = NSColor.clear
                
        textView.textStorage?.append(attributedString(for: string))
        textView.isEditable = true
        textView.checkTextInDocument(nil) // make links clickable
        textView.isEditable = false
        
        measureHeight(view: textView)
        
        return textView
    }
    
    func updateNSView(_ textView: NSTextView, context: Context) {
        measureHeight(view: textView)
    }

}
#endif

extension NativeTextView {

    func measureHeight(view: NativeView) {
        DispatchQueue.main.async {
            let bounds = view.bounds
            #if os(iOS)
            let sizeThatFits = view.sizeThatFits(CGSize(width: bounds.size.width, height: CGFloat.greatestFiniteMagnitude))
            #else
//            https://stackoverflow.com/questions/2654580/how-to-resize-nstextview-according-to-its-content
            var sizeThatFits = CGSize(width: 0, height: 0)
            
            if let textView = view as? NSTextView, let layoutManager = textView.layoutManager, let textContainer = textView.textContainer {
                layoutManager.ensureLayout(for: textContainer)
                sizeThatFits = layoutManager.usedRect(for: textContainer).size
            }
            
            #endif
            self.textViewHeight = sizeThatFits.height
        }
    }
}

func attributedString(for string: String) -> NSAttributedString {
    let attributedString = NSMutableAttributedString(string: string)
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 4
    let range = NSMakeRange(0, (string as NSString).length)
    
    attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
    attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
    attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
    attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
    return attributedString
}
Accepted Answer
Update, July 2020, after WWDC 2020.

We got new LazyVStack API, which means we can implement this now with scrollview instead of list. So here is a solution based on ScrollView and attributed string height measuring. I discussed this with Apple engineers during a SwiftUI who said that this is if not the best/official practice, then at least a pretty good solution. It works well for me in both macOS and iOS.

(I got an error from the forum system that I have too many characters, therefore including only the iOS native code. macOS NSTextView stuff is very similar in spirit.)

Code Block swift
//
// Created by Jaanus Kase on 11.05.2020.
// Copyright © 2020 Jaanus Kase. All rights reserved.
//
import SwiftUI
struct NativeTextsWithManagedHeight: View {
let rows = texts(count: 1000)
var body: some View {
GeometryReader { geometry in
ScrollViewReader { scrollViewProxy in
VStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0..<self.rows.count, id: \.self) { i in
self.makeView(geometry, text: self.rows[i])
}
}
}
Button("Scroll to row 3") {
print("Something")
withAnimation {
scrollViewProxy.scrollTo("Hello https://example.com: 1 2 3", anchor: .center)
}
}.padding()
}
}
}
}
func makeView(_ geometry: GeometryProxy, text: String) -> some View {
let attributed = attributedString(for: text)
let height = attributed.height(containerWidth: geometry.size.width)
return NativeTextView(string: text).frame(width: geometry.size.width, height: height).id(text)
}
}
struct NativeTextsWithManagedHeight_Previews: PreviewProvider {
static var previews: some View {
NativeTextsWithManagedHeight(lazyStack: true)
}
}
#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor
struct NativeTextView: UIViewRepresentable {
var string: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isScrollEnabled = false
textView.dataDetectorTypes = .link
textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.textContainer.lineFragmentPadding = 0
let attributed = attributedString(for: string)
textView.attributedText = attributed
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
}
}
#endif
func attributedString(for string: String) -> NSAttributedString {
let attributedString = NSMutableAttributedString(string: string)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let range = NSMakeRange(0, (string as NSString).length)
attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
return attributedString
}
extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.height)
}
}
func texts(count: Int) -> [String] {
return (1...count).map {
(1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
}
}

Update after WWDC 2020. We got LazyVStack there, which is exactly what’s needed. I discussed this with Apple engineers in a lab, who confirmed that LazyVStack + scrollview + UI/NSTextView is quite acceptable for the time being.
How to render multiline text in SwiftUI List with correct height?
 
 
Q