I need to be able to display HTML tagged text with a SwiftUI text view.
Following tutorials I came to this UIViewRepresentable
solution using NSAttributedString
.
struct HTMLText: UIViewRepresentable {
var attributedText: NSAttributedString
init(text: String) {
self.attributedText = text.htmlToAttributedString
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.backgroundColor = .red
uiTextView.textContainerInset = .zero
uiTextView.isEditable = false
uiTextView.isScrollEnabled = false
uiTextView.textContainer.lineFragmentPadding = 0
return uiTextView
}
func updateUIView(_ uiTextView: UITextView, context: Context) {
uiTextView.attributedText = attributedText
}
}
extension String {
var htmlToAttributedString: NSAttributedString {
guard let data = data(using: .utf8) else {
return NSAttributedString(string: self) }
do {
return try NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding:String.Encoding.utf8.rawValue],
documentAttributes: nil)
} catch {
return NSAttributedString(string: self)
}
}
}
While this works nicely for simple views, I have run into difficulty incorporating it into my app. Something to do with a NavigationStack
causes cycles in the attribute graph: === AttributeGraph: cycle detected through attribute 96280 ===
. Used in my app a few dozen of these messages are logged and no text field displayed. More fatally, GeometryReader
used directly as a parent view seems to cause a SIGABRT
, with a logging of precondition failure: setting value during update: 768
.
This replication causes cycles, although only a few and the text is displayed:
struct ContentView: View {
@State var showView: Bool = false
var body: some View {
NavigationStack {
Button(action: {
showView.toggle()
}, label: {
Text("Show view")
})
.navigationDestination(isPresented: $showView, destination: {
ScrollableView() // Putting the scroll view here directly seems to be fine
})
}
}
}
struct ScrollableView: View {
var body: some View {
ScrollView {
HTMLText(text: "hello <em> world </em>")
}
}
}
And this causes a complete crash.
struct Crash: View {
var body: some View {
GeometryReader { _ in
HTMLText(text: "hello <em> world </em>")
}
}
}
The exact point where things go wrong is where the string data is attempted to be coded into the attributed string, even though it is wrapped in a try block, the error does not seem to be handled here at all. I've not been able to find other ways to display HTML text, do I have other options? What steps can I take to try debug an attribute graph?
As debug your code, I found out that when access htmlToAttributedString
, you can see an internal function _UIHostingView.requestImmediateUpdate
call. Base on its name, I think its job is to force UI reload immediately. Which will cause a cycle
as show with this logging AttributeGraph: cycle detected through attribute
. As you can see when put debug breakpoint in init
of HTMLText
:
A ScrollableView.body.getter
will lead to another ScrollableView.body.getter
.
To get rid of this problem, my suggestion is to put your htmlToAttributedString
in a DispatchQueue.main.async
:
struct HTMLText: UIViewRepresentable {
var text: String
init(text: String) {
self.text = text // instead of create NSAttributedString, just save a String
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.backgroundColor = .red
uiTextView.textContainerInset = .zero
uiTextView.isEditable = false
uiTextView.isScrollEnabled = false
uiTextView.textContainer.lineFragmentPadding = 0
return uiTextView
}
func updateUIView(_ uiTextView: UITextView, context: Context) {
DispatchQueue.main.async { //<- get rid of cycle
uiTextView.attributedText = self.text.htmlToAttributedString
}
}
}