🧐

[SwiftUI] ViewのIdentityと再描画を意識しよう

2023/08/01に公開
2

SwiftUIはViewをどのように管理しているのでしょうか?その裏側には、Identityという仕組みがあります。 SwiftUIはIdentityによってViewを管理し、また、再描画についてもこのIdentityが関わっています。
この記事は、Identityを理解することでSwiftUIの再描画について意識できるようにし、どのようにコードを書けばSwiftUIの描画システム的にパフォーマンスの良いアプリが作れるのかを実践していきます。

「View Identityの概念・挙動はもう完璧に知ってるよ」という方は、「(考察)SwiftUIの描画ロジック」から見ていただければと思います。

View Identity

WWDC2021 Demystify SwiftUIで解説があります。

https://developer.apple.com/videos/play/wwdc2021/10022/

Identity is how SwiftUI recognizes elements as the same or distinct across multiple updates of your app.
(翻訳)Identityは、SwiftUIがアプリの複数の更新にわたって同じまたは異なる要素を認識する方法です。

つまり、Viewの要素が同一かどうかを識別するものです。
View Identityには2種類あります。Explicit IdentityStructural Identityです。

Explicit Identity

明示的な値によって管理されるIdentityです。

Explicit Identityとして指定している値が変化すると、Viewが別の要素として認識されます。
文字通り、明示的に指定された場合のみ Explicit Identityを持つと考えられます。(コンポーネントが内部的に行っている場合はあるかもしれません)

.id

https://developer.apple.com/documentation/swiftui/view/id(_:)

@State var id = 1

var body: some View {
    VStack {
        Text("Hello World")
            .id(id)

        Button {
            id += 1
        } label: {
            Text("change id")
        }
    }
}

ボタンを押すとidが変化するため、Textが別のIdentityに変化します。
Text("Hello World").id(1)Text("Hello World").id(2)が別の要素としてSwiftUIに認識されるというのが重要なポイントです。

ForEach(_:id:content:)

https://developer.apple.com/documentation/swiftui/foreach/init(_:id:content:)-82hm4

@State var messages = ["A", "B"]

var body: some View {
    VStack {
        ForEach(messages, id: \.self) { message in
            Text(message)
        }
    }
}

ForEachは以下のように展開されていると考えられます。

var body: some View {
    VStack {
	Text(messages[0])
	    .id(messages[0])
	
	Text(messages[1])
	    .id(messages[1])
    }
}

Structural Identity

View構造と型によって管理されるIdentityです。Explicit Identityは明示的に利用する一方で、Structural IdentityはView構造と型によって管理されるため、全てのViewに存在するIdentityだと言えます。

Structural Identityを一言で言うと、Viewをn回書いたらn個別々のIdentityを持つ=n個実態が作られるです。より具体的に言うと、(body等の)Viewの型によって管理され、型情報上で別のViewと表現されているViewは別のIdentityを持ちます。

まず、このコードで2回書いたHogeViewがそれぞれ別のIdentityを持ち、別々の実態として動作することは直感的に理解できるかと思います。

var body: some View {
    VStack {
        HogeView()
	
	HogeView()
    }
}

type(of: self.body)を出力してbodyの型情報を見てみましょう。

var body: some View {
    VStack {
        HogeView()

        HogeView()
    }
    .onAppear {
        print(type(of: self.body))
    }
}
VStack<TupleView<(HogeView, HogeView)>>

(本来は.onAppearで出力しているのでModifiedContent<..., _AppearanceActionModifier>が出力されていますが、出力を加工しています)
TupleView<(HogeView, HogeView)>のようにHogeViewが2回出現しています。
これは、2つのHogeViewがそれぞれ別のIdentityを持つことを意味します。

Structural Identityはこれが全てです。
例えレイアウト上で表示される位置が同じでも、見た目が同じでも、型が同じでも、引数等の値が同じでも、ViewBuilder上で2回HogeView()と書いたら、それぞれは別々のIdentityを持ち、別々の要素として認識され、実態も二つ持ちます。

Viewをn回書いたらn個別々のIdentityを持つ が全てで新しい法則は出てきませんが、ifやswitchについてもみていきましょう。

if

Viewをn回書いたらn個別々のIdentityを持つに基づき、AとBのHogeViewは別のIdentityとして管理されます。

var body: some View {
    if condition {
        // A
        HogeView()
    } else {
        // B
        HogeView()
    }
}

bodyの型

_ConditionalContent<HogeView, HogeView>

HogeViewそれぞれが別のIdentityを持つことがわかります。
例えレイアウト的に、同じ位置に同じ見た目のViewだったとしても、ifを使っている以上、bodyの型情報には影響しないため、別のIdentityとして管理されます。

また、computed propertyfunctionを利用して呼び出しを共通化しても、生成されるbodyは同じなので、別のIdentityとして管理されるのに変わりありません。(これは、SwiftUIに関係なくcomputed propertyfunctionの挙動として理解できると思います。)

var body: some View {
    if condition {
        content
    } else {
        content
    }
}

var content: some View {
    HogeView()
}

bodyの型

_ConditionalContent<HogeView, HogeView>

_ConditionalContentという型がでてきました。これは条件分岐を表現する型で、_ConditionalContent<Trueの時, Falseの時>という表現になります。

switch

Viewをn回書いたらn個別々のIdentityを持つに基づき、swtichの各分岐も別のIdentityとして管理されます。

enum Kind {
    case a
    case b
    case c
}

@State private var kind: Kind = .a

var body: some View {
    switch kind {
    case .a:
        HogeView()

    case .b:
        HogeView()

    case .c:
        HogeView()
    }
}

bodyの型

_ConditionalContent<_ConditionalContent<HogeView, HogeView>, HogeView>>

switchも_ConditionalContentで表現されています。
ViewBuilderの出力から見ると、ifとswitchに本質的な違いはなさそうです。

ここが大事なんですが、switchでenumのcaseをまとめて記述すると、Identityを同一にできます。(これもViewをn回書いたらn個別々のIdentityを持つに基づいています。enumの.a, .bでHogeViewを共通化できているので理解できる挙動かと思います。)

var body: some View {
    switch kind {
    case .a, .b:
	// enumがa<->bで変化しても、Identityに変化なし
        HogeView()

    case .c:
        HogeView()
    }
}

bodyの型

_ConditionalContent<HogeView, HogeView>

_ConditionalContentの分岐が一つ減り、enumの.a, .b間ではView Identityが共通なことが理解できると思います。

また、全てのcaseをまとめて書くと_ConditionalContentすら消えるのも面白い点です。

var body: some View {
    switch kind {
    case .a, .b, .c:
        HogeView()
    }
}

bodyの型

HogeView

View Identityはどんな影響を与えるか

Identityがどんな物かを理解したところで、「じゃあIdentityってどんな影響を与えるの?」という話です

状態(State, StateObject)はIdentityごとに管理される

以下の状態を持つCounterViewを考えましょう。

struct CounterView: View {

    @State var count = 0

    var body: some View {
        Button {
            count += 1
        } label: {
            Text(count.description)
                .font(.title)
        }
    }
}

Explicit Identity

@State var id = 1

var body: some View {
    VStack {
        CounterView()
            .id(id)

        Button {
            id += 1
        } label: {
            Text("change id")
        }
    }
}

"change id"のボタンを押すと、カウンタがリセットされます。
これは、CounterViewのExplicit Identityを変更しており、Identityごとに管理されている状態(@State)が削除されるためです。

また、ViewのライフサイクルもIdentityによって管理されているため、Explicit Identityの変更によってonAppear, onDisappearも呼ばれます。
(Identityの変更によってViewが別の要素に変化したと考えると、理解できる挙動かなと思います)

@State var id = 1

var body: some View {
    VStack {
        CounterView()
	    .onAppear {
                print("appear")
            }
            .onDisappear {
                print("disappear")
            }
            .id(id)

        Button {
            id += 1
        } label: {
            Text("change id")
        }
    }
}

なお、.id()の外側に書くと呼ばれません。Identityが変化しているのは.id()の内側のViewのみということがわかります。

@State var id = 1

var body: some View {
    VStack {
        CounterView()
            .id(id)
	    .onAppear {
                print("appear")
            }
            .onDisappear {
                print("disappear")
            }

        Button {
            id += 1
        } label: {
            Text("change id")
        }
    }
}

Structural Identity

@State var condition = true

var body: some View {
    VStack {
        if condition {
            CounterView()
        } else {
            CounterView()
        }

        Button {
            condition.toggle()
        } label: {
            Text("toggle")
        }
    }
}

"toggle"のボタンを押すと分岐の一方のViewが見えなくなり、他方のViewが見えることで状態がリセットされたように見えます。分岐間で状態は共有されません。なぜならIdentityが異なるからです。

Identityが変わると該当Viewが「完全に再描画」される

Identityが違うViewは別の要素として扱われるため、例え見た目が完全に同じでも、「完全に再描画」 されます。

「完全に再描画」というのは、「コンポーネントを新しく0から生成し、画面に表示する」 という意味です。UIKitのレイヤーの話をすると、「該当UIKitのコンポーネントのインスタンスを新しくinitしてaddSubViewする」 処理に相当します。

「完全に再描画」と「描画のアップデート」は違う

SwiftUIには 「描画のアップデート」 も存在します。一般的にSwiftUIで「再描画」と言われたらこちらをイメージすると思います。これはViewで用いている@Stateの値が更新された時に起きる挙動で、 「コンポーネントの必要なパラメーターを変更して表示をアップデートする」 という意味です。UIKitのレイヤーの話をすると、 「該当UIKitのコンポーネントの必要なパラメーターを変更する」 処理に相当します。

例えば、以下のようなViewでcountを増やした際にボタンのテキストが0->1->2と増えていくと思います。この際は「描画のアップデート」が行われています。

@State var count = 0

var body: some View {
    Button {
	count += 1
    } label: {
	Text(count.description)
    }
}

「完全に再描画」と「描画のアップデート」は違うと言える根拠

以下のような描画が重いViewを用意します。
これはScrollViewで1万個のTextを表示するViewです。各Textで表示する数字に足す変数addingを親Viewから渡すことができます。

struct HeavyView: View {

    let adding: Int

    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(0..<1_000_0, id: \.self) { index in
                    Text((index + adding).description)
                }
            }
        }
    }
}

Identityを変更する例

Explicit IdentityStructural Identityの例を両方用意しましたが挙動はほぼ同じです。
ボタンを押すとHeavyViewに渡すaddingを増やし、HeavyViewの表示を更新します。Explicit Identityでは.id(adding)とすることで、Structural Identityではifを用いることでIdentityを変更します。

struct HeavyViewCheckExplicitIdentityChange: View {
    @State var adding = 0

    var body: some View {
        VStack {
            HeavyView(adding: adding)
                .id(adding)

            Button {
                adding += 1
            } label: {
                Text("adding")
            }
        }
    }
}

struct HeavyViewCheckStructuralIdentityChange: View {
    @State var adding = 0
    @State var condition = true

    var body: some View {
        VStack {
	    if condition {
                HeavyView(adding: adding)
	    } else {
	        HeavyView(adding: adding)
	    }

            Button {
                adding += 1
		condition.toggle()
            } label: {
                Text("adding")
            }
        }
    }
}

値を更新する例

HeavyViewに純粋にaddingを渡すだけです。

struct HeavyViewCheck: View {
    @State var adding = 0

    var body: some View {
        VStack {
            HeavyView(adding: adding)

            Button {
                adding += 1
            } label: {
                Text("adding")
            }
        }
    }
}

Xcode>Open Developer Tool>Instruments>Time Profiler を用いてそれぞれのパフォーマンスを測定します。

Identityを変更 値を更新

ボタンを押した際のHangに着目すると

  • Identityを変更: 1.43s
  • 値を更新: 538.27ms≒0.54s

と、これらの再描画に明らかな違いがあることがわかります。

実際の画面でも、Hangの長さの違いがわかると思います。

Identityを変更 値を更新

以上より、

  • Identityを変更->「完全に再描画」: コンポーネントを0から生成し直し、画面に表示する
  • 値を更新->「描画のアップデート」: コンポーネントの必要なパラメーターを更新し、画面表示をアップデートする

という違いがあると考えられます。

「完全に再描画」だとScrollViewText1万個のインスタンスを再生成しているのに対し、「描画のアップデート」だと既に表示されているTextの文字列パラメーターを変更し、表示をアップデートするだけなので、描画コストが比較的低く済んでいると推測できます。

今回は差を証明するために極端な例を採用しましたが、実際の開発でユーザーが知覚できる違い (60FPS=16ms以上, 120FPS=8ms以上) かどうかは場合によると思います。ただ、差があることは事実なので、可能な限りIdentityの変更は避け、「描画のアップデート」で済むように書くのが良いと思います。

存在しなくなったIdentityに紐づく状態・Viewは即削除される

A, Bの二つのIdentityがあったとします。
A->Bと変化した時点でAの状態やViewは削除されるので、A->B->Aと後から戻ってきても状態は残っていませんし、Viewの描画時にキャッシュなどは効きません。つまり毎回「完全に再描画」されます。

Explicit Identityの例

@State var id = 1

var body: some View {
    VStack {
        CounterView()
            .id(id)


        Text("id: \(id)")

        HStack {
            Button {
                id -= 1
            } label: {
                Text("-")
                    .font(.title)
            }

            Button {
                id += 1
            } label: {
                Text("+")
                    .font(.title)
            }
        }
    }
}

Structural Identityの例

@State var condition = true

var body: some View {
    VStack {
        if condition {
            CounterView()
                .foregroundColor(.red)
        } else {
            CounterView()
                .foregroundColor(.yellow)
        }

        Button {
            condition.toggle()
        } label: {
            Text("toggle")
        }
    }
}

アニメーション中はIdentityが延命される(っぽい)

アニメーションをつけると、そのアニメーション中にA->B->Aと戻ってこれば状態は維持できます。アニメーション終了後だとダメです。
これはSwiftUIがアニメーションにIdentityを利用しているのが影響していると思います。

@State var condition = true

var body: some View {
    VStack {
        if condition {
            CounterView()
                .foregroundColor(.red)
        } else {
            CounterView()
                .foregroundColor(.yellow)
        }

        Button {
            withAnimation(.linear(duration: 1.0)) {
                condition.toggle()
            }
        } label: {
            Text("toggle")
        }
    }
}

Listは最新1個のIdentityをキャッシュする(っぽい)

これは意味がわからないです。ListはSwiftUIのコンポーネントの中でも特に謎の挙動をするので、「Listは内部でゴチャゴチャやっている」と覚えておくとバグを踏んだ時に助けになるかもしれません

@State var id = 0

var body: some View {
    List {
        CounterView()
            .id(id)


        Text("id: \(id)")

        HStack {
            Text("-")
                .font(.title)
                .onTapGesture {
                    id -= 1
                }

            Text("+")
                .font(.title)
                .onTapGesture {
                    id += 1
                }
        }
    }
}

ちなみにList(datas) { data in }List { ForEach(datas) { data in } }ではこの現象は起きません。List { }List(datas) { data in }は内部でやっていることが違うようです。

(考察)Identity = Structural & Explicit なのでは?

今まで、「Explicit Identityの場合」「Structural Identityの場合」と、排他的であるかのような構造で挙動を確認してきましたが、これらは排他的ではないと考えています。さらに、Structural Identityの方が上位だと考えています

つまり、Identityの同値チェックは以下のようなロジックで行われているのではないかと考えています。

// 擬似コード
extension View.Identity {
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.structural == rhs.structural && lhs?.explicit == rhs?.explicit
    }
}

根拠1: IDView

.id()の型はIDViewという非公開の型になっています。

var body: some View {
    Text("Hello World")
        .id(1)
}

bodyの型

IDView<Text, Int>

swiftinterface(./Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator/prebuilt-modules/16.4/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface
でIDViewを検索すると、以下がヒットしました。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@usableFromInline
@frozen internal struct IDView<Content, ID> : SwiftUI.View where Content : SwiftUI.View, ID : Swift.Hashable {
  @usableFromInline
  internal var content: Content
  
  // idを持っている
  @usableFromInline
  internal var id: ID
  @inlinable internal init(_ content: Content, id: ID) {
        self.content = content
        self.id = id
    }
  @usableFromInline
  @_Concurrency.MainActor(unsafe) internal var body: Swift.Never {
    get
  }
  @usableFromInline
  internal typealias Body = Swift.Never
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension SwiftUI.View {
  // .id()の実装
  @inlinable public func id<ID>(_ id: ID) -> some SwiftUI.View where ID : Swift.Hashable {
        return IDView(self, id: id)
    }
  
}

IDViewvar id: IDを持っていることがわかりますし、.id()を呼ぶとIDView(self, id: id)が返されることがわかります。

一般的なmodifierはModifiedContentとなる一方で、わざわざIDViewを作っているのはこのIDViewがExplicit Identityを管理しているのではないでしょうか。Explicit Identityを保持しているIDViewが型で表現されているということは、Structural Identityの概念の上に存在しているということになります。
以上から、Explicit Identityは同一のStructural Identityの中でのidなのではないでしょうか。

根拠2: Structural Identityを跨いでExplicit Identityを適応できない

if分岐のそれぞれにCounterViewを定義し、同じExplicit Identityを持たせても、状態は共有されません。(ifの切り替え時に.id(1)が一瞬存在しなくなることで、状態が消えている可能性を排除するため、アニメーションによってIdentityの生存期間を延長していますが、それでもダメです。)
これも、Explicit Identityは同一のStructural Identityの中でのidと考えられるのではないでしょうか。

@State var condition = true

var body: some View {
    VStack {
	if condition {
            CounterView()
                .id(1)
	} else {
	    CounterView()
                .id(1)
	}

        Button {
            withAnimation(.linear(duration: 1.0)) {
                condition.toggle()
            }
        } label: {
            Text("toggle")
        }
    }
}

反例: ForEach

ForEachを用いると、上記の根拠の反例となる挙動を作れます。

まず、以下のような二つのViewを用意します。

  • ForEachCheckA: [1, 2]の配列をForEachで展開するView
  • ForEachCheckB: [1, 2]の配列をForEachを使わずに記述し、.id()を用いてExplicit Identityを付与したView

また、ボタンを押すと[1,2][2,1]に反転します。

struct ForEachCheckA: View {

    @State var numbers = [1, 2]

    var body: some View {
        VStack {
            ForEach(numbers, id: \.self) { number in
                CounterView()
            }

            Button {
                withAnimation {
                    numbers.reverse()
                }
            } label: {
                Text("reverse")
            }
        }
    }
}

struct ForEachCheckB: View {

    @State var numbers = [1, 2]

    var body: some View {
        VStack {
            CounterView()
                .id(numbers[0])

            CounterView()
                .id(numbers[1])

            Button {
                withAnimation {
                    numbers.reverse()
                }
            } label: {
                Text("reverse")
            }
        }
    }
}
ForEachCheckA ForEachCheckB

ForEachで記述すると、状態を保持したままカウンターの位置が入れ替わる(!!)一方でForEachなしで記述すると、状態が消えます。
(冒頭で触れた、ForEachと、ForEachを使わず.id()を付与するViewが同等ではない、という話もこの挙動からわかります。)

ForEachは内部に複数のViewを持っていると考えられるので、Viewをn回書いたらn個別々のIdentityを持つに基づいて考えれば、それらのViewは別々のIdentityを持つはずです。つまり、この例ではStructural Identityを跨いでExplicit Identityを適応できていることになります。

一方で「ForEachが例外なんだ」とご都合主義な解釈をすれば、
ForEach内では特別にStructural Identityが一つの扱いになっているのではないか? (もしくは、Explicit Identityの影響を特別に強くしている)という考え方もできると思います。なお、Listでも同様の挙動が起きます。

これは完全にただの考察ですので、他に面白い根拠や反例があればぜひ教えてください。

(考察)SwiftUIの描画ロジック

「完全に再描画」と「描画のアップデート」の2種類があるという話をしました。
これをもう少し掘り下げて、SwiftUIの描画ロジックについて考察したいと思います。

「完全に再描画」をするかどうかの判断に使われるのがIdentityでしたが
同一のIdentityのViewが見つかった後、「描画のアップデート」をするかどうかの判断にはEquatableが使われます。Viewのプロパティが全てEquatableの場合なら、Equatableに明示的に準拠していなくても同様の挙動します。

https://developer.apple.com/documentation/swiftui/equatableview

A view type that compares itself against its previous value and prevents its child updating if its new value is the same as its old value.
(翻訳)自分自身と以前の値を比較し、新しい値が古いと同じ場合、子要素の更新を防止するViewの型です。

SwiftUIのViewの描画ロジックは以下のようになっていると考えています(想像です)。

  1. Identityが同値か?:
    • Identityが変化していない場合はViewの同値判定へ移ります。
    • 変化している場合は子Viewも含めViewを完全に再描画します。
  2. Viewが同値か?: Equatableによる判定をします。
    • 同値の場合は描画のアップデートなしとして判定を終了します。この場合はプロパティを見るだけでbodyは呼び出されません。
    • 同値ではない(またはEquatableではなく比較ができない)場合はViewのbodyを呼び出して次のステップに進みます。
  3. 描画実態を持つか?: 描画実態を持つView(後述)の場合は自身の描画のアップデートを行います。
  4. 子Viewの評価へ再帰:
    • bodyに子Viewが存在する場合は全ての子Viewに対して1〜4のViewの評価を実行します(再帰)。
    • 存在しない場合は(再帰を)終了します。

ここで大事なポイントは Equatableの評価は再帰的にViewごとに行われるため、親のbodyが呼び出されたからといって、子のbodyが呼び出されているとは限らない という点です。(一方でIdentityの変化は子を含め完全に再描画します)

描画実態を持つ・持たないView

「描画実態を持つView」 というのは、(私が作った言葉ですが)以下の2種類のViewのことを指しています。

  • Text, Image, Color, VStackなどのSwiftUIから提供されているView全般
  • UIView(Controller)Representableによって作られたView

これらのViewは実際の描画を管理するViewになっており、これらに渡すパラメーターが変更されたら実際に描画に影響します。

「描画実態を持たないView」 というのは、以下のようなViewのことを指しています。
以下のChildViewは描画実態を持ちません、描画実態を持っているのはChildViewの子ViewであるTextであって、ChildViewではないです。(と定義します)

struct ChildView: View {
    let message: String

    var body: some View {
        Text(message)
    }
}

描画実態を持たないViewのbodyが呼ばれたこと自体は描画に影響しない

こういう定義(見方)をすると何が嬉しいのかというと
描画実態を持たないViewのbodyが呼ばれたこと自体は描画に影響しない という見方ができます。

以下の例を見てみましょう。ChildViewは受け取ったlet message: StringTextで表示するだけのViewです。Equatableでの判定をfalseにするために、適当な値flag: Boolを定義しています。

struct ChildView: View {

    let flag: Bool
    let message: String

    var body: some View {
        let _ = Self._printChanges()
        Text(message)
    }
}

struct ParentView: View {

    @State var condition = true

    var body: some View {
        VStack {
            ChildView(flag: condition, message: "Hello")

            Button {
                condition.toggle()
            } label: {
                Text("toggle")
            }
        }
    }
}

ParentViewmessageは定数値にし、flagだけを変えます。この時、ChildViewbodyが呼ばれlet _ = Self._printChanges()は出力されますが、これ自体は描画がアップデートされていることを示すわけではありません
なぜなら、bodyが呼び出されているのは描画実態を持たないChildViewであって、描画実態を持つTextではありません。

また、実際に、TextEquatableに準拠しており、messageが変化していないため、 Equatableの判定によってTextのbodyは呼び出されておらず、Textの描画もアップデートされていません。 結果、全体としても描画はアップデートされていないということになります。

このように、描画実態を持たないView(自作Viewの大半)のbodyが呼びされても描画には影響しません。描画に影響するのは、描画実態を持つViewのbodyが呼び出された時です。
重要なのは、「Viewのbodyが呼ばれる」≠「描画がアップデートされる」=「描画実態を持つViewのbodyが呼ばれる」 という点です。

ただ、bodyの呼び出しを全く気にしなくて良いというわけではありません。 描画に直接影響しなくとも、bodyはメインスレッドで呼ばれるため、メインスレッドのCPU時間を消費し、描画エンジンが利用できるCPU時間が減ることで結果的に描画にも影響します。

実践: View Identityを意識してレイアウトを組もう

以上の知識を踏まえて、どのようにレイアウトを組むべきか見ていきます。

Explicit Identityにはuniqueかつstableなidを使おう

ForEachなどに用いるidは unique(重複しない) かつ stable(変化しない) 物にしましょう。
メッセージを一覧表示する画面を考えます。

NotGood: uniqueじゃない

@State var message = ""
@State var messages = ["Hello", "World", "Good"]

var body: some View {
    List {
        ForEach(messages, id: \.self) { message in
            Text(message)
        }

        TextField("new message", text: $message)

        Button {
            withAnimation {
                messages.insert(message, at: 0)
            }
            message = ""
        } label: {
            Text("add")
        }
    }
}

messageはユーザーが任意に入力できるので、uniqueではないです。
同じ文字列を入力した際以下のようなwarningが出力される上、アニメーションも不安定になります。

ForEach<Array<String>, String, Text>: the ID Hello occurs multiple times within the collection, this will give undefined results!

Not Good: stableじゃない

var body: some View {
    List {
        ForEach(messages.indices, id: \.self) { index in
            Text(messages[index])
                .listRowRandomColor()
        }

        TextField("new message", text: $message)

        Button {
            withAnimation {
                messages.insert(message, at: 0)
            }
            message = ""
        } label: {
            Text("add")
        }
    }
}

messageはuniqueじゃないので、indexを使うことにしました。これはuniqueですが、stableではありません。メッセージを上部に追加すると、indexがズレるので、メッセージに対してidが変化しています。
(メッセージをローカルで追加するケースだけでなく、APIから新規メッセージを取得する場合も同様の挙動が発生します。)

index(id) message
1 Hello
2 World
3 Good

↓ Byeを追加

1 Bye
2 Hello // idが変化
3 World // idが変化
4 Good // idが変化

これだと、追加したメッセージの部分だけ描画をアップデートをすれば良いのに、メッセージに対してidが変化するため、すべての行で描画のアップデートが必要になります。

すべての行で描画のアップデートが行われている様子

以上より、表示する内容がuniqueではない場合は、UUIDなどのuniqueなIDを別途用意して、idとして利用しましょう。

Good

struct Message: Identifiable {
    var id: UUID
    var text: String

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
    }
}

struct ForEachDiffGood: View {

    @State var message = ""
    @State var messages: [Message] = [Message(text: "Hello"), Message(text: "World"), Message(text: "Good")]

    var body: some View {
        List {
            ForEach(messages) { message in
                Text(message.text)
            }

            TextField("new message", text: $message)

            Button {
                withAnimation {
                    messages.insert(Message(text: message), at: 0)
                }
                message = ""
            } label: {
                Text("add")
            }
        }
    }
}

新しいメッセージのみが描画のアップデートをされている様子

実際の開発ではAPIやDBなどを用いるケースの方が多く、そこにidが存在するケースが多いと思いますので、実際に強く意識することは少ないかもしれません。

カスタマイズはifではなくmodifier内の三項演算子で行おう

Viewの見た目をカスタマイズする際は極力ifを使わず、modifier内の三項演算子で行いましょう。
そもそも、Structural Identityの挙動からわかる通り、ifという物は「二つの異なるViewを出し分ける」物であって、「一つのViewの見た目を変える」物ではないです。

Not Good

var body: some View {
    if condition {
        CounterView()
            .foregroundColor(.blue)
            .fontWeight(.bold)
    } else {
        CounterView()
            .foregroundColor(.red)
    }
}

bodyの型

_ConditionalContent<ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>, ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>>

Good

var body: some View {
    CounterView()
        .foregroundColor(condition ? .blue : .red)
        .fontWeight(condition ? .bold : .regular)
}

bodyの型

ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>

modifiler内の三項演算子はViewBuilderが生成する型に影響を与えないので_ConditionalContentがなくなっているのがわかります。

後者の方が良い理由は二つあります。

  • Identityが変わると状態が消えてしまうから
  • Viewの完全な再描画が行われてしまうから

です。今回の例では内部に状態(@State)を持つCounterViewの例ですが、状態を持たない場合も後者の影響があるので極力modifier内の三項演算子で行った方が良いです。

if { self } しているmodifierは危険

これについては以前書いた記事がありますのでそちらも参照

https://zenn.dev/kntk/articles/4e3538f402d171

先ほどの例で「いやーwwwそんなifでカスタマイズしないよw」と思った方も、これによって気づかない内に同様のことを行なっている可能性があります。

extension View {
    @ViewBuilder
    func `if`<Content: View>(
        _ condition: Bool,
        @ViewBuilder transform: (Self) -> Content
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }

    @ViewBuilder
    func `if`<TrueContent: View, FalseContent: View>(
        _ condition: Bool,
        @ViewBuilder _ then: (Self) -> TrueContent,
        @ViewBuilder `else`: (Self) -> FalseContent
    ) -> some View {
        if condition {
            then(self)
        } else {
            `else`(self)
        }
    }
}

このexntesionは以下のように条件に応じてmodifierを切り替えることができる物です。

var body: some View {
    CounterView()
        .if(condition) { view in
            view
                .foregroundColor(.blue)
                .fontWeight(.bold)
        } else: { view in
            view
                .foregroundColor(.red)
        }
}

一見、Viewをn回書いたらn個別々のIdentityを持つ に基づくと、CounterViewは1回しか書いてないのでIdentityは一つにように見えます。
ここで、bodyの型を出力してみましょう。

_ConditionalContent<ModifiedContent<ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>, _EnvironmentKeyTransformModifier<Array<AnyFontModifier>>>, ModifiedContent<CounterView, _EnvironmentKeyWritingModifier<Optional<Color>>>>

見てわかるとおり、_ConditionalContentが出現しており、ifでCounterViewを2回記述した時と同じ型になっていることがわかります。
それもそのはずで、.ifのextensionで以下のようにselfをif分岐でラップしているためです。

if condition {
    then(self)
} else {
    `else`(self)
}

先ほどの例ではselfCounterViewに相当するため、

CounterView()
    .if(condition) { view in
    } else: { view in
    }

if condition {
    CounterView()
} else {
    CounterView()
}

に展開されていると言うことです。

また、以下のようなextensionを利用時にifを利用した場合も同様です。

func extend<Content: View>(@ViewBuilder transform: (Self) -> Content) -> some View {
    transform(self)
}

CounterView()
    .extend {
        if condition {
	    $0.foregroundColor(.blue)
                .fontWeight(.bold)
	} else {
	    $0.foregroundColor(.red)
	}
    }

Structural Identityの本質は**ViewBuilderの中でViewを複数回記述すること** です。
それがmodifierの中やクロージャーの中にあっても、結果は同じです。

Structural Identityが内部で変化するようなmodifierは、Structural Identityが変化していることを利用側から隠蔽する危険なmodifierであると言えます。
可能な限り使わない方が良いと言えます。

三項演算子が使えないケース

modifierの中には引数が存在しない等で三項演算子が使えない物もあります。

var body: some View {
    // 特定のケースではonDragを無効化したいが`isActive:`がない
    // onDrag { if {} }では、UIでドラッグ自体はできてしまう
    Text("aaa")
        .onDrag { data in
	    ...
        }
}

こういったケースになって初めて ifを使うことを検討しましょう。
もちろん、状態の初期化と完全な再描画は起きていますので、それらを妥協できる場合に限ります。
(これはSwiftUIが未熟でよくない点ですね)

var body: some View {
    // isActive: がない
    if condition {
        Text("aaa")
            .onDrag { data in
	        ...
            }
    } else {
        Text("aaa")
    }
}

また、このケースになって初めて modifier内でifを使うextensionを記述する選択をしても良いかなと思います。
危険なmodifierであることに変わりないので注意は必要ですが、onDragの無効化はifを使うことでしか実現できないので、このケースでは便利なmodifierであると言えます。

@ViewBuilder
func onDrag(isActive: Bool, data: @escaping () -> NSItemProvider) -> some View {
    if isActive {
        self.onDrag(data)
    } else {
        self
    }
}

if, switchの分岐は可能な限り統合しよう

「異なるViewを出し分ける」際にはif,switchを使う必要がありますが、その際にも注意すべき点があります。
可能な限り分岐を統合して、Structural Identityを統合しましょう。

Not Good

enum UIState {
    case idle
    case loading
    case success
    case failure
}

@State var uiState: UIState = .idle

var body: some View {
    switch uiState {
    case .idle:
        ProgressView()

    case .loading:
        ProgressView()

    case .success:
        Text("success")

    case .failure:
        ErrorStateView()
    }
}
_ConditionalContent<_ConditionalContent<ProgressView<EmptyView, EmptyView>, ProgressView<EmptyView, EmptyView>>, _ConditionalContent<Text, ErrorStateView>>

Good

var body: some View {
    switch uiState {
    case .idle, .loading:
        ProgressView()

    case .success:
        Text("success")

    case .failure:
        ErrorStateView()
    }
}
_ConditionalContent<_ConditionalContent<ProgressView<EmptyView, EmptyView>, Text>, ErrorStateView>

_ConditionalContentが一つ減り、.idle.loadingのIdentityが統合できていることがわかります。

例: 状態管理に用いるenum

↓のようなenumを画面の状態管理に用いるケースがあると思います。

enum UIState<V, E: Error> {
    case idle
    case loading
    case success(V)
    case failure(E)
}

「再読み込み時ににも前回の結果を表示する」という仕様になったとしましょう。
この時、2通りの実装方法が考えられると思います。

A: loading(V?)

enum UIState<V, E: Error> {
    case idle
    case loading(V?)
    case success(V)
    case failure(E)
}

B: reLoading(V)

enum UIState<V, E: Error> {
    case idle
    case initialLoading
    case reLoading(V)
    case success(V)
    case failure(E)
}

A: loading(V?)

@State private var uiState: UIState<String, any Error> = .idle

var body: some View {
    VStack {
        switch dataState {
        case .idle, .loading(nil):
            EmptyView()

        case .success(let text),
	        .loading(let text?):
	    Text(text)

        case .failure:
	    ErrorStateView()
        }
    }
    .overlay {
        if uiState.isLoading {
	    ProgressView()
	}
    }
}

再読み込み時に「完全な再描画」を発生させないために、.loading.successを同一caseに記述してStructural Identityを統合する必要があります。 しかし、.loading.successのAssociated Valueの型が異なるため .loading(nil).loading(let text?)に分けて記述する必要があります。

以下のような書き方は再読み込みをするたびに該当Viewを「完全に再描画」しており、パフォーマンス的にも悪いし、状態があった場合は状態が消えるので良くない実装といえます。

var body: some View {
    switch dataState {
    case .idle:
	EmptyView()

    case .loading(let text):
	if let value {
	    Text(text)
	}

    case .success(let text):
	Text(text)

    case .failure:
	ErrorStateView()
    }
}

B: reLoading(V)

var body: some View {
    VStack {
        switch dataState {
        case .idle, .initialLoading:
            EmptyView()

        case .success(let text),
	        .reLoading(let text):
	    Text(text)

        case .failure:
	    ErrorStateView()
        }
    }
    .overlay {
        if uiState.isLoading {
	    ProgressView()
	}
    }
}

.reLoading(V)の場合も同様です。こちらは.initialLoading.reLoadingにそもそも分かれており、.reLoading(V)success(V)とAssociated Valueの型が同じなので同一caseに通常通り記述できます。

@swiftty さんのコメントにより修正 .loading(nil) .loading(text?)と記述できるのを知らず、A: loading(V?) をアンチパターンとして記載しておりましたが、こちらを使えばAでも良さそうなので、修正しました。

分岐の位置を工夫して「完全に再描画」の影響範囲を減らそう

「異なるViewを出し分ける」際には他にも注意すべき点があります。

それは分岐位置を工夫して、分岐の影響範囲を小さくすることです。

Not Good

@State var isLoading = false
@State var datas: [String] = ...

var body: some View {
    if isLoading {
        ProgressView()
    } else {
        List(datas, id: \.self) { data in
            Text(data)
        }
    }
}

Good

var body: some View {
    List {
        if isLoading {
            ProgressView()
        } else {
            ForEach(datas, id: \.self) { data in
                Text(data)
            }
        }
    }
}

もしくは

var body: some View {
    List {
        ForEach(datas, id: \.self) { data in
            Text(data)
        }
    }
    .overlay {
        if isLoading {
            ProgressView()
        }
    }
}

Not Goodの例だと、Listが分岐に含まれてしまっているため、ロードする度にListを「完全に再描画」しています。(存在しなくなったIdentityに紐づくViewや状態はキャッシュされないので、毎回Listのインスタンスを再生成しています。)
UIKitのレイヤーで言うと、ロードする度にUICollectionViewremoveFromSuperViewしてdeinitし、その後新しいインスタンスをinitしてaddSubViewしています。

Goodの例は、List内部もしくはListに被せたViewで分岐が起きているため、Listの「完全に再描画」は起きません。UIKitのレイヤーで言うと、UICollectionViewの中のViewを操作しているだけで、Not Goodに比べコストが低いと言えます。

頻繁に起こる分岐の中に状態を持たないようにしよう

頻繁に起こる分岐の中に状態をもつViewを配置せざるを得ない場合は、@Binding等を用いて状態を分岐の外に出しましょう。

@State var condition = true
@State var count = 1

// 条件によってVStackとHStackを切り替えたい例
var body: some View {
    if condition {
        VStack {
	    Text("counter")
            CounterView(count: $count)
        }
    } else {
        HStack {
	    Text("counter")
            CounterView(count: $count)
	}
    }
}

一方で、頻繁に起きない分岐や、分岐した際に状態が消えるのが意味的におかしくないケースなら分岐内に状態をもっても良いと思います。

// ログイン画面とログイン後の画面を切り替える例
var body: some View {
    if isLogin {
        MainScreen()
    } else {
        LoginScreen()
    }
}

分岐を内部的に行うViewにも気をつけよう

分岐(Viewの出しわけ)を内部的に行うViewがいくつかあります。
これらはクロージャーで複数のViewを受け取り、条件に応じて出し分けるため、ほぼifやswitchと同等の挙動をします。

TabViewはその中の一つですが、TabViewの用途的にもそれぞれのViewが別のIdentityとして扱われるのは明確ですし、またTabViewは非表示状態のViewもキャッシュするので、状態が消える等問題になることは少ないかと思います。

var body: some View {
    TabView {
        HomeScreen()

        PostScreen()
    }
}

ViewThatFits

iOS16+から使える、レスポンシブレイアウトのようなレイアウトを組めるViewThatFitsというViewがあります。

以前書いたこちらの記事も参照
https://zenn.dev/kntk/articles/a0e1e91036dbdb

ViewThatFitsは、クロージャーで受け取ったViewのうち、サイズに収まる最初のViewを表示します。
以下の例では、Aがサイズに収まるならA、そうでないならBを表示します。

var body: some View {
    ViewThatFits(in: .horizontal) {
        // A
        HStack {
            Text("counter")
            CounterView()
        }
        .frame(minWidth: 380)

        // B
        VStack {
            Text("counter")
            CounterView()
        }
    }
}

この時、画面回転やiPadのSlide Overなどを利用すると、画面サイズが変わるため、表示されるViewが切り替わる可能性があります。この時、ViewThatFitsの中に状態を持っていると、ifの時と同様に状態が消えてしまいます。

この場合も@Bindingなどを利用して状態をViewThatFitsの外に出すと良いでしょう。

@State var count = 0

var body: some View {
    ViewThatFits(in: .horizontal) {
        HStack {
            Text("counter")
            CounterView(count: $count)
        }
        .frame(minWidth: 380)

        VStack {
            Text("counter")
            CounterView(count: $count)
        }
    }
}

このように、分岐を内部的に行うViewを利用する際も、ifやswitchを使う際と同等の注意が必要です。

分岐を内部的に行うViewが特殊な例のように取り上げてしまいましたが、実はこれもViewをn回書いたらn個別々のIdentityを持つに基づいており、本質的には特段新しいことではありません。クロージャーに二つViewを書いているので、Identityが二つできて状態が共有されないのは当たり前かなと思います。

デバッグでView Identityの変更を検知する方法

Self._printChanges()@identity changed

Self._printChanges()は公式から提供されているプライベートAPIです。

https://developer.apple.com/documentation/swift-playgrounds/console-print-debugging#Understand-when-and-why-your-views-change

変更を検知したいViewのbodyにlet _ = Self._printChanges()と記述します。

struct CounterView: View {

    @State var count = 0

    var body: some View {
        let _ = Self._printChanges()
        Button {
            count += 1
        } label: {
            Text(count.description)
                .font(.title)
        }
    }
}

struct PrintChangeCheck: View {
    @State var condition = true
    @State var message = ""

    var body: some View {
        VStack {
            if condition {
                CounterView(message: message)
            } else {
                CounterView(message: message)
            }

            Button {
                condition.toggle()
            } label: {
                Text("toggle")
            }

            TextField("message", text: $message)
        }
    }
}
// 初回表示時
CounterView: @self, @identity, _count changed.

// message変更時
CounterView: @self changed.

// CounterViewのcount変更時
CounterView: _count changed.

// condition変更時
CounterView: @self, @identity, _count changed.
  • @self changed: initが呼ばれた時(値が変更された時)
  • _count changed: @Stateが変更された時
  • @identity changed Identityが変更された時

という挙動になっていると推測できます。
この@identity changedでIdentityの変更を検知できます。

が、Self._printChanges()には問題があります

@Stateが存在しないと@identity changedが表示されない

struct TextView: View {

    let text: String

    var body: some View {
        let _ = Self._printChanges()
        Text(text)
    }
}

struct PrintChangeCheck2: View {
    @State var condition = true
    @State var message = ""

    var body: some View {
        VStack {
            if condition {
                TextView(text: message)
            } else {
                TextView(text: message)
            }

            Button {
                condition.toggle()
            } label: {
                Text("toggle")
            }

            TextField("message", text: $message)
        }
    }
}
// 初回表示時
TextView: @self changed.

// message変更時
TextView: @self changed.

// condition変更時
TextView: @self changed.

これでは、「完全に再描画」なのか「描画のアップデート」なのか区別がつきません。

型単位でしか利用できない

Self.と付けることからわかるように、型単位でしか利用できません。
computed propertyfunctionに出したViewなど、任意の地点でのIdentityの変更を検知できません。

_highlightViewIdentityChanged, _printViewIdentityChanged

Identityが変わるとStateが消えることを利用して、Identityが変わったタイミング(View「完全に再描画」されたタイミング)で

  • Viewを黄色にハイライトするmodifier: _highlightViewIdentityChanged
  • printするmodifier: _printViewIdentityChanged

の二つを作りました。

private struct PrintViewIdentityChanged: ViewModifier {

    let message: String
    @State private var loaded = false

    func body(content: Content) -> some View {
        content
            .onAppear {
                if !loaded {
                    print("identity changed: \(message)")
		    loaded = true
                }
            }
    }
}

private struct HighlightViewIdentityChanged: ViewModifier {
    @State private var loaded = false

    func body(content: Content) -> some View {
        content
            .overlay {
                if !loaded {
                    Color.yellow.opacity(0.5)
                }
            }
            .onAppear {
                withAnimation {
                    loaded = true
                }
            }
    }
}

public extension View {
    func _highlightViewIdentityChanged() -> some View {
        modifier(HighlightViewIdentityChanged())
    }

    func _printViewIdentityChanged(_ message: String = "") -> some View {
        modifier(PrintViewIdentityChanged(message: message))
    }
}

これらを利用することで、@Stateを持っていないViewでもIdentity変更の検知が可能です。

var body: some View {
    VStack {
        if condition {
            TextView(text: message)
                ._highlightViewIdentityChanged()
                ._printViewIdentityChanged("text true")
        } else {
            TextView(text: message)
                ._highlightViewIdentityChanged()
                ._printViewIdentityChanged("text false")
        }

        Button {
            condition.toggle()
        } label: {
            Text("toggle")
        }

        TextField("message", text: $message)
    }
}

また、.idでも検知可能な上、任意の場所・VStackTextなど自分が作った型でなくとも利用可能です。

@State var count = 0

var body: some View {
    VStack {
        Text("\(count)")
            ._highlightViewIdentityChanged()
            ._printViewIdentityChanged("text \(count)")
            .id(count)

        Button {
            count += 1
        } label: {
            Text("change id")
        }
    }
    ._highlightViewIdentityChanged()
    ._printViewIdentityChanged("VStack")
}

ぜひデバッグにお役立てください。

アンチパターンと解決案集

以下、自分が過去に目撃したことのあるView Identity的に良くないコードとその解決案を列挙します(思い出したら・見つけたら随時更新)

.redacted()

iOS14+でスケルトンViewを実装するためのmodifierです。

https://developer.apple.com/documentation/swiftui/view/redacted(reason:)

Not Good

@State var isLoading = ...
@State var data: (name: String, number: Int) = ...

let stub: (name: String, number: Int) = ("A", 1)

var body: some View {
    List {
        if isLoading {
            content(data: stub)
                .redacted(reason: .placeholder)
        } else {
            content(data: data)
        }
    }
}

@ViewBuilder
func content(data: (name: String, number: Int)) -> some View {
    Text(data.name)

    Text(data.number.description)
}

スケルトンViewと実際のViewの間でStructural Identityが別になっており、「完全に再描画」が起きています。

Good

var body: some View {
    List {
        content(data: isLoading ? stub : data)
	    .redacted(reason: isLoading ? .placeholder : [])
    }
}

.redacted(reason: [])は見た目に影響しないのでisLoading ? .placeholder : []のように三項演算子を用いれば、スケルトンViewと実際のViewの間でViewのインスタンスを使いまわせます。

また、以下のようなextensionも同様にStrucutral Identityに影響しない形で記述できます。

Not Good

extension View {
    @ViewBuilder
    func skelton(isActive: Bool) -> some View {
        if isActive {
            self.redacted(reason: .placeholder)
        } else {
            self
        }
    }
}

Good

extension View {
    func skelton(isActive: Bool) -> some View {
        redacted(reason: isActive ? .placeholder : [])
    }
}

Explicit Identityの時はどっちでも良い

以下のように、TextForEachのExplicit Identityによって管理されている場合
ForEachに渡しているstub<->datas間でidが変化することによってTextが「完全に再描画」されるため、ForEach自体のStructural Identityを同一にしてもしなくても挙動は同じになります。

@State var isLoading = ...
@State var datas: [String] = ...

let stub = ["AAAAA", "BBBBB", "CCCC"]

var body: some View {
    List {
        if isLoading {
            content(datas: stub)
                .redacted(reason: .placeholder)
        } else {
            content(datas: datas)
        }
	
	// content(data: isLoading ? stub : data)
	//    .redacted(reason: isLoading ? .placeholder : [])
    }
}

func content(datas: [String]) -> some View {
    ForEach(datas, id: \.self) { data in
        Text(data)
    }
}

.hidden

https://developer.apple.com/documentation/swiftui/view/hidden()

Not Good

var body: some View {
    if condition {
        Text("hoge").hidden()
    } else {
        Text("hoge")
    }
}

Good

var body: some View {
    Text("hoge").opacity(condition ? 0 : 1)
}

.hiddenにはisActive: Bool等の引数がないため、状況によって表示状態を切り替えたい場合に三項演算子が使えずif {}を使う必要が出て、Identity的によくありません。
完全に挙動が同じかは分かりませんが、ほぼ同じ挙動の.opacity()で代用できます。

.overlay(if:), .background(if:), .onChange(if:), etc..

以下はoverlayの例ですが、全部同じです。
if { self.xxx } else { self }ではなく、.xxx { if {} }で記述しましょう。

Not Good

@ViewBuilder
func overlay<Content: View>(if condition: Bool, content: Content) -> some View {
    if condition {
        self.overlay {
            content
        }
    } else {
        self
    }
}

Good

func overlay<Content: View>(if condition: Bool, content: Content) -> some View {
    overlay {
        if condition {
            content
        }
    }
}

参考文献

https://www.hackingwithswift.com/store/pro-swiftui

https://sakunlabs.com/blog/swiftui-identity-transitions/

https://zenn.dev/bannzai/articles/20557106b4ba7b

https://qiita.com/fuziki/items/6fe7a304b30146ba43c7

Discussion

swifttyswiftty

とても有用な記事ありがとうございます!

一点こちらの enum の解説ですが、このようにすると A 案でも構造を揃えることができそうです!

    switch dataState {
    case .idle, 
         .loading(nil):
	EmptyView()

    case .loading(let text?),
         .success(let text):
	Text(text)

    case .failure:
	ErrorStateView()
    }
kntkymtkntkymt

コメントありがとうございます!!

この記法知りませんでした、、ありがとうございます、訂正しておきます!!