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 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, 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.
//
// ListWithNativeTexts.swift
// SUIToy
//
// Created by Jaanus Kase on 03.05.2020.
// Copyright © 2020 Jaanus Kase. All rights reserved.
//
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
}
Here’s what it outputs on iOS. macOS output is similar.
How do I get this solution to 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.
Sizing List rows doesn't work well with SwiftUI.
However, I have worked out how to display a scroll of native UITextViews in a stack, where each item is dynamically sized based on the height of its attributedText.
Here is the full class with extensions for attributedText height and regular string size, as well.