100DaysOfSwiftUI Day60 Challengeをやってみる (Step2 : メイン画面(Userをリスト表示)実装)

データモデルを作ったので、メインのUIを作っていきます。

まずは、@ObservedObject

まずは、ビューが参照するモデルを記述します。

@ObservedObject var userList: UserList

対象モデルをそのままクラスにしましたので、すごくシンプルです。

このプロパティを追加すると、ContentView_Previewsでエラーが発生しますので、そのエラーにも対応しましょう。
新しいプロパティを追加したのに、Previewでは、その情報が渡されないことがエラーの原因です。なので、追加すればOKです。

static var previews: some View {
    ContentView(userList: UserList())
}

メインUIの主役は、List

UITableViewが非常によく使われたように、SwiftUIでは、Listが非常によく使われると思います。

UserListに保持されているUserを行として、表示するリストにします。行には、ユーザーのIDと名前とisActiveを表示することにしましょう。

将来的に階層的な表示にしていくので、NavigationViewにすることも忘れてはいけません。

struct ContentView: View {
    @ObservedObject var userList: UserList
    var body: some View {
        NavigationView {
            List(userList.users) { user in
                HStack {
                    Text("name : \(user.name)")
                    VStack {
                        Text("id       : \(user.id)")
                        Text("isActive : \(String(user.isActive))")
                    }
                }
            }
        }
    }
}

一発では動きませんでした。Listで扱うためには、IdentifiableにConformしていなければいけないというエラーが発生しました。

Listは、このIdentifiableを拠り所にして、リストの管理をしてくれていますので、無視できません。例えば、行が削除されたときに、そのようなアニメーションになるのは、Listがどの行が削除されたか判断できるからです。

UserをIdentifiableにConformさせます。実は、組み込みタイプで構成されるクラスは、Identifiableになれます。(Codableと似た感じと言えばよいでしょうか)

これで、動くかと思いましたが、今度は、SceneDelegate.swiftでエラーが発生します。

このクラスが実際に動く時に実行されるクラスなのですが、その中でContentViewを作る際に、引数が足りなくてエラーです。
さきほどのPreviewのエラーと同じエラーですね。

UserListを渡すように変更します。

let contentView = ContentView(userList: UserList())

ContentViewの引数が追加された部分です。

シミュレータで実行すると以下のようになります。

Day60EmptyUI

NavigationViewで情報表示

すこしさみしいので、タイトルを表示するようにしました。

".navigationBarTitle"modifierを使って、"FriendDB"と表示するようにしました。

Previewの改良

もちろん、このままJSONファイル読み込みに進んでも良いのですが、HStackやVStackを使ったデータ表示がどうなるかが、よく見えません。
サンプルデータを作って、Previewで表示するようにしてみましょう。

サンプルを追加する関数を作ります。追加しようとすると、以下のことが必要になります。

サンプル関数の追加

func addSample() -> UserList {
    let newUser = User()
    self.users.append(newUser)
    return self
}

ContentView_Previewコードの修正

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(userList: UserList().addSample())
    }
}

上記のコードだけでは、Userのinitializerがないので、うまく動きません。

Userのinitializer

すべての値を渡すInitializerを作るのが理想的かもしれませんが、プロパティが多いので、練習ということも考慮して、id, isActive, name以外のプロパティには、値を設定してしまい、id,isActive,nameを受け取るinitializerを作りましょう。

class User: Codable, Identifiable {
    var id:String
    var isActive: Bool
    var name:String
    var age: Int = 25
    var company:String = ""
    var email: String = ""
    var address:String = ""
    var about: String = ""
    var registered: Date = Date()
    var friends:[Friend] = []
    
    init(id: String, isActive:Bool, name: String) {
        self.id = id
        self.isActive = isActive
        self.name = name
    }
}

サンプル追加コードの改良

func addSample() -> UserList {
    ["1", "2", "3"].forEach {
        let newUser = User(id: $0, isActive: Bool.random(), name: "Example".appending($0))
        self.users.append(newUser)
    }
    return self
}

UIの改良

サンプルを作ってみて良かったことがわかります。レイアウトがすこし変です。Spacer()とかを追加しつつ調整します。

調整後のコード

struct ContentView: View {
    @ObservedObject var userList: UserList
    var body: some View {
        NavigationView {
            List(userList.users) { user in
                HStack {
                    Text("name : \(user.name)")
                    Spacer()
                    VStack (alignment: .leading) {
                        Text("id: \(user.id)")
                        Text("isActive: \(String(user.isActive))")
                    }
                }
            }
        .navigationBarTitle("FriendDB")
        }
    }
}

Day60UpdatedUI

# 普段は、シミュレータでスナップショットを撮るのですが、今回はPreviewにサンプルを与えているので、XCodeの画面をキャプチャしてます。

JSONを読もう!

ここまで作って、JSONファイルを読み込む準備が完了です。

ContentViewに読み込むための関数を追加しましたが、なんと、mutatingをつけた関数の中からでも、その中のClosureからは、selfが変更できないという・・・
まぁ、当たり前と言えば当たり前のことに、XCodeのエラーメッセージから気づきました。

当初は、onAppearで呼び出すことを考えていましたが、SceneDelegate中のContentViewが作成される前段階で読み込むことにしました。

さっそく、
なので、UserListにinitを追加して、URLを使っての初期化を試してみましたが、動かない・・・どうやら、decodeできていないみたい。

初心に返って、UserListの簡単なinitを作るところから。
以下のようなInitializerを作って、ローカルファイルからきちんと読めるか確認しました。

in. SceneDelegate.swift
let contentView = ContentView(userList: UserList(fileName: "FriendDBShort"))

In UserList.swift
init(fileName:String) {
    self.users = []
    guard let filePath = Bundle.main.url(forResource: fileName, withExtension: "json") else {
        fatalError("failed to find file")
    }
    guard let data = try? Data.init(contentsOf: filePath) else {
        fatalError("failed to read file")
    }
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    if let decodedData = try? decoder.decode([User].self, from: data) {
        print("OK")
    }
}

原因は、JSONファイル中では、UserListに対して名称は付いていないからでした。なので、UserListのinit(from decoder)に手を入れないといけません。

注意
この調査中に気づきましたが、Previewのコードをデバッグすることはできません。コードをデバッグしたければ、シミュレータで行うしかないようです。

Root要素が無名配列への対応

今回対象としているJSONファイルは、User相当の要素を配列なのですが、その配列には、名称がついていません。ですが、コードの方は、Decodeしようとする時には、UserListという名称が付いていることを期待しています。

このことが、読み込めない理由でした。

Userの配列を、アプリケーションのデータモデルにしても良いのですが、やはり少し、扱いにくい気がしますので、配列を明示的にクラスにしたUserListという単位で扱いたいです。

JSON Encoder/Decoderで無名配列に対応できないとすると、現在のデータ構造から見直さないといけなくなってしまいます・・・・
それなりに、ありそうな要望な気がしたので、調べてみたところ、ありました!
このような無名の配列は、JSONDecoder/Encoderでは、unkeyedContainerで扱うようです。

UserListのdecode側は以下のようなコードで、無名配列に格納されたUserデータを読み込むことができました。

required init(from decoder: Decoder) throws {
    users = []
    var container = try decoder.unkeyedContainer()
    while !container.isAtEnd {
        let user = try container.decode(User.self)
        users.append(user)
    }
}

今回のアプリでは使いませんが、Encode側も対応しました。

func encode(to encoder: Encoder) throws {
    var container = encoder.unkeyedContainer()
    for user in users {
        try container.encode(user)
    }
}

WebからJSONを取得してDecodeするコード

うまくEncode/Decodeでいるようになったので、Webから取得してDecodeするコードをUserListのInitializerとして、追加しました。

init(urlString:String) {
    self.users = []
    guard let url = URL(string: urlString) else { return }
    let request = URLRequest(url: url)
    URLSession.shared.dataTask(with: request) { (data, response, error) in
        guard let data = data else {
            print("failed to get data from web")
            return
        }
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        if let decodedData = try? decoder.decode(UserList.self, from: data) {
            self.users = decodedData.users
        }
    }.resume()
}

このInitializerを使って、読み込んだ後に、ContentViewを構築するようにSceneDelegate.swiftを変更しました。

let contentView = ContentView(userList: UserList(urlString: "https://www.hackingwithswift.com/samples/friendface.json"))

データを読み込むところまでいけました。

Day60DataLoaded

あまりにも、ガタガタなので、すこしレイアウトと表示する要素を変更しました。この辺りは、SwiftUIだから、簡単に大きな変更を行えますね。

struct ContentView: View {
    @ObservedObject var userList: UserList
    var body: some View {
        NavigationView {
            List(userList.users) { user in
                VStack (alignment: .leading) {
                    Text("\(user.name)")
                    HStack {
                        Spacer()
                        Text("age: \(user.age)")
                            .font(.footnote)
                        Spacer()
                        Text("isActive: \(String(user.isActive))")
                            .font(.footnote)
                        Spacer()
                    }
                }
            }
            .navigationBarTitle("FriendDB")
        }
    }
}

Day60DataLoadedImproved

ここまでのまとめ

  • jsonファイルの構造に100%合うようなデータ構造にするのが、すすめやすいが、要検討
  • CodableにConformするために実装な必要なコードを調整することで柔軟にJSONに対応できる

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です