SwiftUIで、外部からテキストを受け取りMarkdown表示する

# 概要

SwiftUIのTextなどは、Markdown記法が使えるようになりました。例えば以下のTextは、

```swift
struct MarkdownView: View {
    var body: some View {
        List {
            Text("**BOLD**")
        }
    }
}
```

実際にプレビューに表示するとBold表示になります。

![MarkDownでBold表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/63bd7562-71d9-6ff8-642e-c472359ca013.png)

# パラメータとしてマークダウン文字列を取得する

例えば[Qiita API v2](https://qiita.com/api/v2/docs)などは、レスポンスの中にMarkdownで記述されたbodyがあります。これをMarkdownで表示しようと思います。
(Qiita APIからデータを取得するためのEnvironment実装は[おまけ](#おまけ)で紹介します)

```swift
struct MarkdownView: View {
    
    /// Qiita APIからデータを受け取るためのEnvironment
    @Environment(\.qiita) private var qiita
    
    @State private var markdown = ""
    
    var body: some View {
        List {
            Text(markdown)
        }
        .onAppear {
            let itemId = "c686397e4a0f4f11683d" // Markdown記法 チートシート記事ID
            Task {
                markdown = try await qiita.get("/api/v2/items/\(itemId)").body?.stringValue() ?? ""
            }
        }
    }
}
```

しかしこれを実際に表示したところ、Qiitaから取得したマークダウンの表示はプレーンなテキスト表示になりました。

![マークダウンで表示されない例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/d7b59903-99dc-8311-f9a9-7ece29c63010.png)

# マークダウンとして文字列を認識させる

次のようにTextの中身の値に`.init`を使うことで文字列がマークダウンとして認識されるようになります。

```swift
struct MarkdownView: View {
    
    /// Qiita APIからデータを受け取るためのEnvironment
    @Environment(\.qiita) private var qiita
    
    @State private var markdown = ""
    
    var body: some View {
        List {
            Text(.init(markdown)) // ← 変更点
        }
        .onAppear {
            let itemId = "c686397e4a0f4f11683d" // Markdown記法 チートシート記事ID
            Task {
                markdown = try await qiita.get("/api/v2/items/\(itemId)").body?.stringValue() ?? ""
            }
        }
    }
}
```

![マークダウンで表示される例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/5597993f-3361-69c3-5260-07dca476d054.png)

この`.init`ですが、これは実際は`LocalizedStringKey`のinitになります。
なので、`Text(.init(markdown))`の部分を`Text(LocalizedStringKey(markdown))` に変更しても同様に動作します。

# 注意点

## iOS 14ではプレーンテキストとして表示される
マークダウン記法対応は iOS15からなので、iOS14ではLocalizedStringKeyを用いたとしてもプレーンテキストで表示が行われます。


## 受け取った文字列がLocalizable.stringのキーと同一の場合、Localizable.stringの文字列が表示される

内部でLocalizedStringKeyを使っているので当然といえば当然なんですが、もし受け取った文字列がLocalizable.stringのキーと同一だった場合、Localizable.stringに定義された文字列が出力されてしまいます。

```swift
struct MarkdownView: View {
    @State private var markdown = ""
    
    var body: some View {
        List {
            Text(.init(markdown))
        }
        .onAppear {
            Task {
                markdown = "App description" //← Localizable.stringに定義されている
            }
        }
    }
}

struct MarkdownView_Previews: PreviewProvider {
    static var previews: some View {
        MarkdownView()
            .environment(\.locale, .init(identifier: "ja-JP"))
    }
}
```
![スクリーンショット 2022-12-15 11.13.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/2415c2a5-c234-f325-1767-997d9cc1f52e.png)

# おまけ

今回のコード説明ではQiita APIからデータを取得するために、`Qiita`のEnvironmentと、そのレスポンス`JSON`を作成しています。このQiitaクラスはgetのみの対応になっています。

```swift Qiita.swift
import Foundation

struct QiitaError: Error {
    let json: JSON
}

struct Qiita {
    internal init(host: String) {
        self.get = Get(host: host)
    }
    
    private static let scheme = "https"
    
    let get: Get
    
    @dynamicCallable
    struct Get {
        let host: String
        func dynamicallyCall(withKeywordArguments pairs: KeyValuePairs<String, String?>) async throws -> JSON {
            var components = URLComponents()
            components.scheme = Qiita.scheme
            components.host = host
            components.path = (pairs.filter { $0.key.isEmpty }.first?.value)!
            components.queryItems = pairs.filter({ !$0.key.isEmpty}).map({
                URLQueryItem(name: $0.key, value: $0.value)
            })
            let data = try await URLSession.shared.data(from: components.url!)
            let response: HTTPURLResponse = data.1 as! HTTPURLResponse
            let json = try JSON(data: data.0)
            switch response.statusCode {
            case 200: return json
            default: throw QiitaError(json: json)
            }
        }
    }
}

import SwiftUI

private struct QiitaKey: EnvironmentKey {
    static let defaultValue: Qiita = .init(host: "qiita.com")
}

extension EnvironmentValues {
    var qiita: Qiita {
        get { self[QiitaKey.self] }
        set { self[QiitaKey.self] = newValue }
    }
}

extension View {
    func qiita(_ qiita: Qiita) -> some View {
        environment(\.qiita, qiita)
    }
}
```

```swift JSON.swift
import Foundation

typealias NumberValue = Double //  IEEE 754 specification (BinaryFloatingPoint)

@dynamicMemberLookup
enum JSON {
    case dictionaryValue(Dictionary<String, JSON>)
    case arrayValue(Array<JSON>)
    case numberValue(NumberValue)
    case stringValue(String)
    case boolValue(Bool)
    case nullValue
    
    func objectValue() -> Dictionary<String, JSON>? {
        if case .dictionaryValue(let dictionary) = self {
            return dictionary
        }
        return nil
    }
    
    func arrayValue() -> Array<JSON>? {
        if case .arrayValue(let array) = self {
            return array
        }
        return nil
    }
    
    func stringValue() -> String? {
        if case .stringValue(let str) = self {
            return str
        }
        return nil
    }
    
    func numberValue() -> NumberValue? {
        if case .numberValue(let number) = self {
            return number
        } else if case .boolValue(let b) = self {
            return NumberValue(b)
        }
        return nil
    }
    
    func boolValue() -> Bool? {
        if case .boolValue(let bool) = self {
            return bool
        }
        return nil
    }
    
    func nullValue() -> NSNull? {
        if case .nullValue = self {
            return NSNull()
        }
        return nil
    }
    
    subscript(index: Int) -> JSON? {
        if case .arrayValue(let array) = self {
            return index < array.count ? array[index] : nil
        }
        return nil
    }
    
    subscript(dynamicMember member: String) -> JSON? {
        if case .dictionaryValue(let dict) = self {
            return dict[member]
        }
        return nil
    }
    
    private init(_ object: Any) {
        switch object {
        case let boolValue as Bool: self = .boolValue(boolValue)
        case let numberValue as NumberValue: self = .numberValue(numberValue)
        case let stringValue as String: self = .stringValue(stringValue)
        case let dictionaryValue as Dictionary<String, Any>: self = JSON.dictionaryValue(dictionaryValue.mapValues{ JSON($0) })
        case let arrayValue as Array<Any>: self = .arrayValue(arrayValue.map { JSON($0)} )
        default: self = .nullValue
        }
    }
    
    init(data: Data) throws {
        let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
        self = .init(jsonObject)
    }
}

private extension ExpressibleByIntegerLiteral {
    init(_ booleanLiteral: BooleanLiteralType) {
        self = booleanLiteral ? 1 : 0
    }
}
```