更新中にSwiftUIシートを開いたままにするには、プレゼンテーションホストのアイデンティティを維持し、プレゼンテーションバインディングをアクティブな状態に保ちます。シートを安定したコンテナにアタッチすれば、プレゼンターを置き換えずに変化する子コンテンツを更新できます。

このガイドは、基盤となるインターフェイスが変化しても表示されたままにする編集可能なシートを備えたMacアプリを開発する方向けです。例では合成したローカルテキストを使い、個別に確認できる3つの要素、つまりプレゼンテーションの状態、プレゼンティングビューのライフタイム、エディタの内容を分けて扱います。

ビューが更新されるとシートが消えるのはなぜですか?

SwiftUIはbodyの評価時に新しいビュー値を作成しつつ、表示中のビューのアイデンティティを維持できます。そのアイデンティティのライフタイム中は状態が保持されます。ビューを削除したりアイデンティティを変更したりすると、そのライフタイムは終了します。また、両方のブランチに同じビュー型が含まれていても、条件分岐の切り替えによって別々のアイデンティティが作成されることがあります。AppleはDemystify SwiftUIでこれらの関係を説明しています。

Boolean駆動のシートでは、ドキュメントに記載されたプレゼンテーション制御はsheet(isPresented:onDismiss:content:)に渡すバインディングです。値がtrueなら表示が要求されます。シートが消える原因を調べるときは、そのバインディングと、モディファイアを持つビューが引き続き存在しているかを両方確認してください。別の場所に保存された値がtrueでも、元のプレゼンテーションホストが存続しているとは限りません。AppleのBoolean sheet documentationを参照してください。

特に直接的な原因として、明示的な識別子が変化するケースがあります。Appleのドキュメントによると、id(_:)に渡す値を変更すると、状態を含めてビューのアイデンティティがリセットされます。プレゼンティングビューとその祖先で、リビジョンカウンター、編集可能なテキスト、新たに生成された値から作られた識別子を確認してください。意図しないリセット動作を引き起こす識別子は削除します。id(_:)を参照してください。

実用上の設計としては、シートモディファイアをインターフェイスの変化する部分より外側に配置します。読み込み中のビュー、フィルタされた行、別の詳細レイアウトが消える可能性がある場合、通常はその直近にある適切な永続的祖先をプレゼンテーションの所有元にするのが適切です。

Mac向けの完全な例で安定したシートの所有元を作る

XcodeでmacOS SwiftUIアプリを作成し、デプロイメントターゲットをmacOS 12以降に設定して、生成されたアプリとコンテンツビューの宣言を次の単一ファイルに置き換えます。@mainアプリ宣言は1つだけにしてください。macOS 12をベースラインにすることで、例にあるdismiss環境アクションが使えます。TextEditor自体はmacOS 11から利用できます。Appleのdismiss documentationとTextEditor documentationを参照してください。

ウィンドウのルートビューが、プレゼンテーションフラグ、編集可能なテキスト、リビジョンカウンター、レイアウトフラグを所有します。外側のVStackにシートモディファイアを付けます。分岐を切り替えるのは、そのコンテナ内のコンテンツだけです。Appleは、必要とするビューのうち最も上位のビューで状態をプライベートに宣言し、変更が必要な子ビューにはバインディングを渡すことを推奨しています。Stateを参照してください。

import SwiftUI

@main
struct StableSheetDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SheetOwnerView()
        }
    }
}

struct SheetOwnerView: View {
    @State private var isEditorPresented = false
    @State private var note = "Sample note Alpha"
    @State private var revision = 0
    @State private var compactLayout = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Local Notes")
                .font(.title)

            if compactLayout {
                Text("Compact summary: revision \(revision)")
            } else {
                VStack(alignment: .leading, spacing: 8) {
                    Text("Expanded summary")
                    Text("Revision \(revision)")
                    Text(note)
                }
            }

            Button("Edit Note") {
                isEditorPresented = true
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 260)
        .sheet(isPresented: $isEditorPresented) {
            NoteEditorSheet(
                note: $note,
                revision: $revision,
                compactLayout: $compactLayout
            )
        }
    }
}

struct NoteEditorSheet: View {
    @Binding var note: String
    @Binding var revision: Int
    @Binding var compactLayout: Bool
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Edit Sample Note")
                .font(.headline)

            TextEditor(text: $note)
                .frame(minHeight: 160)

            Text("Owner revision: \(revision)")

            Button("Update Owner") {
                revision += 1
                compactLayout.toggle()
            }

            HStack {
                Spacer()
                Button("Done") {
                    dismiss()
                }
            }
        }
        .padding(24)
        .frame(width: 460, height: 340)
    }
}

これはドキュメントに記載されたAPIと照合した、完全なデモアプリです。この環境にはSwiftツールチェーンもmacOSランタイムもないため、ここではコンパイルや実行をしていません。

シートを閉じずに更新を試す

  1. アプリを実行し、Edit Noteをクリックします。
  2. サンプルテキストをSample note Betaに置き換えます。
  3. Update Ownerを数回クリックします。クリックするたびに所有元の状態がインクリメントされ、所有元の条件付きコンテンツが切り替わります。
  4. エディタが表示されたままで、テキストがSample note Betaのまま保たれ、リビジョン表示が進むことを確認します。
  5. Doneをクリックします。レイアウトが展開表示のとき、ウィンドウに編集済みテキストが表示されることを確認します。
  6. エディタをもう一度開き、編集済みテキストが引き続き利用できることを確認します。

更新ボタンはシート内にあるため、基盤となるウィンドウをクリックする必要はありません。AppKitのドキュメントではシートはドキュメントモーダルとされています。シートが表示されている間、親ウィンドウ宛てのほとんどのイベントは禁止されます。バインディングを通じて所有元を更新することで、この例では状態変化を直接試せます。beginSheet(_:completionHandler:)を参照してください。

エディタを閉じたときの動作を決める

この例では所有元のテキストをすぐに編集します。Doneはエディタを閉じますが、別途保存処理は行いません。文字列はウィンドウ内の状態に保持され、この例にはアプリ起動をまたぐ永続化機能はありません。この動作はStateで説明されている共有バインディングの設計に沿っています。

キャンセルできる下書きが必要な場合は、別の下書き値を使い、明示的な確定操作を行ったときだけドキュメントにコピーします。この動作は閉じる処理を実装する前に定義してください。下書きを確定するか破棄するかの判断は、どのビューがシートを所有するかの判断とは別です。

独立した3つの確認項目で閉じる原因を調べる

自分のアプリでは次の手順を使ってください。消えるシートの原因が常に同じだと決めつけずに、一般的なアイデンティティの規則を絞り込んで調査できます。

確認項目 確認する内容 実用的な修正方法
プレゼンテーション値 Booleanをfalseに設定するすべての書き込み、またはオプショナルなプレゼンテーションアイテムをクリアする処理 意図した終了操作にのみ閉じる処理を置く
プレゼンターのアイデンティティ 条件分岐、削除された行、変化する祖先の識別子 プレゼンテーション状態とモディファイアの両方を適切な安定した祖先に移す
コンテンツのライフタイム シート内の状態、変化するコンテンツ識別子、エディタの同期 意図した編集セッションを維持し、既存のコンテンツを明示的に更新する

まず、プロジェクト内でプレゼンテーションプロパティへの代入とdismiss()の呼び出しを検索してください。更新コールバックがシートを明示的に閉じている可能性があります。例のように、シート独自のビュー内でdismissを読み取ってください。Appleのドキュメントによると、このアクションは取得した環境に適用されます。ウィンドウのルートビューで読み取ると、代わりにそのウィンドウを対象にすることがあります。dismissを参照してください。

次に、モディファイアより上位の階層を確認します。更新によって削除される詳細ビューにシートが付いている場合は、そのビューが置き換わる境界より上に移します。ビューがローカルに所有するプレゼンテーション状態も一緒に移してください。Appleのview lifetime explanationによると、アイデンティティが終了する子ビューにフラグを置いたままにすると、ストレージが置き換えの影響を受けやすくなります。

3つ目に、プレゼンテーション自体が閉じた場合と、エディタは表示されたまま下書きが失われた場合を区別します。まずはシンプルなTextEditorの例から始めて、カスタムコンテンツを段階的に戻します。この比較は診断方法です。問題がプレゼンテーションの所有元に伴うものか、コンテンツの更新ロジックに伴うものかを判断するのに役立ちます。

アイテム駆動のシートに切り替えるべきですか?

特定の選択済みエンティティがプレゼンテーションの元になる場合はsheet(item:onDismiss:content:)を使います。Appleのドキュメントでは、Identifiableに準拠するオプショナルなアイテムを定義し、非nilのアイテムをコンテンツクロージャに渡し、アイテムが変化したときに置き換えることを説明しています。オーバーロードを切り替えても、ライフタイムが終了するプレゼンターがそれだけで維持されるわけではありません。item-driven sheetsを参照してください。

意図した編集セッションを通じてエンティティを表す識別子を選びます。SwiftのIdentifiableプロトコルは安定したアイデンティティを表し、必要な期間と範囲は関係する型に委ねるとドキュメントに明記されています。プロパティを読み取るたびに新しい識別子を生成しても、継続するエンティティを表すことはできません。Identifiableを参照してください。

デモのようなシンプルなエディタでは、Booleanのプレゼンテーションフラグと個別のテキストバインディングを使うと、意図する関係を簡単に確認できます。選択アイテムを編集する場合は、選択のアイデンティティと頻繁に変化するフィールドを分けてください。別のエンティティを選ぶことを明示的なプレゼンテーション上の決定として扱い、現在の下書きをどうするか先に定義します。

インタラクティブな閉じ方を無効にするとシートは存続しますか?

interactiveDismissDisabled(_:)はプログラム以外の方法による閉じ方を制御します。Appleは、プレゼンテーションバインディングの変更やdismissの呼び出しによる閉じ方は防げないと明記しています。したがって、ドキュメントに記載された役割からは、アイデンティティの置き換えに対処する手段にはなりません。interactiveDismissDisabled(_:)を参照してください。

インターフェイスでその閉じ方の制御が必要な場合に限り、シート階層内でモディファイアを適用し、明示的な完了方法も用意してください。この作業では、まずホストとその状態を維持します。原因を特定する前に閉じる操作を制限すると、元の所有元の問題が未解決のまま残ることがあります。

シートにAppKitまたはTextKitのコンテンツが含まれる場合はどう変わりますか?

NSViewRepresentableで埋め込んだNSTextViewでは、作成と更新を分けます。AppleはmakeNSView(context:)を作成ステップ、updateNSView(_:context:)を後続のSwiftUI情報を適用する場所として定義しています。コーディネーターを使ってデリゲートやターゲットアクションの変更をSwiftUIに反映します。NSViewRepresentableとmakeNSView(context:)を参照してください。

このライフサイクルから導かれる有用な実装ルールは、無関係な所有元の状態変化だけを理由にエディタを再構築しないことです。内容を置き換える前に、受け取ったモデルテキストが実際に異なるか、ローカルでの同時編集をどう扱うかを判断してください。シートが安定していても、テキストへの競合する書き込みは解決されません。

TextKitはテキストの保存、レイアウト、描画を提供します。AppKitのNSTextViewはこれらの機能をまとめ、最新と従来のレイアウトエンジンを公開します。これらの仕組みはエディタの内容に関するものです。プレゼンテーションの所有元には別途、安定したライフタイムが必要です。編集作業で必要な場合はTextKitを使い、テキスト更新の動作とシートが閉じる問題は分けて調べてください。AppleのTextKit overviewを参照してください。

ウィンドウが所有するAppKitのプレゼンテーションが適切なのはどんな場合ですか?

既存のアプリがすでにAppKitでウィンドウのライフサイクルを管理している場合、macOS 26ではそのウィンドウにSwiftUIコンテンツを直接表示するNSWindow.beginSheet(content:completionHandler:)が導入されています。Appleのドキュメントによると、シートのdismiss環境アクション、サイズ変更、ツールバー、インタラクティブな閉じ方の制御がサポートされます。採用する前にmacOS 26 SDKを使い、macOS 26の利用可能性の境界を適用してください。beginSheet(content:completionHandler:)を参照してください。

返されるNSWindow.HostingSheetRepresentationを使うと、表示中のルートビューを変更したり、AppKitからプログラムで閉じたりできます。ウィンドウレベルの所有がアプリのアーキテクチャに合う場合に、ドキュメントに記載された選択肢です。上記の安定したSwiftUIコンテナの例では不要です。NSWindow.HostingSheetRepresentationを参照してください。

以前のmacOSバージョンでは、AppKitの既存のbeginSheet(_:completionHandler:)がNSWindowシートを受け取り、プレゼンテーションをキューに入れる仕様を定めています。新しいSwiftUIコンテンツのオーバーロードがそれらのシステムで使えるとは想定しないでください。beginSheet(_:completionHandler:)を参照してください。

所有元を安定させ、プレゼンテーション値をアクティブに保ち、エディタが意図する情報源を維持してください。より複雑なコンテンツに戻す前に、小さなローカル更新アクションでこれらの条件を確認します。これにより、Appleが定めるライフタイムとAPIの範囲内で、インターフェイスの更新中も編集を続けるための具体的な方法が得られます。

参照元