How can I set a SwiftUI Text
to display rendered HTML or Markdown?
Something like this:
Text(HtmlRenderedString(fromString: "<b>Hi!</b>"))
or for MD:
Text(MarkdownRenderedString(fromString: "**Bold**"))
Perhaps I need a different View?
How can I set a SwiftUI Text
to display rendered HTML or Markdown?
Something like this:
Text(HtmlRenderedString(fromString: "<b>Hi!</b>"))
or for MD:
Text(MarkdownRenderedString(fromString: "**Bold**"))
Perhaps I need a different View?
Text
can just display String
s.
You can use a UIViewRepresentable
with an UILabel
and attributedText
.
Probably attributedText text support will come later for SwiftUI.Text
.
Text now supports basic Markdown!
struct ContentView: View {
var body: some View {
VStack {
Text("Regular")
Text("*Italics*")
Text("**Bold**")
Text("~Strikethrough~")
Text("`Code`")
Text("[Link](https://apple.com)")
Text("***[They](https://apple.com) ~are~ `combinable`***")
}
}
}
Result:
Update: If you store markdown as a String
, it won't render — instead, set the type to be LocalizedStringKey
.
struct ContentView: View {
@State var textWithMarkdown: LocalizedStringKey = "***[They](https://apple.com) ~are~ `combinable`***"
var body: some View {
Text(textWithMarkdown)
}
}
Result:
AttributedString
, you can simply create a LocalizedStringKey
from the string value and initialize the Text
view with that LocalizedStringKey
. i.e. Text(LocalizedStringKey(textWithMarkdown))
–
Stacy Text(.init(yourTextVariable))
. No need for a markdownToAttributed
function. See answer: https://mcmap.net/q/265716/-how-to-make-hyperlinks-in-swiftui –
Blistery If you don't need to specifically use a Text view. You can create a UIViewRepresentable that shows a WKWebView and simple call loadHTMLString().
import WebKit
import SwiftUI
struct HTMLStringView: UIViewRepresentable {
let htmlContent: String
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.loadHTMLString(htmlContent, baseURL: nil)
}
}
In your body simple call this object like this:
import SwiftUI
struct Test: View {
var body: some View {
VStack {
Text("Testing HTML Content")
Spacer()
HTMLStringView(htmlContent: "<h1>This is HTML String</h1>")
Spacer()
}
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}
ScrollView
. Plus, there's a latency of loading (I'm using a local file). –
Cot Since iOS 15, Text
can have an AttributedString
parameter.
No UIViewRepresentable
necessary
Since NSAttributedString
can be created from HTML, the process is straight forward:
import SwiftUI
@available(iOS 15, *)
struct TestHTMLText: View {
var body: some View {
let html = "<h1>Heading</h1> <p>paragraph.</p>"
if let nsAttributedString = try? NSAttributedString(data: Data(html.utf8), options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil),
let attributedString = try? AttributedString(nsAttributedString, including: \.uiKit) {
Text(attributedString)
} else {
// fallback...
Text(html)
}
}
}
@available(iOS 15, *)
struct TestHTMLText_Previews: PreviewProvider {
static var previews: some View {
TestHTMLText()
}
}
The code renders this:
Be aware that NSAttributedString
only works in main task. That's no problem in the code above, but if you move things in a model, you have to take care.
Text
though. For example .font
–
Melosa Since I have found another solution I would like to share it with you.
Create a new View Representable
struct HTMLText: UIViewRepresentable {
let html: String
func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
let label = UILabel()
DispatchQueue.main.async {
let data = Data(self.html.utf8)
if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
label.attributedText = attributedString
}
}
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {}
}
And use it later like this:
HTMLText(html: "<h1>Your html string</h1>")
You can try to use the package https://github.com/iwasrobbed/Down, generate HTML or MD from you markdown string, then create a custom UILabel subclass and make it available to SwiftUI like in the following example:
struct TextWithAttributedString: UIViewRepresentable {
var attributedString: NSAttributedString
func makeUIView(context: Context) -> ViewWithLabel {
let view = ViewWithLabel(frame: .zero)
return view
}
func updateUIView(_ uiView: ViewWithLabel, context: Context) {
uiView.setString(attributedString)
}
}
class ViewWithLabel : UIView {
private var label = UILabel()
override init(frame: CGRect) {
super.init(frame:frame)
self.addSubview(label)
label.numberOfLines = 0
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setString(_ attributedString:NSAttributedString) {
self.label.attributedText = attributedString
}
override var intrinsicContentSize: CGSize {
label.sizeThatFits(CGSize(width: UIScreen.main.bounds.width - 50, height: 9999))
}
}
I have kind of success with that but cannot get the frame of the label subclass right. Maybe I need to use GeometryReader for that.
TextWithAttributedString(attributedString:"# Hello SwiftUI")
but in the meantime I switched to another approach wich actually displays something but is also not optimal yet. If I make real progress I'll post a new answer here. –
Coomer Some people advise to use WKWebView or UILabel, but these solutions are terribly slow or inconvenient. I couldn't find a native SwiftUI solution, so I implemented my own (AttributedText). It's quite simple and limited in its functionality, but it works quickly and satisfies my needs. You can see all features in the README.md file. Feel free to contribute if the existing functionality is not enough for you.
Code example
AttributedText("This is <b>bold</b> and <i>italic</i> text.")
Result
Swift 5.7 brought new functionalities related to regex. A new RegexBuilder
was implemented in addition to the existing regex support, and that makes it easier to extrapolate the strings in HTML tags.
With little work, we can build a converter from "basic" HTML codes to markdown. By "basic" I mean:
Of course, with more effort, anything can be achieved, but I'm going to stick with the basic example.
The String
extension:
extension String {
func htmlToMarkDown() -> String {
var text = self
var loop = true
// Replace HTML comments, in the format <!-- ... comment ... -->
// Stop looking for comments when none is found
while loop {
// Retrieve hyperlink
let searchComment = Regex {
Capture {
// A comment in HTML starts with:
"<!--"
ZeroOrMore(.any, .reluctant)
// A comment in HTML ends with:
"-->"
}
}
if let match = text.firstMatch(of: searchComment) {
let (_, comment) = match.output
text = text.replacing(comment, with: "")
} else {
loop = false
}
}
// Replace line feeds with nothing, which is how HTML notation is read in the browsers
var text = self.replacing("\n", with: "")
// Line breaks
text = text.replacing("<div>", with: "\n")
text = text.replacing("</div>", with: "")
text = text.replacing("<p>", with: "\n")
text = text.replacing("<br>", with: "\n")
// Text formatting
text = text.replacing("<strong>", with: "**")
text = text.replacing("</strong>", with: "**")
text = text.replacing("<b>", with: "**")
text = text.replacing("</b>", with: "**")
text = text.replacing("<em>", with: "*")
text = text.replacing("</em>", with: "*")
text = text.replacing("<i>", with: "*")
text = text.replacing("</i>", with: "*")
// Replace hyperlinks block
loop = true
// Stop looking for hyperlinks when none is found
while loop {
// Retrieve hyperlink
let searchHyperlink = Regex {
// A hyperlink that is embedded in an HTML tag in this format: <a... href="<hyperlink>"....>
"<a"
// There could be other attributes between <a... and href=...
// .reluctant parameter: to stop matching after the first occurrence
ZeroOrMore(.any)
// We could have href="..., href ="..., href= "..., href = "...
"href"
ZeroOrMore(.any)
"="
ZeroOrMore(.any)
"\""
// Here is where the hyperlink (href) is captured
Capture {
ZeroOrMore(.any)
}
"\""
// After href="<hyperlink>", there could be a ">" sign or other attributes
ZeroOrMore(.any)
">"
// Here is where the linked text is captured
Capture {
ZeroOrMore(.any, .reluctant)
}
One("</a>")
}
.repetitionBehavior(.reluctant)
if let match = text.firstMatch(of: searchHyperlink) {
let (hyperlinkTag, href, content) = match.output
let markDownLink = "[" + content + "](" + href + ")"
text = text.replacing(hyperlinkTag, with: markDownLink)
} else {
loop = false
}
}
return text
}
}
Usage:
HTML text:
let html = """
<div>You need to <b>follow <i>this</i> link</b> here: <a href="https://example.org/en">sample site</a></div>
"""
Markdown conversion:
let markdown = html.htmlToMarkDown()
print(markdown)
// Result:
// You need to **follow *this* link** here: [sample site](https://example.org/en)
In SwiftUI:
Text(.init(markdown))
What you see:
iOS 15 Supports Basic Markdown, but it does not include headings or images. Here is an answer if you want to include basic headings & images in text:
Text("Body of text here with **bold** text") // This will work as expected
But:
let markdownText = "Body of text here with **bold** text".
Text(markdownText) // This will not render the markdown styling
But you can fix that by doing:
Text(.init(markdownText)) // This will work as expected, but you won't see the headings formatted
BUT SwiftUI markdown doesn't support the headings (#, ##, ###, etc.) so if you want "# heading \nBody of text here with **bold** text"
everything will render properly, minus the heading, you will still see "# heading".
So one solution is to break the string into lines, and implement a ForEach
loop to check for the headings prefix (#), drop the #
, and and create a Text()
element with the appropriate styling like so:
let lines = blogPost.blogpost.components(separatedBy: .newlines)
VStack(alignment: .leading) {
ForEach(lines, id: \.self) { line in
if line.hasPrefix("# ") {
Text(line.dropFirst(2))
.font(.largeTitle)
.fontWeight(.heavy)
} else if line.hasPrefix("## ") {
Text(line.dropFirst(3))
.font(.title)
.fontWeight(.heavy)
} else if line.hasPrefix("### ") {
Text(line.dropFirst(4))
.font(.headline)
.fontWeight(.heavy)
} else {
Text(.init(line))
.font(.body)
}
}
}
This will create a well formated markdown text including headings.
If we want to also add images, first we can create an extension on the URL property:
extension URL {
func isImage() -> Bool {
let imageExtensions = ["jpg", "jpeg", "png", "gif"]
return imageExtensions.contains(self.pathExtension.lowercased())
}
}
This method checks if the URL's path extension is one of the common image file extensions (jpg, jpeg, png, or gif) and returns true if it is.
Then, we can alter the ForEach loop like so:
let lines = blogPost.blogpost.components(separatedBy: .newlines)
ForEach(lines, id: \.self) { line in
if line.hasPrefix("# ") {
Text(line.dropFirst(2))
.font(.largeTitle)
.fontWeight(.heavy)
} else if line.hasPrefix("## ") {
Text(line.dropFirst(3))
.font(.title)
.fontWeight(.heavy)
} else if line.hasPrefix("### ") {
Text(line.dropFirst(4))
.font(.headline)
.fontWeight(.heavy)
} else if let imageUrl = URL(string: line), imageUrl.isImage() {
// If the line contains a valid image URL, display the image
AsyncImage(url: imageUrl) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
Text("Failed to load image")
@unknown default:
fatalError()
}
}
} else {
Text(line)
.font(.body)
}
}
In this updated code, we're checking if the line contains a valid image URL by attempting to create a URL object from the line using URL(string: line) and then calling a custom extension method isImage() on the resulting URL to check if it points to an image.
If the line contains a valid image URL, we use the AsyncImage view to load the image asynchronously from the URL. The AsyncImage view automatically handles loading and caching of the image and provides a placeholder ProgressView while the image is being loaded. Once the image is loaded, we display it using the Image view with the resizable() and aspectRatio(contentMode: .fit) modifiers to resize and scale the image appropriately. If the image fails to load for some reason, we display an error message instead.
Text
can just display String
s.
You can use a UIViewRepresentable
with an UILabel
and attributedText
.
Probably attributedText text support will come later for SwiftUI.Text
.
As far as rendering HTML in swiftUI there are a number of solutions, but for rendering it as a generic UILabel via AttributedText, this is what I went with after combining a few other solutions I found.
Here is the UIViewRepresentable which you'll use from your parent swiftUI views:
//Pass in your htmlstring, and the maximum width that you are allowing for the label
//this will, in turn, pass back the size of the newly created label via the binding 'size' variable
//you must use the new size variable frame on an encompassing view of wherever this htmlAttributedLabel now resides (like in an hstack, etc.)
struct htmlAttributedLabel: UIViewRepresentable {
@Binding var htmlText: String
var width: CGFloat
@Binding var size:CGSize
var lineLimit = 0
//var textColor = Color(.label)
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byWordWrapping
label.numberOfLines = lineLimit
label.preferredMaxLayoutWidth = width
//label.textColor = textColor.uiColor()
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
let htmlData = NSString(string: htmlText).data(using: String.Encoding.unicode.rawValue)
let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
DispatchQueue.main.async {
do {
let attributedString = try NSMutableAttributedString(data: htmlData!, options: options, documentAttributes: nil)
//add attributedstring attributes here if you want
uiView.attributedText = attributedString
size = uiView.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
print("htmlAttributedLabel size: \(size)")
} catch {
print("htmlAttributedLabel unexpected error: \(error).")
}
}
}
Now, to use this label effectively, you'll need to provide it a maximum width, which you can get from geometry reader. You'll also need to pass in a CGSize binding so the label can tell the parent view how much space it needs to render. You'll in turn use this size to set an encompassing view height, so that the rest of swiftUI can layout around your html label appropriately:
@State var htmlText = "Hello,<br />I am <b>HTML</b>!"
@State var size:CGSize = .zero
var body: some View {
HStack {
GeometryReader { geometry in
htmlAttributedLabel(htmlText: $htmlText, width: geometry.size.width, size: $size).frame(width:size.width, height: size.height). //the frame is important to set here, otherwise sometimes it won't render right on repeat loads, depending on how this view is presented
}
}.frame(height: size.height) //most important, otherwise swiftui won't really know how to layout things around your attributed label
}
You can also set line limits, or text color, etc., and obviously you can extend this object to take in whatever UIlabel parameters you'd like to use.
This is a simple extension that uses AttributedString
extension String {
var htmlToNSAttributed: 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)
}
}
var htmlToString: String {
htmlToNSAttributed.string
}
var htmlToAttributed: AttributedString {
do {
return try AttributedString(htmlToNSAttributed, including: \.swiftUI)
} catch {
return AttributedString(stringLiteral: self)
}
}
}
Usage
Text(text.htmlToAttributed)
Late to the party, but I found a solution that also works for iOS 14 without UIViewRepresentable and without having to check the iOS verion.
You simply have to create an extension for Text to add support for NSAttributedString. You can copy the extension from here:
extension Text {
init(_ astring: NSAttributedString) {
self.init("")
astring.enumerateAttributes(in: NSRange(location: 0, length: astring.length), options: []) { (attrs, range, _) in
var t = Text(astring.attributedSubstring(from: range).string)
if let color = attrs[NSAttributedString.Key.foregroundColor] as? UIColor {
t = t.foregroundColor(Color(color))
}
if let font = attrs[NSAttributedString.Key.font] as? UIFont {
t = t.font(.init(font))
}
if let kern = attrs[NSAttributedString.Key.kern] as? CGFloat {
t = t.kerning(kern)
}
if let striked = attrs[NSAttributedString.Key.strikethroughStyle] as? NSNumber, striked != 0 {
if let strikeColor = (attrs[NSAttributedString.Key.strikethroughColor] as? UIColor) {
t = t.strikethrough(true, color: Color(strikeColor))
} else {
t = t.strikethrough(true)
}
}
if let baseline = attrs[NSAttributedString.Key.baselineOffset] as? NSNumber {
t = t.baselineOffset(CGFloat(baseline.floatValue))
}
if let underline = attrs[NSAttributedString.Key.underlineStyle] as? NSNumber, underline != 0 {
if let underlineColor = (attrs[NSAttributedString.Key.underlineColor] as? UIColor) {
t = t.underline(true, color: Color(underlineColor))
} else {
t = t.underline(true)
}
}
self = self + t
}
}
}
Here is how to convert your HTML String to an NSAttributedString: Convert HTML to NSAttributedString in iOS
For rendering HTML, I use extension of String for convert to Attributed HTML String and extension of UIColor for working with hex color
extension String {
func htmlAttributedString(
fontSize: CGFloat = 16,
color: UIColor = UIColor(Color.theme.body),
linkColor: UIColor = UIColor(Color.theme.primary),
fontFamily: String = "Roboto"
) -> NSAttributedString? {
let htmlTemplate = """
<!doctype html>
<html>
<head>
<link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
<style>
body {
color: \(color.hexString!);
font-family: \(fontFamily);
font-size: \(fontSize)px;
}
a {
color: \(linkColor.hexString!);
}
</style>
</head>
<body>
\(self)
</body>
</html>
"""
guard let data = htmlTemplate.data(using: .unicode) else {
return nil
}
guard let attributedString = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil
) else {
return nil
}
return attributedString
}
}
extension UIColor {
var hexString:String? {
if let components = self.cgColor.components {
let r = components[0]
let g = components[1]
let b = components[2]
return String(format: "#%02x%02x%02x", (Int)(r * 255), (Int)(g * 255), (Int)(b * 255))
}
return nil
}
}
And use it later like this:
import SwiftUI
struct ContentView: View {
@State var htmlText = """
<a href="example.com">Example</a>
"""
var body: some View {
if let nsAttrString = htmlText.htmlAttributedString() {
Text(AttributedString(nsAttrString))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
© 2022 - 2024 — McMap. All rights reserved.