如何在SwiftUI的Text中显示HTML或Markdown?

45

我该如何设置SwiftUI的Text以显示渲染后的HTML或Markdown?

类似这样:

Text(HtmlRenderedString(fromString: "<b>Hi!</b>"))
或者对于MD:
Text(MarkdownRenderedString(fromString: "**Bold**"))

也许我需要一个不同的视角?

14个回答

40

iOS 15

现在支持基本的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`***")
        }
    }
}

结果:

Markdown result


更新: 如果您将Markdown存储为String,它不会呈现 - 相反,请将类型设置为LocalizedStringKey

struct ContentView: View {
    @State var textWithMarkdown: LocalizedStringKey = "***[They](https://apple.com) ~are~ `combinable`***"

    var body: some View {
        Text(textWithMarkdown)
    }
}

结果:

Markdown rendered


太好了!但是,如果你把一个包含Markdown的字符串放在变量中,它就不起作用了!有解决方案吗,还是只是要报告一个错误? - gundrabur
1
@gundrabur 很可能是一个 bug(我记得有人在 WWDC21 数字休息室里问过这个问题)。请看我的编辑以获取解决方法。 - aheze
3
@aheze Markdown仅适用于字符串字面量,这是有意为之的,请参阅此推文 - George
12
为了解决存储的字符串无法转换为Markdown的问题,可以不将其转换为“AttributedString”,而是从字符串值创建一个“LocalizedStringKey”,并使用该“LocalizedStringKey”初始化“Text”视图。即:Text(LocalizedStringKey(textWithMarkdown)) - RanLearns
6
我通过使用 Text(.init(yourTextVariable)) 解决了这个问题。不需要 markdownToAttributed 函数。请参考答案:https://dev59.com/L1MH5IYBdhLWcg3w4E7k#69898689 - Jacob Ahlberg

29

如果您不需要特别使用Text视图,您可以创建一个UIViewRepresentable来显示WKWebView并简单地调用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)
    }
}
在您的代码中,只需像这样简单调用该对象:
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()
    }
}

嗨@DJ, 它在我的项目上运作良好,我已经更新了我的答案,并提供了一个完整的SwiftUI文件。 我的意思是,你在“预览屏幕”上看不到任何东西,但如果你按播放键就会工作。 如果我回答了你的问题,请告诉我。 - Tomas
2
谢谢您的回复,它对它也起作用了,但在列表中却没有。我相信这可能是列表大小的问题。我会进一步调查它。 - DJ-
@DJ- 我尝试使用UIViewRepresentable属性的多行文本。我能够获取带属性和多行文本标签,以设置preferredMaxLayoutWidth从GeometryReader宽度。但是在列表项大小调整时出现问题,文本会重叠在其他项目上。如果您找到解决方案,请添加答案,提前致谢。 - Rohit Wankhede
可能和DJ一样,我在ScrollView中遇到了问题。而且还存在加载延迟(我使用的是本地文件)。 - Chris Prince
1
这里有更改。对我来说已经修复了。https://developer.apple.com/forums/thread/653935 - Chris Prince
显示剩余3条评论

10

我找到了另一个解决方案,我想与您分享。

创建一个新的视图表示器

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) {}
}

然后像这样稍后使用:

HTMLText(html: "<h1>Your html string</h1>")

如何增加字体大小? - Di Nerd Apps
嗨@DiNerd,在NSAttributedString的参数“options:”中,您应该添加一个新选项来设置字体,就像这样:NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .font: UIFont.boldSystemFont(ofSize: 36)], documentAttributes: nil) - Tomas
当文本无法适应一行时,您会使用谁?我添加了这些行,但它没有起作用:label.lineBreakMode = .byWordWrapping,label.numberOfLines = 0 - Ramis
嗨@Ramis,请查看此答案,我认为可能会有所帮助https://dev59.com/flMH5IYBdhLWcg3wvyE4#58474880 - Tomas
太好了,谢谢!我发现标签的宽度有问题,它在水平方向上扩展而不是垂直方向。原来是因为标签在ScrollView内部。如果其他人遇到同样的问题,这里的答案可以帮助解决:https://dev59.com/0FIG5IYBdhLWcg3w0089#62788230 - mota

9
自从 iOS 15 开始,Text 可以有一个 AttributedString 参数。
不需要使用 UIViewRepresentable 由于 NSAttributedString 可以从 HTML 创建,所以这个过程很简单:
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()
    }
}

该代码生成以下内容:

Rendered HTML example


尽管如此,您不能将SwiftUI参数应用于生成的Text。例如.font - meowmeowmeow
这个解决方案是唯一能够起作用并让我使用其他语言的方案。唯一需要修改的是将 Data(html.utf8) 改为 html.data(using: .utf16)! - TheMason
然而,在标题和段落之间缺少一个换行符。 - undefined

6
您可以尝试使用包https://github.com/iwasrobbed/Down,从您的Markdown字符串生成HTML或MD,然后创建一个自定义的UILabel子类,并使它在SwiftUI中可用,如以下示例所示:
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))
    }
}

我在这方面取得了一定的成功,但无法正确获取标签子类的框架。也许我需要使用 GeometryReader 来解决。


请问您能否举个例子来说明如何使用您的代码?我曾尝试过这个但没有成功: TextWithAttributedString(attributedString: DownView(frame: .zero, markdownString: "").accessibilityAttributedValue!) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - Mauricio Zárate
请问我们应该如何称呼这个东西?我们可以直接说TextWithAttributedString(attributedString:"<div>Hello check</div>")吗? - DJ-
1
是的,本意是使用TextWithAttributedString(attributedString:"# Hello SwiftUI")来调用它,但与此同时,我转而采用了另一种方法,虽然可以显示一些内容,但仍不够理想。如果我有真正的进展,我会在这里发布新答案。 - blackjacx
@blackjacx - 我尝试使用UIViewRepresentable属性多行文本。我能够获得带属性和多行文本标签。将标签的preferredMaxLayoutWidth从GeometryReader宽度设置。但是列表项大小的问题是文本会重叠在其他项上。如果您找到解决方案,请添加答案,提前致谢。 - Rohit Wankhede
@blackjacx 这并不会将 MD 或 HTML 转换 - 它只是在标签中输出原始字符串 - 我错过了什么? - daihovey
没事了,我刚刚找到了 down.toAttributedString() :) - daihovey

6

有些人建议使用WKWebViewUILabel,但这些解决方案非常缓慢或不方便。我找不到原生的SwiftUI解决方案,所以我实现了自己的(AttributedText)。它相当简单且功能有限,但它可以快速工作并满足我的需求。您可以在README.md文件中查看所有功能。如果现有功能不足,请随时进行贡献。

代码示例

AttributedText("This is <b>bold</b> and <i>italic</i> text.")

结果

示例


完美。但不支持 <br>。 - chitgoks
我喜欢这个项目 - 轻量级和完美! - Gergely Kovacs
我已经苦恼了几个星期,一直在和HTML以及整个噩梦般的情景作斗争。这个软件包完美地运行,并且拯救了我免于彻底崩溃。非常感谢你。 - user1204493

5

Text 可以仅显示 String

您可以使用 UIViewRepresentableUILabelattributedText

可能会在以后为 SwiftUI.Text 添加 attributedText 支持。


是的,但链接无法点击,出了什么问题? - sony

4

iOS 15支持基本的Markdown,但不包括标题或图片。如果您想在文本中包含基本标题和图片,以下是一个答案:

Text("Body of text here with **bold** text") // This will work as expected

但是:

let markdownText = "Body of text here with **bold** text".
Text(markdownText) // This will not render the markdown styling

但你可以通过以下方式修复:

Text(.init(markdownText)) // This will work as expected, but you won't see the headings formatted

但 SwiftUI 的 markdown 不支持标题(#、##、###等),因此,如果您想要 "# 标题 \n这里是正文,带有 **粗体** 文本",除了标题以外,一切都会正确呈现,你仍然会看到 "# 标题"。

因此,一个解决方案是将字符串分成几行,并实现一个 ForEach 循环来检查标题前缀(#),删除 #,并创建一个带有适当样式的 Text() 元素,就像这样:

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)
                                    }
                                }
}

这将创建一个包含标题的格式良好的Markdown文本。

如果我们想要添加图片,首先可以在URL属性上创建一个扩展:

extension URL {
func isImage() -> Bool {
    let imageExtensions = ["jpg", "jpeg", "png", "gif"]
    return imageExtensions.contains(self.pathExtension.lowercased())
}
}

该方法检查URL的路径扩展名是否为常见的图像文件扩展名之一(jpg、jpeg、png或gif),如果是,则返回true。

然后,我们可以将ForEach循环修改如下:

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)
}
}

在这个更新的代码中,我们通过尝试使用URL(string: line)创建一个URL对象并调用自定义扩展方法isImage()来检查它是否指向一张图片,以检查该行是否包含有效的图像URL。
如果该行包含有效的图像URL,我们使用AsyncImage视图从URL异步加载图像。AsyncImage视图自动处理图像的加载和缓存,并在加载图像时提供占位符ProgressView。一旦图像加载完成,我们使用Image视图显示它,并使用resizable()和aspectRatio(contentMode: .fit)修饰符适当地调整和缩放图像。如果由于某种原因图像无法加载,我们将显示错误消息。

4

自Swift 5.7以来的新功能 - 转换“基本”HTML

Swift 5.7引入了与正则表达式相关的新功能。除了现有的正则表达式支持外,还实现了一个新的RegexBuilder,这使得从HTML标记中推断字符串变得更容易。

通过一些简单的工作,我们可以构建一个从“基本”HTML代码到Markdown的转换器。所谓“基本”,是指:

  • 它们包含换行符、粗体、斜体(没有属性)
  • 它们可以包含超链接,这是下面转换器中复杂的部分
  • 它们不包含标题、脚本、id属性等

当然,付出更多的努力,任何事情都可以实现,但我将坚持基本示例。

String扩展:


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
    }
}

使用方法:

HTML 文本:

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 转换:

let markdown = html.htmlToMarkDown()
print(markdown)

// Result:
// You need to **follow *this* link** here: [sample site](https://example.org/en)

在SwiftUI中:
Text(.init(markdown))

你所看到的:

enter image description here


2

你还在维护这个项目吗?我可能会为它做出贡献,但目前它在iOS上无法编译,并且有一个修复它的拉取请求等待合并。 - Brett

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接