SwiftUIの .sheet 内で @State の値を使う時の注意

SwiftUIの `.sheet` 内で `@State` の値を利用する際、最新値が反映されないことがあるので注意が必要です。

## 値が反映されないケース

例えば下のようなコードを書いたとします。

```swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        Button("Button") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
    }
}
```
`.sheet` は `isPresented: Binding<Bool>` を引数として受け取り、`@State var isPresented` の値が `true` であれば、シートを表示します。

そのため、`.sheet` 内に実装された `Text` でも 「表示中」 という文字が表示されそうですが...

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/042c4f1a-1194-73fd-a703-db047513ba94.gif)

実際には 「表示してないよ」 と表示されてしまいました。

ちなみに `@State` を下のように分割しても
```swift
struct ContentView: View {
    @State private var isPresented: Bool = false
    @State private var text: String = "初期値"

    var body: some View {
        Button("Button") {
            text = "表示中"
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(text)
        }
    }
}
```

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/61b492ae-2d0b-56aa-8868-980b1efba523.gif)

やはり同じように、値が更新されません。

## 値が反映されるケース
以下のような実装の場合は最新値がsheetの中に反映されます。

```swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        VStack {
            Button("Button") {
                isPresented = true
            }
            Text(isPresented.description)
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
    }
}
```

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/97aaee1b-424b-195f-b7bc-c01ca7c53b8a.gif)

`.sheet` の外で `@State` を使っている箇所がある場合は値が更新されそうです。

## `_printChanges()` を見てみる
`_printChanges()` はViewの更新をトリガーした値の変更を調べることのできる開発者機能です。これを先ほど紹介した実装に仕込んでみます。

### 動かないケース
```swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        let _ = Self._printChanges()        
        Button("Button") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
    }
}
```

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/f8a1d244-4409-1818-dfcb-d12b8e168a46.gif)

### 動くケース

```swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        let _ = Self._printChanges()        
        VStack {
            Button("Button") {
                isPresented = true
            }
            Text(isPresented.description)
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
    }
}
```

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/853a09f9-4d90-93d0-0fe9-d5cdb15940a1.gif)

動かないケースではボタンを押してもデバッグコンソールに何も流れてきませんが、動くケースでは `ContentView: _isPresented changed.` というログが表示されます。

`.sheet` 内だけで利用されている `@State` の変更に関してはViewの更新が必要無いとみなされてしまい、古い値が `.sheet` 内のViewで利用されしまっていそうでした。

## 解決策
いくつか解決策を見つけたので紹介します。

### `.sheet(item:content:)` を使う

```swift
struct ContentView: View {
    private struct Item: Identifiable {
        var id = UUID()
        var text: String
    }

    @State private var item: Item? = nil

    var body: some View {
        Button("Button") {
            item = Item(text: "表示中")
        }
        .sheet(item: $item) { item in
            Text(item.text)
        }
    }
}
```

`.sheet(isPresented: Binding<Bool>, content: () -> View)` の代わりに `.sheet(item: Binding<Identifiable?>, content: (Identifiable) -> View)` を使うことで、更新された値をクロージャの引数として受け取ることができるようになります。

## `@State` の状態を `onChange` で監視する

```swift
struct ContentView: View {
    @State var isPresented: Bool = false

    var body: some View {
        Button("Button") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
        .onChange(of: isPresented, initial: false) {}
    }
}
```
`onChange(of:initial:action:)` を付けることで、sheetの外で `@State` を利用する箇所ができるので、 `isPresented` が更新された際にViewの更新をトリガすることができます

## `.sheet` 内のViewのコンストラクタで `Binding<Bool>` を受け取るようにする

```swift
struct ContentView: View {
    @State private var isPresented: Bool = false

    var body: some View {
        Button("Button") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            SheetView(isPresented: $isPresented)
        }
    }

    private struct SheetView: View {
        @Binding var isPresented: Bool
        var body: some View {
            Text(isPresented ? "表示中" : "表示してないよ")
        }
    }
}
```

`Binding<Bool>` を受け取ることによって、`isPresented` が更新された場合にサブビューである `SheetView` を更新することもできそうでした。