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
)がシート形式で表示され、そこでユーザー名を更新することができます。
以下が問題の再現コードです。
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も更新されます。
iOS 18で発生する問題
iOS 18.0およびiOS 18.1では、次のような挙動が確認されました。初回の「Update Profile」ボタン押下後に画面更新が行われますが、「Edit Count」の値が0→0のまま変わりません。このため、状態が期待通りに更新されず、初回のみ正しく反映されない問題が生じます。
初回のボタン押下時に 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") }
}
}
}
【解決方法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のプロパティにアクセスするだけでよい
}
}
}
まとめ
iOS 18で発生するSwiftUIの不具合は、TabView、Sheet、NavigationStack、EnvironmentObjectの組み合わせによるものでしたが、今回紹介した方法で解決できます。引き続き、SwiftUIのアップデートに対応しながら、安定したアプリ開発を目指しましょう。
他にもシンプルな解決策や改善点があれば、ぜひコメントでお知らせください!