iOS18でsheetによるモーダル表示した画面で状態が更新されない問題の対処法

iOS 18がリリースされ、SwiftUIを使用するアプリケーションの開発者にとっては、新しい機能や改善点が楽しみな反面、いくつかの予期せぬ不具合にも直面しています。
その中でも、TabViewとNavigationStack、Sheet、そしてEnvironmentObjectを組み合わせて使用する際に、状態が正しく更新されないという問題が発生しています。

本記事では、その原因を分析し、簡単な修正方法を紹介します。

## 開発環境と対象バージョン
- iOS 16以上をターゲットにしたアプリ開発中
- 主な不具合はiOS 18.0およびiOS 18.1で発生

## 不具合概要

今回直面した問題は、SwiftUIの`TabView`と`Sheet`、`NavigationStack`、そして`EnvironmentObject`を組み合わせた際、状態が正しく更新されないという現象です。具体的には、`Sheet`で表示された画面内のボタンを押しても、画面の状態が更新されない、もしくは`EnvironmentObject`のプロパティが変更されても反映されないという問題です。

## サンプル

例えば、ユーザー情報を管理するアプリで、ユーザーが自身のプロフィール情報を更新できる画面を実装したいとします。このような場合、ユーザーはタブからプロフィール画面を開き、プロフィール編集ボタンをタップすると、シート形式で別の画面が表示され、そこで名前やその他の情報を更新することができます。

今回のサンプルでは、このような「ユーザー情報の更新画面」を例として使用しています。ユーザーが「Edit Profile」ボタンを押すと、プロフィール編集画面（`ProfileEditorView`）がシート形式で表示され、そこでユーザー名を更新することができます。

以下が問題の再現コードです。

```swift
import SwiftUI

struct ContentView: View {
    @StateObject var profile: Profile = .init()
    @State var isSheetPresented: Bool = false

    var body: some View {
        TabView {
            NavigationStack {
                VStack {
                    Button("Edit Profile") {
                        isSheetPresented = true
                    }
                    .sheet(isPresented: $isSheetPresented) {
                        ProfileEditorView()
                            .environmentObject(profile)
                    }
                }
            }
            .tabItem { Image(systemName: "person") }
        }
    }
}

// プロフィール編集画面
struct ProfileEditorView: View {
    @EnvironmentObject var profile: Profile
    @State var editCount = 0

    var body: some View {
        VStack {
            Text("Edit Count: \(editCount)")
            Text(profile.name)
            Button("Update Profile") {
                editCount += 1
                profile.name = "Updated Name"
            }
        }
    }
}

@MainActor
class Profile: ObservableObject {
    @Published var name: String = "Default Name"
}

#Preview {
    ContentView()
}
```

### 期待する挙動
iOS17以下では上記のコードでは問題が生じませんでした。
「Update Profile」ボタンを押下することで、Default NameがUpdated Nameに変更され、Edit Countも更新されます。

![Simulator Screen Recording - iPhone SE (3rd generation) iOS 17.4 - 2024-09-15 at 05.22.04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160605/c6b7a1d7-defd-1611-4198-db0633da51cd.gif)


## iOS 18で発生する問題
iOS 18.0およびiOS 18.1では、次のような挙動が確認されました。初回の「Update Profile」ボタン押下後に画面更新が行われますが、「Edit Count」の値が0→0のまま変わりません。このため、状態が期待通りに更新されず、初回のみ正しく反映されない問題が生じます。

![Simulator Screen Recording - iPhone SE (3rd generation) - 2024-09-15 at 05.33.47.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160605/b684e71e-60f6-e081-22aa-14bb88e40304.gif)

*初回のボタン押下時に Edit Count が 0 のままで更新されない様子*
※動画だとわかりづらくてすみません。🙇

ユーザプロフィール画面では大きな影響にはならないかもしれないですが、重要な申込などの動線で申し込み完了時にViewの表示を切り替える仕様の場合、表示が切り替わらず、戸惑うユーザが出てくることが考えられます。

## 予期せぬViewの再描画が起きていることが原因

この問題の原因は、ProfileEditorViewで予期せぬ再描画が起き、シート内で保持していた状態が初期化されてしまう点にあります。`let _ = Self._printChanges()`を使って確認したところ、「Update Profile」ボタン押下時のEnvironmentObjectのプロパティにアクセスするタイミングで予期せぬ再描画が発生し、初期化が行われていることが確認できました。

【参考】iOS18で1回目「Edit Profile」ボタン押下時のログ
```
ProfileEditorView: _profile, _editCount changed.
ContentView: _profile changed.
ProfileEditorView: @self, @identity, _profile, _editCount changed. // 再描画によりViewの状態が初期化されてしまっていた。
```
【参考】iOS18で2回目「Edit Profile」ボタン押下時 or iOS17以下操作時のログ
```
ProfileEditorView: _profile, _editCount changed.
ContentView: _profile changed.
```
## 発生条件

調査の結果、以下のような構成で不具合発生することが確認されました。この組み合わせは、モバイルアプリの多くの場面で見られる構成であるため、幅広い開発者がこの問題に直面する可能性があります。

- `TabView` 内に `NavigationStack` を使用して画面遷移を実装している
- `NavigationStack` のルートビューの子ビューから `Sheet` によるモーダル遷移が行われている
- モーダル遷移されたビューで `EnvironmentObject` が使用されている

この不具合は `NavigationView` でも再現し、画面遷移の先で `Sheet` によるモーダル遷移を行う場合には再現しません。

ただし、発生条件は分かっていても根本原因はわかっておりません。。。

## 解決方法

解決方法は2パターンあります。

### 【解決方法A】.sheetの定義位置を変更する
Buttonのsheet ModifierをVStackに持ってくる方法です。
条件としてはNavigationStack直下のViewにsheetを付与すると、解決します。
ただしこの方法は既存のViewの階層が既に複雑な場合は対応が難しいと考えられます。

ContentViewのみ変更するので、ContentViewの修正後のサンプルコードのみ記載いたします。
```
struct ContentView: View {
    @StateObject var profile: Profile = .init()
    @State var isSheetPresented: Bool = false

    var body: some View {
        TabView {
            NavigationStack {
                VStack {
                    Button("Edit Profile") {
                        isSheetPresented = true
                    }
                    // .sheet(isPresented: $isSheetPresented) {
                }
                .sheet(isPresented: $isSheetPresented) { // ⭐️NavigationStack直下のViewに移動⭐️
                    ProfileEditorView()
                        .environmentObject(profile)
                }
            }
            .tabItem { Image(systemName: "person") }
        }
    }
}
```
![Simulator Screen Recording - iPhone SE (3rd generation) - 2024-09-15 at 04.43.46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160605/14743e4b-f2eb-128c-ab72-9d1f1ef8b3d8.gif)

### 【解決方法B】sheetによるModal遷移されたViewのtask/onAppearでEnvironmentObjectに一度アクセスする

これはなぜうまく動くのかがよくわかっていないです...。（有識者の方いらっしゃれば教えて欲しいです....。）
ContentViewは変更しないので、修正後のProfileEditorViewのサンプルコードは以下になります。

```
struct ProfileEditorView: View {
    @EnvironmentObject var profile: Profile
    @State var editCount = 0

    var body: some View {
        VStack {
            Text("Edit Count: \(editCount)")
            Text(profile.name)
            Button("Update Profile") {
                editCount += 1
                profile.name = "Updated Name"
            }
        }
        .task(priority:.background) { // ⭐️追加(※onAppearでもよい)⭐️
            profile.name = profile.name // EnvironmentObjectのプロパティにアクセスするだけでよい
        }
    }
}
```
![Simulator Screen Recording - iPhone SE (3rd generation) - 2024-09-15 at 04.54.14.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160605/d8c38e06-5b46-1f91-6490-cb9cb8396821.gif)

## まとめ
iOS 18で発生するSwiftUIの不具合は、TabView、Sheet、NavigationStack、EnvironmentObjectの組み合わせによるものでしたが、今回紹介した方法で解決できます。引き続き、SwiftUIのアップデートに対応しながら、安定したアプリ開発を目指しましょう。

他にもシンプルな解決策や改善点があれば、ぜひコメントでお知らせください！