[第二回] PlaygroundでSwiftUIの仕様を学ぶ

Swift

親ビューと子ビュー

今回は親ビューと子ビューの関係とコーディングの方法。

また、初学者が詰まりやすそうな親ビューと子ビュー間でのデータの受け渡しについてまとめました。

子ビューの必要性

子ビューは親ビューが煩雑になるのを防ぐために使います。

例えば、ユーザ名を複数表示させる場合を考えましょう。今回は全部同じのを表示しているため意味不明なコードになっていますが、親ビューにそのまま書くと以下のようになります。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        Group {
            Text("Player name is tkgling")
            Text("Player name is tkgling")
            Text("Player name is tkgling")
            Text("Player name is tkgling")
            Text("Player name is tkgling")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

親ビューにどんどん要素を追加すると可読性が悪くなるのがわかるでしょうか?

なので親ビューではなるベく子ビューを利用し、生のコンポーネントをそのまま表示しないようにするのが望ましいです。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        Group {
            PlayerView()
            PlayerView()
            PlayerView()
            PlayerView()
            PlayerView()
        }
    }
}

struct PlayerView: View {
    var body: some View {
        Text("Player name is tkgling")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

しかし、これでも内容が変わっただけで同じコードを五回も書いているのでダサいですよね?なのでForEach文で繰り返し処理を行うようにします。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { _ in 
            PlayerView()
        }
    }
}

struct PlayerView: View {
    var body: some View {
        Text("Player name is tkgling")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

SwiftUIではForEachはRange<Int>を指定しなければいけないので、同じ処理を繰り返したい場合はこのように書きます。ちょっと特徴的なので覚えておくと良いでしょう。

で、勘の良い方はわかったと思うのですが、ForEachを親ビューで回すのがダサいので子ビューで回すようにします。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        PlayerView()
    }
}

struct PlayerView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { _ in 
            Text("Player name is tkgling")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

こうすることで「親ビューは何が表示されているかだけを確認」することができ「子ビューで実際に表示される内容を確認」することができるのです。

子ビューは再利用性を高くする

しかし、実際に同じ内容を何度も表示をするなんてことは少ないので、今回はForEachを使って子ビューに値を渡し、それを表示するコードを考えましょう。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { _ in 
            PlayerView()
        }
    }
}

struct PlayerView: View {
    var body: some View {
        Text("Player name is tkgling")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

さて、わかりやすくするためにコードを一段階戻してこの状態から考えます。

ForEach(Range(1 … 5)) { _ inの箇所ですが、これは「Range(1 ... 5)を先頭から順番に読み込んでその内容を_で参照する」という書き方です。

本来であれば「ForEach(Range(1 … 5)) { i in」のように変数に代入して使うのですが今回は「ループの何番目を処理しているか」という情報を使わないので_で置き換えて無視しているわけです。

何回目かのループ情報を利用するには以下のように書き直せばよいです。注意点としてはiにはInt型が入っているので適切にString型にキャストする必要があります。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            Text(String(i))
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

親ビューから子ビューに変数を渡す

ではこのコードを子ビューを使って実現するにはどうしたら良いでしょうか?

やりたいことは以下のようなコードになるわけですが、これは当然コンパイルエラーが発生します。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView()
        }
    }
}

struct PlayerView: View {
    var body: some View {
        Text("Index is \(i)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

なぜなら、親ビューから子ビューへループ情報であるiが伝わっていないからです。子ビューは「変数iってなんですか?」とエラーを返します。

コンストラクタを使ってみる

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView(loop: i)
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

ものすごく愚直に書くならこのように書けば動作します。

これはPlayerViewを呼び出すときに引数loopにInt型変数を代入するものです。これによって親ビューから子ビューにデータを送ることができます。

ではこのデータを子ビューでどうやって受け取ればいいでしょうか?

やりたいこと(コンパイルエラー)

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView(loop: i)
        }
    }
}

struct PlayerView: View {
    var body: some View {
        Text("Index is \(loop)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

やりたいこととしてはこのように書きたいわけです。ところがこれは子ビューが受け取った変数を代入できていないので変数が定義されてないとエラーが出ます。

なので受け取るためのコンストラクタであるinit()を定義します。

やりたいこと(コンパイルエラー)

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView(loop: i)
        }
    }
}

struct PlayerView: View {
    init(loop: Int) {
        let index: Int = loop
    }
    var body: some View {
        Text("Index is \(index)")
    }
}

詳しくは自分もよくわかってないので解説が難しいのですが、このコードもコンパイルエラーが発生します。コンストラクタ内で定義されている変数はViewからはそのまま参照できないのです。

グローバル変数として定義

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView(loop: i)
        }
    }
}

struct PlayerView: View {
    private var index: Int
    init(loop: Int) {
        index = loop
    }
    
    var body: some View {
        Text("Index is \(index)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

恐ろしく愚直なコードを書くとこうなります。これで一応受け取ったデータをグローバル変数indexにコピーし、その値を表示することができます。

でもこれ、すっごくダサくないですか?コンストラクタが引数をグローバル変数にコピーしてるだけっていうのがめっちゃダサいですよね?

ちょっとだけマシにする(ダサい)

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        ForEach(Range(1 ... 5)) { i in 
            PlayerView(loop: i)
        }
    }
}

struct PlayerView: View {
    private var message: String
    init(loop: Int) {
        message = "Index is \(loop)"
    }
    
    var body: some View {
        Text(message)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

なのでコンストラクタで表示するメッセージを生成するように切り替えます。これでちょっとだけマシになりますが、やはりとてもダサいです。

StateとBinding

なのでこのクソダサコードを解消するための仕組みがSwiftUIには備わっています。

クソダサコード

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    private var username: String = "tkgling"
    var body: some View {
        PlayerView(name: username)
    }
}

struct PlayerView: View {
    private var username: String
    init(name: String) {
        username = name
    }
    var body: some View {
        Text("This is \(username)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

さてこれは先程と同じく、子ビュー宣言時にコンストラクタで値を受け取りそれを表示するコードです。

StateとBindingの意味

さて、このダサいコードを書き直す前にStateとBindingの意味を理解しておきましょう。

State

Stateは正確さを犠牲にものすごく簡単にいうと「スーパーグローバル変数」のようなものです。

struct ContentView: View {
    private var username: String = "tkgling"
    var body: some View {
        PlayerView(name: username)
    }
}

この例で言えば極端な話、子ビューから親ビューのusernameにアクセスしてそれを読み取りたいわけです。

プログラミング言語によってはthis.parent().usernameみたいな感じでアクセスできるかもしれませんがSwiftUIにはそういう記法が存在しません。というのもSwiftUIでのViewがclassでなくstructであるためです。

つまりそのままでは親ビューが持つ変数にはアクセスできないので、変数の管理をSwiftUIフレームワークに丸投げする仕組みが考えられました、これがまさにStateというわけです。

SwiftUIフレームワークはアプリ内のすべてのビューのデータを扱えるのでSwiftUIフレームワークを経由することで親ビューのデータにアクセスすることができるのです。

通常の変数ではなくStateとして変数を定義したい場合は以下のようにコードを書き換えます。

struct ContentView: View {
    @State var username: String = "tkgling"
    var body: some View {
        PlayerView(name: $username)
    }
}

privateとしていたところを@Stateに書き換え、コンストラクタの引数として$をつけることを忘れないようにしてください。

Binding

Bindingをひどく簡単に言えばコンストラクタに渡された「スーパーグローバル変数であるState」の値を「通常の変数」に落とし込むための「おまじない」です。

こっちに関してはとりあえず子ビューに書いておけば扱い方は基本的には普段の変数と同じなので楽です。

private var username: String
init(name: String) {
    username = name
}

ダサコードではこのようにコンストラクタを使っていちいちコピーしていましたが、これをBindingで書き直すと以下のようになります。

@Binding var name: String

なんということでしょう?四行もかかってデータをコピーしていただけのコードがたった一行で書けてしまいました。大事なのは変数名と型は必ずコンストラクタの引数と合わせてください。

コードを書き直そう

それがStateとBindingでこれを使ってさっきのダサいコードをどのくらいわかりやすく書き直せるか実践してみます。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var username: String = "tkgling"
    var body: some View {
        PlayerView(name: $username)
    }
}

struct PlayerView: View {
    @Binding var name: String
    var body: some View {
        Text("This is \(name)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

StateとBindingの特徴

値が更新できる

ここで、初期状態ではボタンにtkglingというラベルが貼ってあるが、ボタンを押すとtkgstratorにラベルの内容が変わるというコードを考えてみます。

これをStateとBindingを使わずに書こうとすると以下のようになります。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    private var username: String = "tkgling"
    var body: some View {
        PlayerView(name: username)
    }
}

struct PlayerView: View {
    private var username: String
    init(name: String) {
        username = name
    }
    var body: some View {
        Button(username, action: {
            self.username = "tkgstrator"
        })
    }
}

PlaygroundPage.current.setLiveView(ContentView())

実はこのコードはコンパイルエラーを起こして動作させることができません。

Button(username, action: {
    self.username = "tkgstrator"
})

問題となるのはこの部分で、これはクロージャ内でデータを更新したいため変数にselfをつける必要があります。ところが、SwiftUIではこのselfをつかって通常の変数の値を上書きすることができないのです。

struct PlayerView: View {
    private var username: String
    init(name: String) {
        username = name
    }
    var body: some View {
        Button(username, action: {
            print(self.username)
        })
    }
}

単純にデータを表示するだけならこのように書けますが、これでは意味がないわけです。

値を正しく更新するコード

その問題もStateとBindingを使えば解決します。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var username: String = "tkgling"
    var body: some View {
        PlayerView(name: $username)
    }
}

struct PlayerView: View {
    @Binding var name: String
    var body: some View {
        Button(name, action: {
            self.name = "tkgstrator"
        })
    }
}

PlaygroundPage.current.setLiveView(ContentView())

これはボタンを押すと名前が変化する、想定通りのコードです。

親ビューでは普通の変数のように使える

Stateの値を子ビューに渡すだけではなく、普通に親ビューでも使いたい場面があります。

親(Stateを宣言した)ビューでは通常の変数と同じように扱えるので、特別に$をつけて変数を扱うことはありません。「$は子ビューのコンストラクタにわたすときだけ使う」と考えておくと良いでしょう。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var username: String = "tkgling"
    var body: some View {
        Group {
            PlayerView(name: $username)
            Text("Player name is \(username)")
        }
    }
}

struct PlayerView: View {
    @Binding var name: String
    var body: some View {
        Text("This is \(name)")
    }
}

PlaygroundPage.current.setLiveView(ContentView())

同じ変数名は使えない

struct ContentView: View {
    @State var username: String = "tkgling"
    private var username: String = "tkgling"
    var body: some View {
        PlayerView(name: $username)
    }
}

このようにStateとprivateで同じ変数名を宣言することはできません。

ObservedObject

さて、ここまでこればおおよそのStateとBindingの使い方がわかったと思います。

次はそれを発展させたObservedObjectについて学びましょう。

ユーザ名とIDを子ビューに渡すコード

これはPlayerViewにユーザ名とIDを渡して表示するコードです。Stateを二つ使っているだけで特別に難しいことはしていません。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var username: String = "tkgling"
    @State var userid: Int = 1234567890
    var body: some View {
        Group {
            PlayerView(name: $username, id: $userid)
        }
    }
}

struct PlayerView: View {
    @Binding var name: String
    @Binding var id: Int
    var body: some View {
        VStack {
            Text("Username: \(name)")
            Text("UserID: \(id)")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

さて、ここで不穏な雰囲気を感じ取った方もいるのではないでしょうか。「これ、引数が増えればコンストラクタがめっちゃ長くなるのでは?」と。

Stateにオブジェクトは使えない

じゃあ以下のコードのようにState自身をオブジェクトにしてそれを渡してしまおうという考えが当然浮かびます。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var userinfo: (name: String, id: Int) = ("tkgling", 1234567890)
    var body: some View {
        PlayerView(info: $userinfo)
    }
}

struct PlayerView: View {
    @Binding var info: (name: String, id: Int)
    var body: some View {
        VStack {
            Text("Username: \(info.name)")
            Text("UserID: \(info.id)")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

ところがこれはコンパイルエラーが発生してしまいます。userinfo自体はStateとして定義できているのですが、その中のnameidが通常のString型とInt型変数であるためにエラーが発生するのです。

そこで使うのがStateのオブジェクトであるObservedObjectというわけです。

ObservedObjectの使い方

ObservedObjectの考え方としてはStateのオブジェクト版という考えで大丈夫です。初期化時に@ObservedObjectをつけObservableObjectを継承したクラスのインスタンスを宣言し、メンバ変数に@Publishedをつけるだけです。

以下は「ボタンを押せばユーザ名が変更される」コードです

import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    @Published var name: String = "tkgling"
    @Published var id: Int = 1234567890
    
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.name) ID: \(user.id)")
            Button("TAP ME", action: {
                self.user.name = "tkgstrator"
            })
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

さっきのコードでいうところのユーザ情報を持っていたオブジェクトをまずクラスとして宣言します。この中に保持したいデータをどんどん入れていくイメージです。

ObservedObject自体はただの配列のような感じなので、宣言するとき以外に特に何かを気にする必要はありません。

注意しなければいけないのはObservableObject内の変数の定義の仕方です。

ObservableObject

ObservableObject内の変数は外部から参照したいのであれば必ず@Publishedをつけなければなりません。ここでややこしいのは実はpublicとしておいてもコンパイルエラーが発生しない点です。

class UserinfoModel: ObservableObject {
    @Published var name: String = "tkgling"
    @Published var id: Int = 1234567890
}

class UserinfoModel: ObservableObject {
    public var name: String = "tkgling"
    public var id: Int = 1234567890
    
}

ただし、実際に実行してみるとその違いがわかります。

エラーは出ないが想定通りに動かないコード

import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    public var name: String = "tkgling"
    public var id: Int = 1234567890
    
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.name) ID: \(user.id)")
            Button("TAP ME", action: {
                self.user.name = "tkgstrator"
            })
        }
    }
}

これはエラーこそでないものの、タップしてもユーザ名が変わりません。どうして@Publishedでは動いたのにpublicではダメなのでしょう?

それはSwiftUIで画面を再描画するためにはSwiftUIフレームワークに「データが更新された」という情報を伝える必要があるからです。これは日本語では「(イベントの)発火」などと呼ばれていますが「通常の変数に値を代入する」という処理はこの発火を伴わないため、想定した動作にならないのです。

イベントの発火について

ただし、public変数(クラスのメンバ変数)への代入は「発火」こそ引き起こさないものの内部データは変更されているので別の変数が「発火」を引き起こせば描画を更新することができます。

import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    public var name: String = "tkgling"
    @Published var id: Int = 1234567890
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.name) ID: \(user.id)")
            Button("TAP ME", action: {
                self.user.name = "tkgstrator"
                self.user.id = 987654321
            })
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

このコードではユーザ名の変更では「発火」しませんが、IDの変更で「発火」が起こり、IDによる「発火」でユーザ名もIDのどちらも正しく更新されます。

おまけ

ユーザ名の変更とIDの変更順を逆にしたらどうでしょう?

Button("TAP ME", action: {
    self.user.id = 987654321
    self.user.name = "tkgstrator"
})

一見するとIDに変更が加わったときに再描画が行われ、その次にユーザ名が変更されるので「IDだけ変わってユーザ名は変わらない」と思うかもしれません。

実はこれはなんとIDもユーザ名もどちらも正しく更新されます。それはイベントの「発火」がクロージャを抜けてから実行されるためです。

つまりIDが変わった時点でSwiftUIフレームワークは「今実行しているクロージャ(カッコ内の処理)を抜けたら画面を再描画する」というフラグを立てているわけです。

親ビューのObservedObjectを子ビューに渡す

import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    @Published var name: String = "tkgling"
    @Published var id: Int = 1234567890
    
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.name) ID: \(user.id)")
            ButtonView(data: user)
        }
    }
}

struct ButtonView: View {
    @ObservedObject var data: UserinfoModel
    var body: some View {
        Button("TAP ME", action: {
            self.data.id = 987654321
            self.data.name = "tkgstrator"
        })
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Stateの際は受け取るときには@Bindingを使っていましたが、ObservedObjectの場合は@ObservedObjectでデータを受け取ることができます。もちろん、子ビューのコンストラクタに余計な宣言は要りませんし、親ビューから渡す際に$をつけるようなまどろっこしいことも必要ありません。

おまけ

// コンパイルエラー
import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    @Published var info: (name: String, id: Int) = ("tkgling", 1234567890)
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.info.name) ID: \(user.info.id)")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Publishedもやっぱりタプルは使えませんでした。

import SwiftUI
import PlaygroundSupport

class UserinfoModel: ObservableObject {
    @Published var info: [String] = ["tkgling", "1234567890"]
}

struct ContentView: View {
    @ObservedObject var user = UserinfoModel()
    var body: some View {
        VStack {
            Text("Name: \(user.info[0]) ID: \(user.info[1])")
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

ただし、配列であれば使うことができます。

まとめ

自分の学習のためにStateとBindingについて調べたり実際にコードを書いてみたりしていたのですが、かなり自分の理解が進んだので非常に満足しています。

今回調べた内容を早速自分のアプリに組み込んでよりわかりやすいコードにしようと思います。

コメント

タイトルとURLをコピーしました