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!