0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUIAdvent Calendar 2022

Day 23

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

Last updated at Posted at 2022-12-22

概要

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

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

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

MarkDownでBold表示

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

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

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から取得したマークダウンの表示はプレーンなテキスト表示になりました。

マークダウンで表示されない例

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

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

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() ?? ""
            }
        }
    }
}

マークダウンで表示される例

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

注意点

iOS 14ではプレーンテキストとして表示される

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

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

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

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

おまけ

今回のコード説明では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
    }
}
0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?