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?
iOS 15 (beta)
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:
However, if you store a String that contains Markdown in a property, it doesn't render. I'm pretty sure this is a bug.
struct ContentView: View {
#State var textWithMarkdown = "***[They](https://apple.com) ~are~ `combinable`***"
var body: some View {
Text(textWithMarkdown)
}
}
Result:
You can work around this by converting textWithMarkdown to an AttributedString, using init(markdown:options:baseURL:).
struct ContentView: View {
#State var textWithMarkdown = "***[They](https://apple.com) ~are~ `combinable`***"
var body: some View {
Text(textWithMarkdown.markdownToAttributed()) /// pass in AttributedString to Text
}
}
extension String {
func markdownToAttributed() -> AttributedString {
do {
return try AttributedString(markdown: self) /// convert to AttributedString
} catch {
return AttributedString("Error parsing markdown: \(error)")
}
}
}
Result:
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()
}
}
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.
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
Text can just display Strings.
You can use a UIViewRepresentable with an UILabel and attributedText.
Probably attributedText text support will come later for SwiftUI.Text.
I created a markdown library specifically for SwiftUI:
https://github.com/Lambdo-Labs/MDText
Feel free to contribute!
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.
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:
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 = """
Example
"""
var body: some View {
if let nsAttrString = htmlText.htmlAttributedString() {
Text(AttributedString(nsAttrString))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
New since Swift 5.7 - convert from "basic" HTML
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:
they contain line breaks, bold, italic (no attributes)
they can contain hyperlinks, and that's the complicated part of the converter below
they do not contain headers, scripts, id attributes...
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: sample site</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:
Related
I have an app that has image data saved in CoreData and I need to include those images on internally generated web pages generated from the CoreData info. The following app demonstrates my problem exclusive of the core data for simplicity. I can't figure out how to reference the local temp file with the image. If there is a way to do this without a temp file directly from the CoreData data, that would be even better.
import SwiftUI
struct ContentView: View
{
#State var html = "<html><body>Image below<br><img src='+++' width='400'></body></html>"
let img = Image("pic") // any pic as an asset for demo purposes
var body: some View
{
VStack
{
img.resizable().aspectRatio(contentMode: .fit)
Button("Click")
{
do
{
let image = UIImage(named: "pic")
let tmp = FileManager.default.temporaryDirectory
let file = tmp.appendingPathComponent("myfile").appendingPathExtension("png")
if let data = image?.pngData()
{
try data.write(to: file)
}
html = html.replacingOccurrences(of: "+++", with: file.absoluteString)
print(html)
}
catch
{
print(error.localizedDescription)
}
}
WebView(text: html)
TextField("html", text: $html)
}
}
}
Don't know the answer to the actual question about using a temp file but just ended up embedding the image in the url as base64 data:
import SwiftUI
struct ContentView: View
{
#State var html = "<html><body>Image below<br><img src='+++' width='400'/></body></html>"
let img = Image("pic")
var body: some View
{
VStack
{
img.resizable().aspectRatio(contentMode: .fit)
Button("Click")
{
if let image = UIImage(named: "pic")
{
if let data = image.pngData()
{
let ttxt = "data:image/png;base64," + data.base64EncodedString()
html = html.replacingOccurrences(of: "+++", with: ttxt)
}
}
}
WebView(text: html)
TextField("html", text: $html)
}
}
}
I want to create a vocabulary app with pronounce. Because I am a fresh to swiftUI and try to self-learning, so I am try to watch online coding and modify. I have create a local Json file with a "LearnMode" view, and also I take a reference of text to speech coding example online to made a "soundicon" view, but I have no idea that how I can make them work together. Currently I make each " trumpet“ icon with an "apple" pronounce, I don't know how I can attached each of the Json word to pronounce, I think the way are pretty easy, but I cannot figure out. Here are 2 swiftUI file coding, many thanks for your help:
//LearnMode.swift
import SwiftUI
struct Model1: Codable,Identifiable{
enum CodingKeys: CodingKey{
case word
case interpretation
}
var id = UUID()
var word,interpretation:String
}
class Json1: ObservableObject{
#published var json = Model1
init(){
load()
}
func load(){
let path = Bundle.main.path(forResource:"data",ofType:"json")
let url = URL(fileURLWithPath: path!)
URLSession.shared.dataTask(with: url){(data,response,error) in
do{
if let data = data {
let json = try JSONDecoder().decode([Model1].self, from:data)
DispatchQueue.main.sync{
self.json = json
}
}else{
print("No data")
}
} catch {
print(error)
}
}.resume()
}
}
struct LearnMode: View {
#ObservedObject var datas = Json1()
var body: some View {
NavigationView{ZStack{
List(datas.json){item in
HStack{
Text(item.word)
.font(.system(size: 16))
Spacer()
Text(item.interpretation)
.multilineTextAlignment(.leading)
.frame(width: 50, height: 60)
.font(.system(size: 12))
.foregroundColor(Color.gray)
SoundIcon()
}
}
}
.navigationTitle("IELTS")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct LearnMode_Previews: PreviewProvider {
static var previews: some View {
LearnMode()
}
}
// SoundIcon.swift
import SwiftUI
import AVFoundation
struct SoundIcon: View {
var body: some View {
Button(){
let utterance = AVSpeechUtterance(string: "apple")
utterance.voice = AVSpeechSynthesisVoice(language:"en-US")
utterance.rate = 0.5
let synthesizer = AVSpeechSynthesizer()
synthesizer.speak(utterance)
}label:{
Image(systemName: "speaker.wave.1")
}
}
}
struct SoundIcon_Previews: PreviewProvider {
static var previews: some View {
SoundIcon()
}
}
Having trouble figuring out why code won’t display default value when variable is nil. Here’s the context below. Any pointers would be greatly appreciated.
Thanks!
EXAMPLE OF DATA FROM JSON API:
NOTE: image_url is just the base name, not the full path or file extension.
[
{
"id": 1,
"title": "Autumn in New York",
"image_url": ""
}
]
DATA MODEL:
import Foundation
struct Challenge: Codable, Hashable, Identifiable {
let id: Int
let title: String
let imageURL: String?
private enum CodingKeys: String, CodingKey {
case id
case title
case imageURL = "image_url"
}
}
CODE FOR VIEW AND VIEW MODEL:
import SwiftUI
struct JSONChallengeV2: View {
#State private var challenge: [Challenge] = []
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 5) {
ScrollView(.horizontal, showsIndicators: true) {
HStack() {
ForEach (challenge) { challenge in
NavigationLink(
destination:
PlayerView(),
label: {
// PROBLEMS OCCUR IN THIS VIEW (see view code below)
JSONChallengeRowView(challenge: challenge)
})
}
}
}
}
.onAppear {
getData()
}
}
}
func getData() {
let url = URL(string: "https://example.com/jsonapi") // EXAMPLE ONLY
URLSession.shared.dataTask(with: url!) { (data, response, error) in
guard error == nil else {
print(error?.localizedDescription ?? "")
return
}
guard data != nil else {
print("No data")
return
}
let decoder = JSONDecoder()
do {
let loaded = try decoder.decode([Challenge].self, from: data!)
challenge = loaded
} catch {
print("Can't decode data")
}
}.resume()
}
}
CODE FOR SUB-VIEW ("JSONChallengeRowView" referenced in above view):
import SwiftUI
struct JSONChallengeRowView: View {
var challenge: Challenge
var body: some View {
let thumbnailPrefix = "https://example.com/" // EXAMPLE ONLY
let thumbnailSuffix = "-001.jpg"
VStack(alignment: .leading) {
// WORKS: Hardcoding a known image base (i.e., "autumn-default":
RemoteImageView(url: ("\(thumbnailPrefix)\(String(describing: "autumn-default"))\(thumbnailSuffix)"))
.scaledToFit()
.cornerRadius(10)
Link("Go", destination: (URL(string: "\(thumbnailPrefix)\("autumn-default")\(thumbnailSuffix)") ?? URL(string: "https://google.com"))!)
// DOESN'T WORK: build succeeds but no default image appears when no "imageURL" value can be found:
RemoteImageView(url: ("\(thumbnailPrefix)\(String(describing: challenge.imageURL ?? "autumn-default" ))\(thumbnailSuffix)"))
.scaledToFit()
.cornerRadius(10)
Link("Go", destination: URL(string: "\(thumbnailPrefix)\(String(describing: challenge.imageURL ?? "autumn-default"))\(thumbnailSuffix)")!)
// AND WHILE THESE WORK:
Text("\(challenge.title)")
Text(challenge.title)
// THESE SIMILARLY DISPLAY NOTHING (despite values in the "title" variable, used as a default value here for testing only):
Text("\(challenge.imageURL ?? challenge.title)")
Text(challenge.imageURL ?? challenge.title)
}
}
}
Nick's point about image_url containing an empty string, not nil, pointed me in the right direction, and with a tip from my Swift learner's forum elsewhere, I used this modification to fix the sections that weren't working:
challenge.imageURL.isEmpty ? "autumn-default" : "\(challenge.imageURL)"
For example, here:
RemoteImageView(url: "\(thumbnailPrefix)" + (challenge.imageURL.isEmpty ? "autumn-default" : "\(challenge.imageURL)") + "\(thumbnailInfix)" + (challenge.imageURL.isEmpty ? "autumn-default" : "\(challenge.imageURL)") + "\(thumbnailSuffix)")
I also applied this syntax to the Link and Text views successfully.
Thanks again, everyone!
I have a string that contains HTML code. What is the best way to display that (it contains images), also I want to make links in that tappable (open in Safari)
I have tried String extension that gives me NSAttributedString from HTML, but the image is only partially shown and links are not tappable.
let text = htmlString.attributedString(withRegularFont: UIFont.systemFont(ofSize: 14), andBoldFont: UIFont.systemFont(ofSize: 16))
extension String {
func attributedString(withRegularFont regularFont: UIFont, andBoldFont boldFont: UIFont, textColor: UIColor = UIColor.gray) -> NSMutableAttributedString {
var attributedString = NSMutableAttributedString()
guard let data = self.data(using: .utf8) else { return NSMutableAttributedString() }
do {
attributedString = try NSMutableAttributedString(data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding:String.Encoding.utf8.rawValue],
documentAttributes: nil)
let range = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttribute(NSAttributedString.Key.font, in: range, options: .longestEffectiveRangeNotRequired) { value, range, _ in
let currentFont: UIFont = value as! UIFont
var replacementFont: UIFont? = nil
if currentFont.fontName.contains("bold") || currentFont.fontName.contains("Semibold") {
replacementFont = boldFont
} else {
replacementFont = regularFont
}
let replacementAttribute = [NSAttributedString.Key.font:replacementFont!, NSAttributedString.Key.foregroundColor: textColor]
attributedString.addAttributes(replacementAttribute, range: range)
} catch let e {
print(e.localizedDescription)
}
return attributedString
}
}
It shows me the HTML inside the UILabel but I am not able to tap on links and images are cropped respective to device width.
I think that the best option is to save this html string as a file and then load this file using web view.
check this question
I have searched a lot but can only find HTML to plain text, not the other way around, I have email implementation in my app, thus need to send the content of email as HTML to the backend.
Edit 1: I have rich text that includes bold, italic, ordered/unordered list, underlined words.
If you are looking to convert NSAttributedString to String, here is the extension method you are looking for. Simply call yourAttributtedString.htmlString() and print it out.
extension NSAttributedString {
func htmlString() -> String? {
let documentAttributes = [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType]
do {
let htmlData = try self.data(from: NSMakeRange(0, self.length), documentAttributes:documentAttributes)
if let htmlString = String(data:htmlData, encoding:String.Encoding.utf8) {
return htmlString
}
}
catch {}
return nil
}
}
According to this post:
private func getHtmlLabel(text: String) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.attributedString = stringFromHtml(string: text)
return label
}
private func stringFromHtml(string: String) -> NSAttributedString? {
do {
let data = string.data(using: String.Encoding.utf8, allowLossyConversion: true)
if let d = data {
let str = try NSAttributedString(data: d,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil)
return str
}
} catch { }
return nil
}