written by Talaxy on 2021/4/7

The examples in this article all run on the iPhone 11 Pro

Writing in the front

Markdown is actually an HTML-powered tool that simplifies HTML writing. In fact, Markdown has been very successful. However, on the mobile or desktop side, Markdown is usually rendered using Web framework support. This article will provide a native Markdown rendering method for SwiftUI.

Rendering Markdown is both easy and difficult. First, Markdown has its own set of syntax rules (and basic rendering methods) that help determine program functionality and reduce render error rates. But Markdown has some problems of its own. I found some projects on GitHub that use Javascript to render Markdown in a shell, for example:

Hello **markdown** !
Copy the code

Will be rendered (preliminarily) as:

Hello <strong>markdown</strong> !
Copy the code

Therefore, the correctness of some grammars is difficult to determine, such as the following:

* apple
An apple a day keeps the doctor away.
* pine
* banana
Copy the code

Here the second line uses a reference, but the result is not attached to the first list item as it is, but as a separate row element. Actually using indentation will render the reference correctly.

Therefore, it is important to clarify the grammar. In fact, TO solve the native Markdown rendering, I rebuilt the module nearly 4 times, mostly stuck in the grammar rules.

The functional goals of the renderer

The primary goal of a renderer is to render a String as a View (a bit of crap), but here are some secondary function points:

  • Ability to preprocess text (such as canonical whitespace)
  • The ability to add some (hopefully) custom syntax
  • The ability to customize views for elements

In fact, it is not easy to complete these functions, and I encounter various problems during the writing process, such as:

  • The collision of grammars is a matter of grammar priority
  • For lists, the list’s item view also renders the secondary view through a renderer
  • Try to simplify the creation and use of custom syntax and custom views

Rendering framework

I put a picture here to help explain the framework of the rendering flow:

(Figure is for reference only, error may exist)

First, on the far left, we enter a Text, which is rendered by the Renderer to become the far right View. So, what’s going on inside the renderer? If you look closely, you’ll notice that the Renderer’s processing bands are divided into yellow and green, representing two stages of processing:

Stage 1: pretreatment

In the first stage, the markdown original text is preprocessed and segmented according to syntax to become a group of Raw text with type annotation. Each yellow processing stage here represents the execution of a preprocessing rule, which is usually to split the Raw, but can also be modified to output the Raw itself (such as the space specification mentioned earlier), or even discarded.

Raw is defined as follows:

struct Raw: Hashable {
    let lock: Bool      // Whether it is allowed to be processed in the future
    let text: String    // Stored text information
    let type: String?   // Annotated element type, usually after the lock to specify the type of type.
}
Copy the code

The parent class of the preprocessing rule is defined as:

// When defining a preprocessing rule, simply inherit the SplitRule class and override the split method.
class SplitRule {
    // The priority of the rule
    let priority: Double

    init(priority: Double) {
        self.priority = priority
    }
    // Preprocessing method
    func split(from text: String)- > [Raw] {
        return [Raw(lock: false, text: text, type: nil)]}// Batch method
    final func splitAll(raws: [Raw])- > [Raw] {
        var result: [Raw] = []
        for raw in raws {
            if raw.lock {
                result.append(raw)
            } else {
                result.append(contentsOf: self.split(from: raw.text))
            }
        }
        return result
    }
}
Copy the code

That is, each yellow block of the rendering flowchart represents an instance of SplitRule, which inputs a set of Raw data and outputs a new set of Raw data according to the split method.

Stage 2: Map elements

This stage is used to finalize the type of each Raw data. For each Raw, we process the mapping rules into elements with attributes (such as titles, references, code blocks, separators, and other syntactic elements that make up the view, which we can call elements) for the final view output.

Element is defined as:

class Element: Identifiable {
    // id is used to ensure the unique identity of the element, serving the ForEach view component
    let id = UUID()}Copy the code

For each Element, we simply inherit the Element class and implement the init(raw:resolver:) method.

The parent class of the mapping rule is defined as:

// When defining a mapping rule, simply inherit MapRule and override the map method.
class MapRule {
    let priority: Double
    
    init(priority: Double) {
        self.priority = priority
    }
    
    func map(from raw: Raw.resolver: Resolver?). -> Element? {
        return nil}}Copy the code

The Renderer to define

Following the flow chart, we can easily write the renderer definition:

class Resolver {
    
    let splitRules: [SplitRule]
    let mapRules: [MapRule]
    
    init(splitRules: [SplitRule].mapRules: [MapRule]) {
        self.splitRules = splitRules
        self.mapRules = mapRules
    }
    
    // Stage 1: preprocessing
    func split(text: String)- > [Raw] {
        var result: [Raw] = [Raw(lock: false, text: text, type: nil)]
        splitRules.sorted { r1, r2 in
            return r1.priority < r2.priority
        }.forEach { rule in
            result = rule.splitAll(raws: result)
        }
        return result
    }
    
    // Phase 2: mapping processing
    func map(raws: [Raw])- > [Element] {
        var mappingResult: [Element? ]= .init(repeating: nil, count: raws.count)
        mapRules.sorted { r1, r2 in
            return r1.priority < r2.priority
        }.forEach { rule in
            for i in 0..<raws.count {
                if mappingResult[i] = = nil {
                    mappingResult[i] = rule.map(from: raws[i], resolver: self)}}}var result: [Element] = []
        for element in mappingResult {
            if let element = element {
                result.append(element)
            }
        }
        return result
    }
    
    / / rendering
    func render(text: String)- > [Element] {
        let raws = split(text: text)
        let elements = map(raws: raws)
        return elements
    }
}
Copy the code

With Renderer, we pass the two-stage rules to the Renderer at initialization, and then render using the Render method.

View shows

We have a renderer to help us convert the original text into a set of elements, but we also need a view parser to help us export the elements. As mentioned earlier, we expect developers to also be able to customize the view for each element, so during the view display phase, we also need a view map for the element.

Here is the definition of MarkdownView, which is responsible for entering text and accepting a renderer and a view map:

struct Markdown<Content: View> :View {
    let elements: [Element]
    // View mapping of Element Element
    let content: (Element) - >Content
    
    init(
        text: String.resolver: Resolver.@ViewBuilder content: @escaping (Element) - >Content
    ) {
        self.elements = resolver.render(text: text)
        self.content = content
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 15) {
            ForEach(elements) { element in
                HStack(spacing: 0) {
                    content(element)
                    Spacer(minLength: 0)}}}}}Copy the code

So, what does a view map look like? Thankfully, SwiftUI supports the Switch syntax for building views, making it easy to define views for each element type:

struct ElementView: View {
    let element: Element
    
    var body: some View {
        switch element {
        case let header as HeaderElement:
            Header(element: header)
        case let quote as QuoteElement:
            Quote(element: quote)
        case let code as CodeElement:
            Code(element: code)
        .
        default:
            EmptyView()}}}Copy the code

Finally, we can use MarkdownView like this:

struct CustomMarkdownView: View {
    let markdown: String
    let resolver = Resolver(splitRules: [ /* rules */ ],
                            mapRules: [ /* rules */ ])
    
    var body: some View {
        Markdown(text: markdown, resolver: resolver) { element in
            switch element {
            /* cases */
            default:
                EmptyView()}}}}Copy the code

conclusion

I am writing the Markdown rendering project of SwiftUI and will release the first SPM version soon. If you are interested, you can bookmark this article and I will put the link of open source project in the article in the future.

Finally, thank you for reading!