LoginSignup
1
2

More than 3 years have passed since last update.

[SwiftUI サンプルコードあり] 入れ子構造になったJSONをMVVMモデルを使って表示する方法

Last updated at Posted at 2020-09-12

this article shows How to parse nested JSON on SwiftUI MVVM model.
if you can not read Japanese, you should use a translater. I've published Code on GitHub. check this out.
if you have any question, you can keep in touch with me from my Twitter (tkgshn)

はじめに

SwiftUIを使って、入れ子構造になっているJSONファイルをパース(分解)し、表示させるところまでを解説した記事です。
なぜこの記事を書いたのかというと、「入れ子構造になっているJSONファイルのパース」「SwiftUIでの解説」が見つからなかったからです。2週間前の自分と同じ実装で悩んでいる人に向けて書いています。

この記事では、Appleが公開しているSwiftUIのチュートリアルにそったMVVMモデルで、入れ子構造になったJSONをパース(分解)して、実際にViewに表示させるところまでを紹介します。

作るもの

5d9a68e694de4c3cdb5af28bea23435d.gif
今回作るのは、

  • 「英語学習の際のトピック」を親の情報として、
  • 『単語(日本語・英語)』を子供と見立て、情報を同時に表示することです。

これは、入れ子構造(「ネストした」などとも言う)のJSONを分解して、親の情報と子の情報を同時に表示しています。

なお、今回のコードはこちらのGitHubから見ることが出来るので、コードだけ見たい人はどうぞ。

SwiftUIを始める方は、こちらの Appleが公開しているSwiftUIのチュートリアル を最後まで完走していることをオススメします。

簡単に、データの構造を示してみるとこんな感じです。
なお、この図はGitHubある、drow.io から見ることができます。

今回扱うデータの構造

audioContentData.json
[
{
    "id": 0,
    "name": "人称",
    "description": "留学に行く際に空港で聞かれる内容をまとめた内容です",
    "phrases":{
        "1": {"japanese": "Aゲートはどこですか?", "english": "Where is the Gate A ?"},
        "2": {"japanese": "私は日本へ行きます", "english": "I'll go to Japan"}
    }
},
{
    "id": 2,
    "name": "国と言語",
    "description": "国と言語を説明する際に使う英語をまとめました",
    "phrases": {
        "1": {"japanese": "私は日本出身です。", "english": "I'm from Japan"},
        "2": {"japanese": "日本語は世界の言葉に比べて難しいです", "english": "Japanese is difficult than other languages"}
    }
}
]

JSONが入れ子構造になっていることがわかると思います。今回はこちらを説明していきます。
次に、SwiftUIのチュートリアルで使われたJSONを紹介していきます。

SwiftUI チュートリアルで行っている方法

test.json
[
    {
        "name": "Turtle Rock",
        "category": "Rivers",
        "city": "Twentynine Palms",
        "state": "California",
        "id": 1001,
        "isFeatured": true,
        "isFavorite": true,
        "park": "Joshua Tree National Park",
        "coordinates": {
            "longitude": -116.166868,
            "latitude": 34.011286
        },
        "imageName": "turtlerock"
    },
    {
        "name": "Silver Salmon Creek",
        "category": "Lakes",
        "city": "Port Alsworth",
        "state": "Alaska",
        "id": 1002,
        "isFeatured": false,
        "isFavorite": false,
        "park": "Lake Clark National Park and Preserve",
        "coordinates": {
            "longitude": -152.665167,
            "latitude": 59.980167
        },
        "imageName": "silversalmoncreek"
    }
]

こちらがSwiftUI チュートリアルで使用されているJSONのファイルの一部抜粋です。一見入れ子構造になっている気がする、こちらの coordinates の要素ですが、

"coordinates": {
"longitude": -116.166868,
"latitude": 34.011286
},

コードを読み進めてみると、位置情報の取得に使われているだけで、入れ子構造で処理したい人が参考になるようなコードではありません。なので、無視してもらって大丈夫です。

対象読者

  • 「SwiftUIからiOSでのアプリ開発を始めた」
  • 「JSONなどのデータの取り扱いは初めて」
  • 「とりあえずなんとか動いてほしい」
  • 「ちょっと複雑なデータを持つアプリを作りたい」

という、2週間前の筆者の状況の方へ向けた記事です。

この記事では、ある程度「SwiftUIがどう動くか?」というのは分かってきたけど、自分のほしいものは微妙に作れない。という状態の人へオススメです。

そのため、初歩的なSwiftUIの表示などの説明はしていません。

かわりに、なるべく参考記事へのリンクやキーワードを載せました。まったくわからない人は、適宜キーワードを検索しつつ手を動かしてみてください!

では、やっていきましょう〜! 🦄

前提

SwiftUIの導入・基本はこちらの記事が参考になります。

データを作る

続いて、今回の目標は「データを表示させること」なので、表示させるものがなければ始まりません。
正直、表示するものはなんでもいいのですが今回は「英語学習アプリ」を想定して、以下のようなJSONを作ってみました。

audioContentData.json
[
{
    "id": 0,
    "name": "人称",
    "description": "留学に行く際に空港で聞かれる内容をまとめた内容です",
    "phrases":{
        "1": {"japanese": "Aゲートはどこですか?", "english": "Where is the Gate A ?"},
        "2": {"japanese": "私は日本へ行きます", "english": "I'll go to Japan"}
    }
},
{
    "id": 2,
    "name": "国と言語",
    "description": "国と言語を説明する際に使う英語をまとめました",
    "phrases": {
        "1": {"japanese": "私は日本出身です。", "english": "I'm from Japan"},
        "2": {"japanese": "日本語は世界の言葉に比べて難しいです", "english": "Japanese is difficult than other languages"}
    }
}
]

名前(name)概要(description)の下に、単語(phrases)があり、英語・日本語の文字列を持っている状態ですね。

  • id
  • name
  • description
    • japanese
    • english

このサンプルデータはなんでもいいのですが、今回はこんな形で行こうと思います。

データ(JSON)を受け取るモデルを作成

この章では、「Model」という名前でまとめられたグループの説明をしていきます。

1. チュートリアルのコードを見てみよう

チュートリアルでは、こんな感じのファイルがありましたね。
image.png
こんなファイル構造になっていることがわかると思います。

Model_SwitUI_tutorial.group
- Landmark.swift
- UserData.swift
- Data.swift
- Hike.swift

2. 今回のModelの構成

今回は、このような「Model」のファイル構成にしてみました、少し無駄なファイルを減らしたので、少なくなっています。
image.png

Model_nestedJSONParse.group
- AudioContent.swift
- Data.swift
- UserData.swift

まずは、一番上のLandmark.swiftAudioContent.swift の詳細から見ていきましょう。(ファイル名は違いますが、どちらも同じような働きをします)

クリックしてLandmark.swiftを見る
Landmark.swift
/*
See LICENSE folder for this sample’s licensing information.

Abstract:
The model for an individual landmark.
*/

import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    fileprivate var imageName: String
    fileprivate var coordinates: Coordinates
    var state: String
    var park: String
    var category: Category
    var isFavorite: Bool
    var isFeatured: Bool

    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }

    var featureImage: Image? {
        guard isFeatured else { return nil }

        return Image(
            ImageStore.loadImage(name: "\(imageName)_feature"),
            scale: 2,
            label: Text(name))
    }

    enum Category: String, CaseIterable, Codable, Hashable {
        case featured = "Featured"
        case lakes = "Lakes"
        case rivers = "Rivers"
        case mountains = "Mountains"
    }
}

extension Landmark {
    var image: Image {
        ImageStore.shared.image(name: imageName)
    }
}

struct Coordinates: Hashable, Codable {
    var latitude: Double
    var longitude: Double
}

しかし、今回は複雑なコードになってしまう原因である fileprivateenum などは使わないことにします。

AudioContent.swift
import SwiftUI

// MARK: - AudioContent
struct AudioContent: Codable, Identifiable {
    //    コンテンツのid
    var id: Int
    //    コンテンツのタイトル
    var name: String
    //    コンテンツの概要
    var description: String
    // フレーズの入れ子を作る
    var phrases: [String: Phrase]

}


// MARK: - Phrase
struct Phrase: Codable {
    var japanese: String
    var english: String
}

その結果、こんな感じになります。

この時のモデルのコード生成とかは、https://app.quicktype.io/ を使えば、自動で生成できるので頑張らなくて大丈夫です。 

使い方に関しては、以下の記事などを参考にしてください。
- JSON から各言語のコードを生成する quicktype の Haskell 出力を実装した
- 圧倒的捗り!!JSONデータからモデルを自動生成してくれるquicktypeが便利すぎるので紹介してみる

なお、注意としてはサンプルよりも複雑なJSONを扱おうとすると、Landmark.swiftで出てきたような fileprivateenum を使うをやむ得なくなると思います。

そうなった場合は、quicktype では処理出来ない(文字列からでは画像を取り扱いのかはわからない)ので、他のサンプルコードを参考にやってみてください。


3. 「Model」に入っていたその他のファイル

その他のファイルで、説明していない Data.swiftUserData.swift があったと思います。

クリックしてData.swiftを見る
Data.swift
import Foundation
import CoreLocation
import SwiftUI

let landmarkData: [Landmark] = load("landmarkData.json")
let features = landmarkData.filter { $0.isFeatured }
let hikeData: [Hike] = load("hikeData.json")

func load<T: Decodable>(_ filename: String) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

final class ImageStore {
    typealias _ImageDictionary = [String: CGImage]
    fileprivate var images: _ImageDictionary = [:]

    fileprivate static var scale = 2

    static var shared = ImageStore()

    func image(name: String) -> Image {
        let index = _guaranteeImage(name: name)

        return Image(images.values[index], scale: CGFloat(ImageStore.scale), label: Text(name))
    }

    static func loadImage(name: String) -> CGImage {
        guard
            let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
            let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
        else {
            fatalError("Couldn't load image \(name).jpg from main bundle.")
        }
        return image
    }

    fileprivate func _guaranteeImage(name: String) -> _ImageDictionary.Index {
        if let index = images.index(forKey: name) { return index }

        images[name] = ImageStore.loadImage(name: name)
        return images.index(forKey: name)!
    }
}


クリックしてUserData.swiftを見る
UserData.swift
import Combine
import SwiftUI

final class UserData: ObservableObject {
    @Published var showFavoritesOnly = false
    @Published var landmarks = landmarkData
    @Published var profile = Profile.default
}

正直難しいので、初心者の方が理解をするのは時間がかかるとは思いますが、簡単に説明すると

  1. JSONファイルを読み取るところ
  2. アプリ操作する人(User)のデータを処理するところ

という認識で問題ないです。

詳しい紹介はこちらの記事を参考にしてください
SwiftUI Tutorialsを読み解く

表示する

実装をしていきます。
ほとんど内部の処理は分離させていないので、もうちょっときれいに書けるはずです。

ContentView.swift
import SwiftUI

struct ContentView: View {

    @EnvironmentObject var userData: UserData
    var audioContent: AudioContent

    var body: some View {
        NavigationView {
            List {
                ForEach(audioContentData) { audioContent in
                    NavigationLink(
                        destination:
                        VStack{
                            Text(audioContent.name)
                                .font(.largeTitle)
                                .padding(.top)
                            Text(audioContent.description)
                                .padding([.top, .leading, .trailing])
                            Divider()
                                .padding(.top)
                            //                下に書いている
                            PhraseRow(audioContent: audioContent)
                                .padding(.top)
                            })
                    {
                        Text(audioContent.name)
                    }
                }
            }
            .navigationBarTitle("all List!")
        }
    }

}




struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return ContentView(audioContent: userData.audioContents[0])
            .environmentObject(UserData())

    }
}




// MARK: - 単語を表示する部分
struct PhraseRow: View {
    var audioContent: AudioContent
    var body: some View {
        //        Phraseが持っている個数分より多い数、ループを回すとクラッシュしてしまう
        ForEach(1..<3) { num in
            //                日本語を取得
            VStack(alignment: .leading) {
                Text(self.audioContent.phrases[String(num)]!.japanese)
                    .padding(.bottom)
                //                英語を取得
                Text(self.audioContent.phrases[String(num)]!.english)
            }
            Divider()
        }
    }
}

最初のListは名前だけを取得するようにしました
image.png

タップして遷移すると、こちらのコードのおかげで

PhraseRow.swift
// MARK: - 単語を表示する部分
struct PhraseRow: View {
    var audioContent: AudioContent
    var body: some View {
        //        Phraseが持っている個数分より多い数、ループを回すとクラッシュしてしまう
        ForEach(1..<3) { num in
            //                日本語を取得
            VStack(alignment: .leading) {
                Text(self.audioContent.phrases[String(num)]!.japanese)
                    .padding(.bottom)
                //                英語を取得
                Text(self.audioContent.phrases[String(num)]!.english)
            }
            Divider()
        }
    }
}
  1. 親が持っている情報
    • audioContent.name
    • audioContent.description
  2. 子が持っている情報
    • audioContent.phrases[String(num)]!.japanese
    • audioContent.phrases[String(num)]!.english

が表示されています。

image.png

参考サイト

I wish to say big thanks for the Indian on stackover flow.

image.png

1
2
1

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
1
2