I'm building a custom UITextView for SwiftUI, via UIViewRepresentable. It's meant to display NSAttributedString
, and handle link presses. Everything works, but the frame height is completely messed up when I show this view inside of a NavigationView
with an inline title.
import SwiftUI
struct AttributedText: UIViewRepresentable {
class Coordinator: NSObject, UITextViewDelegate {
var parent: AttributedText
init(_ view: AttributedText) {
parent = view
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
parent.linkPressed(URL)
return false
}
}
let content: NSAttributedString
@Binding var height: CGFloat
var linkPressed: (URL) -> Void
public func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.backgroundColor = .clear
textView.isEditable = false
textView.isUserInteractionEnabled = true
textView.delegate = context.coordinator
textView.isScrollEnabled = false
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.dataDetectorTypes = .link
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
return textView
}
public func updateUIView(_ view: UITextView, context: Context) {
view.attributedText = content
// Compute the desired height for the content
let fixedWidth = view.frame.size.width
let newSize = view.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
DispatchQueue.main.async {
self.height = newSize.height
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
struct ContentView: View {
private var text: NSAttributedString {
NSAttributedString(string: "Eartheart is the principal settlement for the Gold Dwarves in East Rift and it is still the cultural and spiritual center for its people. Dwarves take on pilgrimages to behold the great holy city and take their trips from other countries and the deeps to reach their goal, it use to house great temples and shrines to all the Dwarven pantheon and dwarf heroes but after the great collapse much was lost.\n\nThe lords of their old homes relocated here as well the Deep Lords. The old ways of the Deep Lords are still the same as they use intermediaries and masking themselves to undermine the attempts of assassins or drow infiltrators. The Gold Dwarves outnumber every other race in the city and therefor have full control of the city and it's communities.")
}
@State private var height: CGFloat = .zero
var body: some View {
NavigationView {
List {
AttributedText(content: text, height: $height, linkPressed: { url in print(url) })
.frame(height: height)
Text("Hello world")
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Content"), displayMode: .inline)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When you run this code, you will see that the AttributedText
cell will be too small to hold its content.
When you remove the displayMode: .inline
parameter from the navigationBarTitle
, it shows up fine.
But if I add another row to display the height value (Text("\(height)")
), it again breaks.
Maybe it's some kind of race condition triggered by view updates via state changes? The height
value itself is correct, it's just that the frame isn't actually that tall. Is there a workaround?
Using ScrollView
with a VStack
does solve the problem, but I'd really really prefer to use a List
due to the way the content is shown in the real app.
If you will not change text, you can calculate width and height and use them as frame even without binding.
List {
// you don't need binding height
AttributedText(content: text, linkPressed: { url in print(url) })
.frame(height: frameSize(for: text).height)
Text("Hello world")
}
func frameSize(for text: String, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .body)
]
let attributedText = NSAttributedString(string: text, attributes: attributes)
let width = maxWidth != nil ? min(maxWidth!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let height = maxHeight != nil ? min(maxHeight!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let constraintBox = CGSize(width: width, height: height)
let rect = attributedText.boundingRect(with: constraintBox, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).integral
return rect.size
}
With Extension:
extension String {
func frameSize(maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .body)
]
let attributedText = NSAttributedString(string: self, attributes: attributes)
let width = maxWidth != nil ? min(maxWidth!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let height = maxHeight != nil ? min(maxHeight!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let constraintBox = CGSize(width: width, height: height)
let rect = attributedText.boundingRect(with: constraintBox, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).integral
return rect.size
}
}
So I had this exact issue.
The solution isnt pretty but I found one that works:
Firstly, you need to subclass UITextView so that you can pass its content size back to SwiftIU:
public class UITextViewWithSize: UITextView {
@Binding var size: CGSize
public init(size: Binding<CGSize>) {
self._size = size
super.init(frame: .zero, textContainer: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
self.size = sizeThatFits(.init(width: frame.width, height: 0))
}
}
Once you have done this, you need to create a UIViewRepresentable for your custom UITextView:
public struct HyperlinkTextView: UIViewRepresentable {
public typealias UIViewType = UITextViewWithSize
private var text: String
private var font: UIFont?
private var foreground: UIColor?
@Binding private var size: CGSize
public init(_ text: String, font: UIFont? = nil, foreground: UIColor? = nil, size: Binding<CGSize>) {
self.text = text
self.font = font
self.foreground = foreground
self._size = size
}
public func makeUIView(context: Context) -> UIViewType {
let view = UITextViewWithSize(size: $size)
view.isEditable = false
view.dataDetectorTypes = .all
view.isScrollEnabled = false
view.text = text
view.textContainer.lineBreakMode = .byTruncatingTail
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.required, for: .vertical)
view.textContainerInset = .zero
if let font = font {
view.font = font
} else {
view.font = UIFont.preferredFont(forTextStyle: .body)
}
if let foreground = foreground {
view.textColor = foreground
}
view.sizeToFit()
return view
}
public func updateUIView(_ uiView: UIViewType, context: Context) {
uiView.text = text
uiView.layoutSubviews()
}
}
Now that we have easy access to the view's content size, we can use that to force the view to fit into a container of that size. For some reason, simply using a .frame on the view doesnt work. The view just ignores the frame its given. But when putting it into a geometry reader, it seems to grow as expected.
GeometryReader { proxy in
HyperlinkTextView(bio, size: $bioSize)
.frame(maxWidth: proxy.frame(in: .local).width, maxHeight: .infinity)
}
.frame(height: bioSize.height)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With