sheet(item:onDismiss:content:)を使い、選択中の項目を1つのオプショナル値で管理します。シートはクロージャが受け取る項目から構築します。これにより、Macアプリではシートを表示するかどうかと、何を表示するかの両方を1つの値で決められます。

このチュートリアルでは、小さなデスクトップ向けタスクを実装します。ローカルのサンプル項目をクリックすると、すぐに詳細を確認できます。また、シートが空白で開く、前に選択した項目が表示される、あるいはAppKitビュー内に古いテキストが表示される場合の診断手順も紹介します。

項目ベースのシートを使う理由

SwiftUIには、関連する表示方法が2つあります。Boolean版は、バインディングがtrueになると表示されます。項目版は、Identifiableに準拠したオプショナル値を受け取り、その値をコンテンツクロージャに渡します。固定パネルにはBoolean版を、選択内容に応じて中身が変わるパネルには項目版を選びます。AppleのドキュメントでBooleanベースのシートと項目ベースのシートを参照してください。

選択状態と表示状態を別々のプロパティで管理すると、「表示要求はあるが選択項目はない」といった組み合わせが可能になります。両者を1つのオプショナル値にまとめれば、独自の状態モデルからその組み合わせを排除できます。nilは表示する項目がないことを表し、値が入っていれば確認対象の項目を表します。これは2つのAPIから導かれる設計上の判断であり、文書化されていないSwiftUIのスケジューリング不具合を主張するものではありません。

既存の実装で選択項目を設定した後にBooleanを切り替えている場合、その2つの文だけでは初回表示が失敗する理由はわかりません。症状をタイミングのせいにする前に、コンテンツまでのデータ経路を調べてください。確認すべきなのは、シートが渡された入力を描画しているのか、別のプロパティを参照しているのか、それとも別途保存された値を表示しているのかです。

macOS向けの完全な例を作る

XcodeでSwiftUIインターフェースを使うmacOSアプリプロジェクトを作成します。この例では、デプロイメントターゲットをmacOS 12以降にします。項目ベースのシートAPIはmacOS 10.15から利用でき、以下で使う環境からのdismissアクションにはmacOS 12が必要です。Appleのドキュメントで項目ベースのシートとdismiss環境値の対応状況を確認できます。

生成されたアプリ宣言とコンテンツビューを、次の完全なプログラムに置き換えます。ターゲット内のアプリエントリーポイントは1つだけにしてください。以下のラベルと説明はすべて、ローカルで使う架空のサンプルデータです。

import SwiftUI

struct SampleItem: Identifiable {
    let id: String
    let title: String
    let detail: String
}

@main
struct FirstSheetDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemListView()
        }
    }
}

struct ItemListView: View {
    private let items = [
        SampleItem(
            id: "tile-a",
            title: "Amber Tile",
            detail: "A square sample with an amber finish."
        ),
        SampleItem(
            id: "tile-b",
            title: "Blue Tile",
            detail: "A rectangular sample with a blue finish."
        )
    ]

    @State private var presentedItem: SampleItem? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample Items")
                .font(.title2)

            ForEach(items) { item in
                Button("Inspect \(item.title)") {
                    presentedItem = item
                }
            }
        }
        .padding(24)
        .frame(minWidth: 360, minHeight: 200)
        .sheet(item: $presentedItem) { item in
            ItemDetailView(item: item)
        }
    }
}

struct ItemDetailView: View {
    let item: SampleItem
    @Environment(\.dismiss) private var dismiss

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

            Text(item.detail)

            Text("Item ID: \(item.id)")
                .font(.caption)

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

ボタンは、クリックされた値をpresentedItemに代入します。次に表示クロージャが、自身のitemパラメーターをItemDetailViewに渡します。詳細ビューには、2つ目の選択項目用状態プロパティではなく、通常の入力プロパティlet itemがある点に注目してください。Appleは単一の信頼できる情報源を保ち、読み取り専用の値はプロパティで共有し、子ビューに書き込み権限が必要な場合はバインディングを使うことを推奨しています。Stateを参照してください。

dismissアクションは、シートの環境を読み取るItemDetailView内に置きます。表示元のビューでこれを読み取り、シートのコンテンツからそのアクションを呼び出すと、表示元ビューの環境が使われるとAppleは明示的に警告しています。例のように、宣言をシート自身のコンテンツのそばに置いてください。dismissを参照してください。

プログラムの入力と参照に一貫性があることは確認済みですが、この環境ではSwiftツールチェーンが利用できないため、コンパイルや実行はしていません。対象のmacOSバージョンで、以下の確認をXcodeから実行してください。

意図的なクリック手順で初回表示を確認する

初回表示の確認は、毎回アプリを新しく起動して始めてください。別の項目を開いた後だけテストすると、最初に起きた症状を見落とすことがあります。以下の期待値は、サンプルの明示的なラベルとデータ代入に基づく受け入れ基準であり、テスト結果の報告ではありません。

操作 シートに表示される内容 確認できること
起動後、最初にInspect Blue Tileをクリックする Blue Tile、その長方形の説明、tile-b 初回表示でクリックした項目が使われる
Doneをクリックし、次にAmber Tileを確認する Amber Tile、その正方形の説明、tile-a 次の選択内容もコンテンツに渡される
シートを閉じて、もう一度Amber Tileを確認する 同じAmberの詳細 再表示でも内容が一貫している
アプリを再起動し、最初にAmber Tileを確認する すぐにAmberの詳細が表示される 最初に開くサンプルに左右されない

タイトルとIDの両方を表示すれば、タイトルだけを確認するより有効なチェックになります。実際のインターフェースで同じ表示名を許可する場合は、開発中にタイトルが同じでIDと説明が異なる合成アイテムを2つ使ってください。表示テキストで誤って項目を選択してしまう問題を見つけられます。

ボタンから最終ビューまで古い内容を診断する

以下の確認を順番に行ってください。各手順で不一致がある場所を絞り込めるため、関係のない表示上の回避策を加えずに原因のある層を変更できます。

  1. アクションの入力を確認する。 クリックしたボタンのアクションにブレークポイントを設定し、item.idを調べます。アクションが意図したレコードを受け取っていることを確認します。
  2. 表示への入力を確認する。 シートクロージャ内のitemパラメーターを調べます。別の選択状態を再度参照するのではなく、このパラメーターを詳細ビューに渡します。
  3. 最終的に表示するプロパティを確認する。 タイトルと説明が詳細ビューの入力から読み取られていることを確認します。以前の値を保持しているキャッシュ済みプロパティやローカルの編集中データがないか調べます。
  4. AppKitとのブリッジを確認する。 通常のSwiftUIテキストは正しいのにラップされたテキストが古い場合は、Representableの更新メソッドを調べます。

IDを安定させる

項目ベースのAPIではIdentifiableへの準拠が必要です。Swiftでは、このプロトコルは安定したIDを軸としており、その継続期間と適用範囲は準拠する型とその利用側に委ねられています。このサンプルでは、2つのレコードを区別する保存済みの文字列IDを使います。Identifiableを参照してください。

実際のリストでは、インターフェースがサポートする更新をまたいでも同じレコードを識別し続けるIDを選んでください。変更される可能性のあるタイトルや、複数項目で共有されるタイトルからIDを生成するのは避けます。また、読み取るたびに新しいUUIDを作る計算プロパティidも避けてください。安定したIDにはなりません。

表示用の入力と編集用の下書きを分ける

インスペクターでは、item.titleをそのまま表示できます。エディターでは意図的にローカルの下書きを作ることがありますが、その場合は寿命を定義すべき値がもう1つ増えます。いつ作成するのか、後からモデルが変わったときに置き換えるのか、ユーザーが完了またはキャンセルしたときにどうするのかを決めてください。

Appleの説明によると、SwiftUIはビューのIDに対応する状態ストレージを保持し、そのIDの寿命が始まる時点で初期化します。ビュー値を作り直しても、それだけで新しい状態が作られるわけではありません。そのため、入力からローカル状態を初期化しても、後から入力が変わったときに状態を同期する汎用的な方法にはなりません。Demystify SwiftUIを参照してください。

この読み取り専用タスクでは、通常の入力プロパティを使います。編集機能に拡張する場合は、下書きを明示的に作成して確定する設計か、対象のモデル値へのバインディングを選んでください。テキストを更新するだけの目的でビューのIDを変更するのは避けてください。IDを変更すると関連する状態が置き換わり、未確定の下書きが失われる可能性があります。これはAppleが文書化している状態の寿命に関するモデルから導かれる結果です。

シートにAppKitのテキストビューを含めると何が変わるか

NSTextViewを含むシートでも、選択した項目のデータ経路は同じです。加えて、ブリッジを正しく実装する必要があります。AppleのNSViewRepresentableプロトコルでは、初回生成をmakeNSView(context:)、その後の設定をupdateNSView(_:context:)で行います。生成時にしかテキストを設定しないラッパーには、後続のテキスト入力を反映する実装がありません。NSViewRepresentableを参照してください。

読み取り専用のラッパーでは、渡されたテキストでネイティブビューを初期化し、必要に応じて更新メソッドで表示テキストを現在の入力に一致させます。対応するAppKitビューに影響する変更があったときにSwiftUIが更新メソッドを呼び出すことを、Appleは文書化しています。updateNSView(_:context:)を参照してください。

編集可能なラッパーでは、追加の方針が必要です。更新のたびにテキストを無条件に置き換え、編集中の内容を上書きしてはいけません。RepresentableのドキュメントでAppleが説明しているように、Coordinatorを使ってネイティブのデリゲートまたはtarget-actionによる変更をSwiftUIに伝えます。Representableビューのフレームと境界はSwiftUIに管理させてください。Appleは、これらのレイアウトプロパティを直接設定しないよう警告しています。

TextKitはテキストの保存、レイアウト、描画を扱います。AppleはNSTextViewが利用する、最新のNSTextLayoutManagerと従来のNSLayoutManagerの両方の経路を文書化しています。これらの機能は、シートで選択されたレコードを渡すものではありません。テキストレイアウトを調べる前に、ブリッジが正しい項目のテキストを受け取っていることを確認してください。また、TextKitのAPIを追加する前に、それぞれの利用可能条件を確認します。TextKitを参照してください。

タスクに合った表示モデルを選ぶ

コンテンツが固定で、変化する入力が表示状態だけならBoolean版を使います。このインスペクターのタスクには項目版を使います。渡した項目が変わると、既存のシートが閉じて置き換わる場合があるとAppleは説明しています。そのため、項目の置き換えによって同じ表示のまま編集を続けられるとは限りません。sheet(item:onDismiss:content:)を参照してください。

初回表示を正しくするには、選択したレコードの経路を追いやすくします。ボタンアクションでの代入が1か所、シートクロージャの項目パラメーターが1つ、詳細ビューの入力が1つです。新しい起動状態からサンプル項目を両方確認してください。それでも結果が異なる場合は、この診断手順で不一致が選択、保持された状態、ネイティブビューの更新経路のどこから入るかを特定できます。

参考資料