インタラクティブなSwiftUIプレビューを作成するには、各プレビューに独自のローカル状態を持たせ、監視可能なビューモデルをエディタに渡し、編集可能なプロパティをバインディング経由で公開します。このチュートリアルでは、合成したサンプルテキストを使って、小さなmacOS用ラベルエディタを作成します。

プレビュー状態の置き場所を選ぶ

ここでは、ラベルのタイトルと詳細テキストを変更し、詳細を表示するかどうかを切り替えるデスクトップエディタをプレビューします。エディタは変更にすぐ反応し、各プレビューは明示的なサンプル設定から開始する必要があります。

操作に必要な最小限の入力を使います。複雑な依存関係があるとプレビューの構築が難しくなるため、Appleはビューに必要なデータだけを渡すことを推奨しています。静的なラベルには文字列を渡せますが、エディタには状態への書き込み可能な接続が必要です。インターフェイスファイルへのプレビューの追加。

ビューが必要とするもの 適切な入力 サンプル状態の置き場所
固定テキストの表示 不変の文字列 プレビュー内に直接置く
親が所有する1つの値の編集 @Bindingプロパティ プレビューホスト内のローカルな@State
関連する複数の監視可能なプロパティの編集 @Bindableで公開する監視可能なモデル プレビューホストの@Stateに格納したモデルインスタンス
環境から渡されたモデルの読み取り 環境の依存関係 プレビューのルートで明示的に注入

この例では、ビューモデルが2つの文字列、表示設定、計算された表示タイトルを保持します。各プレビューは個別のインスタンスを所有します。これにより、開始条件がプレビュー宣言のそばで確認でき、共有の可変シングルトンに依存せずに済みます。

macOSのSwiftUIターゲットを準備する

ここで示すインラインの@Previewable構文には、Xcode 16以降を使います。AppleはこのマクロをXcode 16で導入しました。この例のデプロイメントターゲットはmacOS 14以降に設定してください。このバージョンではSwiftUIがObservationをサポートしています。Appleの現在のPreviewable()リファレンスにも、macOS 14で利用可能と記載されています。Xcode 16リリースノート、アプリでのモデルデータの管理、Previewable()。

既存のmacOS SwiftUIアプリのターゲットに、LabelEditor.swiftという名前のSwiftファイルを追加します。次の2つのコードブロックを合わせると、プレビューの例が完成します。アプリの既存のエントリポイントはそのままにしてください。このファイルではモデル、エディタ、プレビューを定義します。

このコードでは、サポートされるツールチェーン全体で@Stateという表記を使います。Appleの現在のドキュメントでは、実装の違いが区別されています。Xcode 26以前ではStateプロパティラッパーを使い、Xcode 27以降ではState()マクロを使います。生成コードを確認したり、初期化コストを考えたりするときは、この違いに留意してください。State、State()。

監視可能なモデルと編集可能なビューを作る

モデルに@Observableを付けると、SwiftUIはインターフェイスが読み取るプロパティを追跡できます。エディタでは@Bindableを使い、テキストフィールドとトグルに必要なプロパティバインディングを作成します。Appleは、監視可能なモデルの編集にこの組み合わせを使う方法を説明しています。アプリでのモデルデータの管理、Bindable。

import Foundation
import Observation
import SwiftUI

@MainActor
@Observable
final class LabelViewModel {
    var title: String
    var detail: String
    var showsDetail: Bool

    init(
        title: String,
        detail: String,
        showsDetail: Bool = true
    ) {
        self.title = title
        self.detail = detail
        self.showsDetail = showsDetail
    }

    var displayedTitle: String {
        let trimmed = title.trimmingCharacters(
            in: .whitespacesAndNewlines
        )
        return trimmed.isEmpty ? "Untitled label" : trimmed
    }
}

@MainActor
struct LabelEditor: View {
    @Bindable var model: LabelViewModel

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            VStack(alignment: .leading, spacing: 6) {
                Text("Title")
                TextField("Label title", text: $model.title)
            }

            VStack(alignment: .leading, spacing: 6) {
                Text("Detail")
                TextField("Label detail", text: $model.detail)
            }

            Toggle("Show detail", isOn: $model.showsDetail)

            Divider()

            Text("Rendered label")
                .font(.caption)
                .foregroundStyle(.secondary)

            VStack(alignment: .leading, spacing: 8) {
                Text(model.displayedTitle)
                    .font(.title2)
                    .fixedSize(horizontal: false, vertical: true)

                if model.showsDetail {
                    Text(model.detail)
                        .foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
            .background(
                .quaternary,
                in: RoundedRectangle(cornerRadius: 10)
            )
        }
        .textFieldStyle(.roundedBorder)
        .padding(20)
    }
}

このモデルは、すべてのサンプル値をイニシャライザ経由で受け取るようにしています。イニシャライザはローカルプロパティを代入するだけです。フォールバックの計算は、誰かが入力したタイトルを上書きせずに、レンダリングされるラベルの表示を変えます。空白だけのタイトルは編集可能なまま保持され、表示結果はUntitled labelになります。

エディタは呼び出し元からモデルを受け取ります。@Bindable宣言によって、$model.titleなどのバインディングが作られます。状態の所有権はプレビューホストに置きます。このビューがモデルの値を表示するだけなら、監視には通常のモデル参照で十分です。ここではコントロールに書き込み可能なプロパティバインディングが必要なので、Bindableによる格納を使います。Bindable。

各プレビューに独立したサンプル状態を持たせる

次の宣言を同じファイルに追加します。それぞれが、明示的な合成値を使って個別のモデルを作成します。幅はレイアウトを確認するための例であり、推奨されるウィンドウの最小サイズではありません。

#Preview("Standard label") {
    @Previewable @State var model = LabelViewModel(
        title: "Sample tile",
        detail: "Blue square, medium size"
    )

    LabelEditor(model: model)
        .frame(width: 420)
        .preferredColorScheme(.light)
}

#Preview("Long label in a narrow editor") {
    @Previewable @State var model = LabelViewModel(
        title: "Sample collection with a longer descriptive label",
        detail: "Round tiles arranged in three columns"
    )

    LabelEditor(model: model)
        .frame(width: 300)
        .preferredColorScheme(.dark)
}

#Preview("Blank title and hidden detail") {
    @Previewable @State var model = LabelViewModel(
        title: "   ",
        detail: "An optional detail line",
        showsDetail: false
    )

    LabelEditor(model: model)
        .frame(width: 420)
}

@Previewableを使うと、ローカル状態の宣言が#Previewで生成される埋め込みSwiftUIビューの一部になります。Appleの説明では、タグを付けた宣言は生成されたビューのプロパティになります。この宣言はプレビュークロージャのルートに置いてください。プレビュー本文の外で@Previewableを使うとエラーになります。Previewable()。

これにより、所有関係が明確になります。生成されたプレビューホストがモデルを@Stateに格納し、LabelEditorがモデル参照を受け取り、@Bindableが編集可能なプロパティを公開します。SwiftUIでは、Observationモデルを状態に格納し、その参照を子ビューに渡せます。State。

イニシャライザの値は、開始時のフィクスチャとして扱います。インタラクティブなプレビューでは、実行中に値を変更できます。特定のレイアウトを再現するときは、初期表示を比較する前に新しいプレビューインスタンスを表示してください。

小さなシナリオ表で動作を確認する

LabelEditor.swiftを開き、Show Canvasを選んで、キャンバスタブから名前付きプレビューを選択します。キャンバスが一時停止している場合は、Restartを使います。テキスト入力とコントロールのロジックを試すには、Liveモードを有効にします。これらのコントロールは、Appleの現在のキャンバスガイドで説明されています。キャンバスでプレビューを操作する。

次の確認項目を使って、状態の問題とレイアウトの問題を切り分けます。期待される結果はサンプルコードから導かれるものであり、テスト結果を報告したものではありません。

シナリオ 操作 期待される結果
標準的なラベル タイトルをAmber circleに置き換える レンダリングされたタイトルがすぐに変わる
詳細の表示 Show detailをオフにし、次にオンにする 詳細が消え、テキストを保持したまま再表示される
空のタイトル タイトルの文字をすべて削除する レンダリングされたタイトルがUntitled labelになる
空白のみのタイトル 空白だけを入力する フィールドには空白が残り、レンダリングされたタイトルにはフォールバックが使われる
幅の狭いエディタ 長いタイトルのプレビューを確認する レンダリングされたタイトルがカード内で折り返される
個別のフィクスチャ 1つのプレビューを編集し、新しい別のプレビューを確認する もう一方のプレビューは、宣言した独自のモデル値から始まる

テキストを入力できるのにレンダリング結果が変わらない場合は、フィールドのバインディングと出力が読み取るプロパティの接続を確認してください。ここでは、どちらの経路も直接またはdisplayedTitle経由でmodel.titleにつながっています。表示を切り替えたときに詳細が消去される場合は、空の値を代入するコードがないか確認してください。この例ではshowsDetailだけを変更します。

調査環境ではSwiftコンパイラもmacOSのプレビュー実行環境も利用できなかったため、これらの例はコンパイルや実行ではなく、ドキュメントに記載されたAPIに照らして確認しました。エディタに合わせて変更する前に、Xcodeでターゲットをビルドし、操作確認を完了してください。

共有セットアップと編集可能なフィクスチャを分ける

この規模のモデルなら、ローカルで構築することで各プレビューを理解しやすく保てます。複数のプレビューで高コストなセットアップを再利用する必要が出てきた場合、AppleはPreviewModifierを提供しています。共有コンテキストはキャッシュされ、利用するプレビュー間で再利用されます。このAPIにはmacOS 15以降が必要です。PreviewModifier。

この共有動作を踏まえると、実用的な設計方針が得られます。個別に編集できるフィクスチャモデルはローカルに置き、利用するプレビュー間で意図的に再利用するセットアップには共有コンテキストを使います。可変モデルを共有すると編集内容も共有されるため、シナリオごとの比較が難しくなる場合があります。

既存のビューが環境から型でモデルを取得する場合は、必要なインスタンスをプレビューのルートで注入します。Appleの説明によると、要求された型の非オプショナルなモデルが見つからないと例外が発生します。そのため、プレビューでは選択したルートが実際に読み取る依存関係を渡す必要があります。アプリでのモデルデータの管理。

AppKitとTextKitの境界を保つ

上記のエディタはSwiftUIコントロールを使っています。既存のmacOSエディタにAppKitのテキストビューが含まれる場合は、NSViewRepresentableを使ってそのビューをSwiftUI階層に組み込みます。makeNSViewを通じて作成と設定を行い、SwiftUIから受け取った状態をupdateNSViewで適用します。編集をSwiftUIへ戻す必要がある場合は、コーディネータを介してAppKitのデリゲート変更を転送します。表現するビューのフレームと境界はSwiftUIに管理させてください。NSViewRepresentable。

AppleはAppKitのビューとビューコントローラを直接プレビューする方法もサポートしています。ただし、@PreviewableはSwiftUI専用の機能であり、AppKitの直接プレビューには適用できません。この記事のローカル状態のパターンをAppKitコンテンツに使うには、representableを含むSwiftUIホストをプレビューします。インターフェイスファイルへのプレビューの追加。

NSTextViewを作成するときは、選択したテキストエンジンを維持してください。Appleの説明では、初期化時にTextKit 2を選ぶにはNSTextView(usingTextLayoutManager: true)を使います。また、TextKitのガイドでは、TextKit 1のlayoutManagerにアクセスすると、そのテキストビューのインスタンスで一方向のフォールバックが発生する場合があると説明されています。TextKit 2を使う場合は適切なTextKit 2 APIを使用してください。プレビューによってこうした境界がなくなることはありません。TextKitとテキストビューの新機能。

所有権の境界でプレビューの失敗を診断する

最初のビルド診断から確認します。Swiftマクロはコンパイル時にコードを生成するため、マクロ呼び出しで強調表示されたエラーが生成された宣言に関係している場合があります。XcodeのEditor > Expand Macroコマンドを使うと、そのコードを確認できます。マクロの適用。

次に、状態の仕組みを確認します。既存のCombineベースのObservableObjectには、対応する所有パターンが必要です。Appleは、そのようなオブジェクトをStateに格納しても公開プロパティの変更は追跡されないと警告し、所有者にはStateObjectを使うよう案内しています。この例では@Observableを使うため、状態の所有権にはObservationの方式が適用されます。State。

完成したプレビューファイルでは、明示的な開始値と目に見える操作を中心にします。標準、長いタイトル、空のタイトルのシナリオが期待どおりに動作したら、アプリで適切な親ビューが所有するモデルを使って、同じエディタを再利用します。プレビューのフィクスチャは、こうしたインターフェイスの状態を手軽に再現する方法として使えます。

出典