🦔
SwiftUIの@StateとView Identityの関係
View IDの違いによる@Stateの挙動の違い
- それぞれIncremntボタンを3回タップするとそれぞれのcountが3に増加
- ToggleボタンをタップするとChildViewのinitが走る
- Aのcountは3のままで、B, Cはcountは0にinit(リセット)される
- A, B, CどれもisOnは更新されている
import SwiftUI
struct ParentView: View {
@State var isOn = true
var body: some View {
VStack {
Toggle("Toggle", isOn: $isOn)
Section("A") {
// A
ChildView(count: 0, isOn: isOn)
}
Section("B") {
// B
if isOn {
ChildView(count: 0, isOn: isOn)
} else {
ChildView(count: 0, isOn: isOn)
}
}
Section("C") {
// C
ChildView(count: 0, isOn: isOn)
.id(isOn)
}
}
}
}
struct ChildView: View {
@State var count: Int
var isOn: Bool
init(count: Int, isOn: Bool) {
self.count = count
self.isOn = isOn
}
var body: some View {
VStack {
Text("IsOn: \(isOn)")
Text("Count: \(count)")
.bold()
Button("Increment") {
count += 1
}
.buttonStyle(.borderedProminent)
}
.padding(30)
.border(.red, width: 2)
}
}
#Preview {
ParentView()
}
A/B/CのView ID
- AのView IDは
Structural Identity
- BのView IDは
isOn
を基準にifで分岐しているためisOnが関与したStructural Identity
- SwiftUI._ConditionalContent
- CのView IDは
.id(isOn)
で指定しているためExplicit Identity
- SwiftUI.IDView
// A
ChildView(count: 0, isOn: isOn)
// B
if isOn {
ChildView(count: 0, isOn: isOn)
} else {
ChildView(count: 0, isOn: isOn)
}
// C
ChildView(count: 0, isOn: isOn)
.id(isOn)
@Stateの仕様(考察)
@Stateはキャッシュ<Key, Value>のような動きをし、リセット(新規作成)するためにはKey: View IDの変更(別Key作成)が必要です。
var cache: Cache<ViewID, @State.Value> = [:]
A/B/Cでの@Stateの挙動の違い
- A(ChildView)のView IDはStructural Identityのため、View IDに関係のないisOnが変更されても@Stateはリセットされません。
- B(ChildView)はStructural IdentityにisOnが関与しているためView IDが変更され@Stateがリセットされます
- C(ChildView)はExplicit IdentityであるisOnに変更があったため@Stateがリセットされます
A/B/Cのパフォーマンス
Xcode/Insruments/SwiftUIで描画時間/コストを確認するとAのパターンが1番抑えられています。
おそらくAはisOnを変更しただけ、B/CはViewそのものを再生成しているためです。
Toggleを100回切り替えた際の描画時間
パフォーマンス確認用コード(それぞれに名前付け)
struct ParentView: View {
@State var isOn = true
var body: some View {
VStack {
Toggle("Toggle", isOn: $isOn)
Section("ChildViewA") {
ChildViewA(isOn: isOn)
}
Section("ChildViewB") {
ChildViewB(isOn: isOn)
}
Section("ChildViewC") {
ChildViewC(isOn: isOn)
}
}
}
}
struct ChildViewA: View {
var isOn: Bool
var body: some View {
ChildView(count: 0, isOn: isOn)
}
}
struct ChildViewB: View {
var isOn: Bool
var body: some View {
if isOn {
ChildView(count: 0, isOn: isOn)
} else {
ChildView(count: 0, isOn: isOn)
}
}
}
struct ChildViewC: View {
var isOn: Bool
var body: some View {
ChildView(count: 0, isOn: isOn)
.id(isOn)
}
}
struct ChildView: View {
@State var count: Int
var isOn: Bool
init(count: Int, isOn: Bool) {
self.count = count
self.isOn = isOn
}
var body: some View {
VStack {
Text("IsOn: \(isOn)")
Text("Count: \(count)")
.bold()
Button("Increment") {
count += 1
}
.buttonStyle(.borderedProminent)
}
.padding(30)
.border(.red, width: 2)
}
}
SwiftUI.List内での挙動
ParentViewのVStack
をList
に変更して、Toggleを切り替えるとB, Cでcount: 0にリセットされた筈の@Stateが復元され、またcount: 3が表示されます。
おそらくListはデータの再利用機能があり、それぞれのCellがView IDに紐づいた@Stateを持ち、それをキャッシュ<Key, Value>から復元しています。
そのためB, Cのパターンを使う場合はこの挙動に気をつける必要があります。
Listに置き換えただけのParentView
struct ParentView: View {
@State var isOn = true
var body: some View {
List {
Toggle("Toggle", isOn: $isOn)
Section("A") {
// A
ChildView(count: 0, isOn: isOn)
}
Section("B") {
// B
if isOn {
ChildView(count: 0, isOn: isOn)
} else {
ChildView(count: 0, isOn: isOn)
}
}
Section("C") {
// C
ChildView(count: 0, isOn: isOn)
.id(isOn)
}
}
}
}
SwiftUI.Listのバグ?
@Stateをキャッシュ<View ID, @StateのValue>という風に考えた際に、SwiftUI.Listが上記のように動作することは仕様通りと考えたのですが、以下の手順で操作するとステップ5でB/C, 4/5の@Stateが復元されるはずが0/0になってしまいます。これはバグのように思えます...
ステップ | isOn | A.count | B.count | C.count | コメント |
---|---|---|---|---|---|
1 | true | 1 | 2 | 3 | A/B/Cのcountを1/2/3にincrement |
2 | false | 1 | 0 | 0 | isOnをfalseに変更(B/Cが0にリセット) |
3 | false | 1 | 4 | 5 | B/Cのcountを4/5にincrement |
4 | true | 1 | 2 | 3 | isOnをtrueに戻す(ステップ1でincrementしたB/Cの2/3が復活 |
5 | false | 1 | 0 |
0 |
isOnをfalseに戻す(ステップ3でincrementしたはずのB/C, 4/5が消えている ) |
View Componentsのテスト観点
Viewの部品の動作確認の1つとしてSwiftUI.List内に置いても正しく動作する
があるとテスト観点として良さそうです。
参考資料
Discussion