【SwiftUI】UIの状態を管理する

#この記事は何か
Apple Developerサイトより[Managing User Interface State](https://developer.apple.com/documentation/swiftui/managing-user-interface-state)を独自に解釈・翻訳したものです。

###環境
macOS 11.1
Xcode 12.4
Swift 5.3

#概要
ビュー全体で共有される「信頼できる情報源」を確立するために、データを必要とするビューの少なくとも共通の祖先にステートとしてデータを格納します。Swiftのプロパティを通して読み込み専用としてデータを提供するか、バインディングを使ってステートへの双方向の接続を作成します。SwiftUIはデータの変更を監視し、必要に応じて影響を受けるビューを更新します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/372e2c58-8059-2ccd-5694-ef6b91611194.png)
[WEBから引用]

状態変数のライフサイクルはビューのライフサイクルを反映しているため、状態プロパティを永続的なストレージに使用しないでください。代わりに、ボタンのハイライト状態、フィルタ設定、現在選択されているリスト項目など、UIにのみ影響を与える「過渡的な状態」を管理するために使用してください。また、この種のストレージは、アプリのデータモデルに変更を加える準備が整う前のプロトタイプの間にも便利です。

#状態を示す値の変化を管理する
ビューが変更可能なデータを保存する必要がある場合は、Stateプロパティのラッパーで変数を宣言します。例えば、ポッドキャストの`PlayerView`ビューの内部に「Bool型の`isPlaying`プロパティ」を作成して、ポッドキャストがいつ実行されているかを追跡することができます。

````Swift:
struct PlayerView: View {
    @State private var isPlaying: Bool = false
    
    var body: some View {
        // ...
    }
}
````

プロパティを`@state`としてマークすることで、基礎となるストレージを管理することをフレームワークに指示します。ビューはプロパティ名を使って、ステートの`wrappedValue`プロパティで見つけたデータを読み書きします。値を変更すると、SwiftUIはビューの影響を受ける部分を更新します。例えば、`PlayerView`ビューにボタンを追加して、タップした時に保存されている値を切り替えたり、保存されている値に応じて異なる画像を表示させたりすることができます。

````Swift:
Button(action: {
    self.isPlaying.toggle()
}) {
    Image(systemName: isPlaying ? "pause.circle" : "play.circle")
}
````

状態変数をプライベートとして宣言することで、状態変数のスコープを制限します。これにより、変数は宣言したビュー階層にカプセル化されます。

#固定値を保持するSwiftプロパティを宣言する
「ビューが変更しないデータ」をビューに提供するには、標準のSwiftプロパティを宣言します。例えば、「エピソードのタイトルと番組名」の文字列を含む入力構造を持つようにポッドキャストの`PlayerView`ビューを発展することができます。

````Swift:
struct PlayerView: View {
    let episode: Episode // The queued episode.
    @State private var isPlaying: Bool = false
    
    var body: some View {
        VStack {
            // Display information about the episode.
            Text(episode.title)
            Text(episode.showTitle)

            Button(action: {
                self.isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}
````

`PlayerView`型の`episode`プロパティは定数ですが、親ビューでは定数である必要はありません。ユーザーが親ビューで別のエピソードを選択した場合、SwiftUIは状態の変化を検出し、新しい入力値で`PlayerView`を再作成します。

#ステートの参照を共有するバインディング
ビューが子ビューと状態の制御を共有する必要がある場合、子ビューのプロパティ宣言を`@Binding`属性プロパティでマークします。バインディングは「既存のストレージへの参照」を表し、基礎となるデータの「信頼できる情報源」を保持します。例えば、ポッドキャストのプレイヤービューのボタンを`PlayButton`という子ビューにリファクタリングした場合、`isPlaying`プロパティへのバインディングを与えることができます。

````Swift:
struct PlayButton: View {
    @Binding var isPlaying: Bool
    
    var body: some View {
        Button(action: {
            self.isPlaying.toggle()
        }) {
            Image(systemName: isPlaying ? "pause.circle" : "play.circle")
        }
    }
}
````

上記のコードでは、ステートのようにプロパティを直接参照して「バインディングでラップされた値」を読み書きしています。しかし、ステートプロパティとは異なり、バインディングはそれ自身のストレージを持ちません。その代わりに、「どこか別の場所に保存されているステートプロパティ」を参照して、その保存領域（ストレージ）への双方向な接続を提供します。

`PlayButton`のインスタンスを作成する際にはドル記号`$`を付けて、「親ビューで宣言されたステート変数」に対応するバインディングを渡します。

````Swift:
struct PlayerView: View {
    var episode: Episode
    @State private var isPlaying: Bool = false
    
    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle)
            PlayButton(isPlaying: $isPlaying) // Pass a binding.
        }
    }
}
````

接頭辞`$`は「ラップされたプロパティ」に、その`projectedValue`を要求します。同様に、接頭辞`$`を使用して「バインディングからバインディングを取得する」こともでき、いくつものビュー階層にバインディングを渡すことができます。

また、状態変数のスコープ内で「値へのバインディング」を取得することもできます。例えば、プレイヤーの親ビューに「状態変数として`Bool`型`isFavorite`プロパティを持つ`Episode`型の`episode`プロパティ」を宣言し、その`isFavorite`プロパティをトグルで制御したい場合は、`$episode.isFavorite`を参照して「エピソードのお気に入り状態」へのバインディングを取得できます。

````Swift:
struct Podcaster: View {
    @State private var episode = Episode(title: "Some Episode",
                                         showTitle: "Great Show",
                                         isFavorite: false)
    var body: some View {
        VStack {
            Toggle("Favorite", isOn: $episode.isFavorite) // Bind to the Boolean.
            PlayerView(episode: episode)
        }
    }
}
````

#状態変化のアニメーション
ビューの状態が変化すると、SwiftUIは影響するビューの表示を即座に更新します。視覚的な遷移をスムーズにしたい場合は、`withAnimation(_:_:)`関数の呼び出しで「トリガーとなる状態変化」をラップすることで、アニメーションさせることをSwiftUIに指示できます。例えば、`isPlaying`プロパティによって制御される変化をアニメーションさせることができます。

````Swift:
withAnimation(.easeInOut(duration: 1)) {
    self.isPlaying.toggle()
}
````

ボタン画像のスケール効果と同様に、アニメーション関数の末尾のクロージャの中で`isPlaying`プロパティを変更することで、ラップされた値に依存するものは何でもアニメーションさせるようにSwiftUIに指示します。

````Swift:
Image(systemName: isPlaying ? "pause.circle" : "play.circle")
    .scaleEffect(isPlaying ? 1 : 1.5)
````

SwiftUIは、指定したカーブと持続時間、または何も指定しない場合は合理的なデフォルト値を使用して、与えられた値（`1`と`1.5`）の間で入力されたスケール効果を時間の経過とともに遷移させます。一方で、同じブール値が「どのシステムイメージを表示するか」を決定しているにもかかわらず、イメージの内容はアニメーションの影響を受けません。これは、SwiftUIが`"pause.circle"`と`"play.circle"`の2つの文字列の間で意味のある方法でインクリメンタルに遷移できないからです。

アニメーションをステートプロパティに追加することもできますし、上の例のようにバインディングに追加することもできます。いずれにしても、SwiftUIは、「基礎となるストレージの値」が変化したときに起こるビューの変化をアニメーション化します。例えば、アニメーションブロックの位置よりも上位のビュー階層の`PlayerView`ビューに背景色を追加した場合、SwiftUIはそれもアニメーション化します。

````Swift:PlayerView
VStack {
    Text(episode.title)
    Text(episode.showTitle)
    PlayButton(isPlaying: $isPlaying)
}
.background(isPlaying ? Color.green : Color.red) // Transitions with animation.
````

状態の変化に関わるすべてのビューではなく、特定のビューだけをアニメーションさせたい場合は`animation(_:)`モディファイアを使用します。