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:
I tried to load a html string in my webview with the code :
let htmlFile = Bundle.main.path(forResource: "code", ofType: "html")
let html = try! String(contentsOfFile: htmlFile!, encoding: String.Encoding.utf8)
self.weview.loadHTMLString(html, baseURL: nil)
I run the app, everything works fine, but if i press on a website link, i get the error in my AppDelegate: Thread 1: EXC_BAD_ACCESS (code=1, address=0x10)
Can someone give me a code for swift 4, where I can load a html string and interact with it? Would be really nice.
First thing to check - go to Project Settings -> Build Phases -> Copy Bundle Resources to make sure your directory with your html-files is on the list there.
Second, load the file the way listed below. You don't have to load the content of the file.
let path = Bundle.main.path(forResource: "code", ofType: "html")!
let uri = URL(string: path)!
let request = URLRequest(url: uri)
webView.loadRequest(request)
Also, make sure forResource param contains full path to the html file starting from your project directory
Try This :
let link = "https://google.co.in"
do {
let htmlStr = try String(contentsOf : URL(string:link)!)
let separateHtmlStr = htmlStr.components(separatedBy:"") // any html tag you want to interact with
let newURL = URL(string: link)
yourWebView.loadHTMLString(separateHtmlStr, baseURL: newURL) // in case if you have changed anything
}catch{
print("Exception")
}
}
I have a WkWebView in my ViewController, I used webview load local html. The code is this:
guard let path = Bundle.main.url(forResource: "editor", withExtension: "html") else {
return
}
webView.load(URLRequest(url: path))
1: I choose a image from photoLibrary, then I saved it to document's directory .
private func fileName() -> String {
let date = Date()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd#HH-mm-ss"
return "/" + formatter.string(from: date) + ".png"
}
func saveImage(_ image: UIImage) {
guard let imageData = UIImageJPEGRepresentation(image, 1.0) else {
return
}
let directory = NSHomeDirectory().appending("/Documents")
let path = directory.appending(fileName())
let fileManager = FileManager.default
do {
try fileManager.createDirectory(atPath: directory, withIntermediateDirectories: true, attributes: nil)
fileManager.createFile(atPath: path, contents: imageData, attributes: nil)
} catch {
SHLog("saveImage: \(error)")
}
}
2: I fetched the image path then I used the method of the webView to insert the image.
func evaluateJs(_ imagePath: String) {
webView.evaluateJavaScript("insertImage('\(imagePath)')") { (response, error) in
}
}
insertImage() is a method from the local js file, it can insert the image to html.
If I used the local imagePath, I couldn't insert image to html. But When I used the real url, it can work. I don't know the reason, anyone could help me. Thanks.
Your issue is likely caused by WKWebViews security policies not allowing you access to the local folders of the application.
Check out the documentation for WKWebView loadFileURL. You should be able to pass either the documents directory or full image URL in the allowingReadAccessTo parameter which should let you access the images you want.
If you have a web url of the image, I would prefer to use the web URL anyway rather than trying to store and load the image locally. Your not using up storage in your documents directory and WKWebView is designed to load content from the web.
In my case, assign baseURL your image file's parent folder firstly. Then insert the whole URL path in your html likes following.
<img src="/var/mobile/Containers/Data/Application/C217F3BD-1A1B-438A-A436-D878411C7849/Documents/48C4B007-A950-46D2-A719-E025482710A0/images/79042B1F-E11C-42D1-8B49-DD3A0016F421.jpg"
I just started to learn Swift and xcode and the first problem that I'm facing is how and where should I place the json file ? And how to use those files? Should I place the .json files within Assets folder ? Since I find it difficult, I would love to hear some tips or examples from you !
You can add an empty file, select syntax coloring as JSON and paste your json text. Even if it is not formatted, you can format it by selecting all the text and pressing Ctrl + I.
How I've done this in September 2019...
1) In Xcode, create an Empty file. Give the file a .json suffix
2) Type in or paste in your JSON
3) Click Editor -> Syntax Coloring -> JSON
4) Inside the file, highlight the JSON, click ctrl + i to indent
5) import SwiftyJSON using Cocoapods
6) In your ViewController, write...
guard let path = Bundle.main.path(forResource: "File", ofType: "json") else { return }
let url = URL(fileURLWithPath: path)
do {
let data = try Data(contentsOf: url)
let json = try JSON(data: data)
} catch {
print(error)
}
N.B. - "File" is the name of the file you created, but excluding the .json suffix
See SwiftyJSON GitHub page for more info - https://github.com/SwiftyJSON/SwiftyJSON
Please review the below image to check where to place the file.
I suggest you to create a group and add the file in that.
After that, review the below could for using that file.
Edit: This is the updated code for Swift 5 if that helps anyone.
let path = Bundle.main.path(forResource: "filename", ofType: "json")
let jsonData = try? NSData(contentsOfFile: path!, options: NSData.ReadingOptions.mappedIfSafe)
var location = "test"
var fileType = "json"
if let path = Bundle.main.path(forResource: location, ofType: fileType) {
do {
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .alwaysMapped)
let jsonObj = JSON(data: data)
if jsonObj != JSON.null {
}
} catch let error {
print(error.localizedDescription)
}}
As per your requirement, you want to read json from that json file.
I am using SWIFTY JSON Library for that.
Find below the link for that
https://github.com/SwiftyJSON/SwiftyJSON
Add this library to your project.
After adding it, now review the below code:-
let json = JSON(data: jsonData!)
for (index, subjson): (String, JSON) in json{
let value = subjson["key"].stringValue
}
I'm building a simple content-blockin app.
It works, but i want to apply filters (which webs to block and which not) with UISwitches (saved to NSUserDefaults).
Because the content blocking extension uses json it's unclear to me how can i select multiple json files to function simultaneously.
Any ideas how it can be achieved? Multiple extensions? Combining and splitting json files somehow?
I have been in same situation. Answer to this is bit tricky, so bear with me. You cannot write to file in bundle i.e blockerList.json is not writeable. Here is what you need to do,
Enable App groups from TARGETS->YOUR MAIN APP -> Capabilities -> App Groups. And add unique identifier for app groups. Do same with extension. (Use same identifier as group name which you entered for main app)
Create a file in Container directory.
Write rules (json) to that file.
Reload extension once you have written rules.
Read rules from Container directory in content blocker extension.
From your main app create file and write json rules into that file as:
let jsonData = try! JSONSerialization.data(withJSONObject: webFilters, options: JSONSerialization.WritingOptions.prettyPrinted)
//Convert back to string. Usually only do this for debugging
if let JSONString = String(data: jsonData, encoding: String.Encoding.utf8) {
let file = "conbo.json"
if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "YOUR_GROUP_IDENTIFIER") {
let path = dir.appendingPathComponent(file)
do {
try JSONString.write(to: path, atomically: false, encoding: String.Encoding.utf8)
let id = "YOUR_CONTENT_BLOCKER_BUNDLE_IDENTIFIER"
SFContentBlockerManager.reloadContentBlocker(withIdentifier: id) {error in
guard error == nil else {
print(error ?? "Error")
return
}
print("Reloaded")
}
}
catch {
}
}
}
Now in extension read file from container as:
class ContentBlockerRequestHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
let file = "conbo.json"
if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_IDENTIFIER") {
let path = dir.appendingPathComponent(file)
do {
do {
let attachment = NSItemProvider(contentsOf: path)!
let item = NSExtensionItem()
item.attachments = [attachment]
context.completeRequest(returningItems: [item], completionHandler: nil)
}
}
}
}
}