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.
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) }) } }