WWDC Quick Look 💓 By SwiftGGTeam
Explore new advances in App Intents

Explore new advances in App Intents

元の動画を見る

Highlight

今回の App Intents のアップデートはカバー範囲が広いですが、目玉となる変更は Interactive Snippets です。Snippet はこれまで確認情報や結果の表示にしか使えませんでしたが、Button や Toggle を埋め込めるようになり、ユーザーがタップすると既存の App Intent が直接トリガーされます。システムはバックグラウンドで Intent を実行し、その後 Snippet ビューを再描画します。この一連の流れに追加のグルーコードは必要なく、既存の Intent をそのまま再利用できます。


核となる内容

これまで App の機能をシステムの他の場所からトリガーしたい場合、開発者は Shortcuts、Spotlight、Visual Intelligence の間で個別に対応する必要がありました。Snippet も静的な結果カードに過ぎず、リクエストの確認や結果の表示には使えますが、インタラクションはできませんでした。ユーザーが結果を見てさらに操作したいと思ったら、メインの App を開かなければなりません。この「もう一度 App を開く」というステップが、多くのシナリオの体験を遅くしていました。

iOS 26 では Snippet を Interactive Snippet に進化させました。サンプルの TravelTracking App では、最寄りのランドマークを見つけると Snippet にハートのボタンが表示されます。ユーザーがボタンをタップすると、システムは App 内の既存の UpdateFavoritesIntent を実行し、完了後に同じ SnippetIntent を使ってビューを再描画します。これによりハートが空の状態から塗りつぶされた状態へと切り替わります。インタラクションは Snippet 内で完結し、App をフォアグラウンドで起動する必要はありません。リフレッシュフローもシステムが面倒を見てくれます。リフレッシュのたびに宣言したパラメータで AppEntity を再取得し、SnippetIntent の perform メソッドを呼び出してビューを再描画し、SwiftUI の contentTransition モディファイアがアニメーション遷移を担当します。

Snippet 以外にも、本セッションでは Visual Intelligence の画像検索連携(IntentValueQuery + SemanticContentDescriptor)、On-screen Entity(NSUserActivity を使って現在のビューを ChatGPT に紐付ける)、Spotlight から直接 Action を実行する機能、UndoableIntent、IntentModes(Intent をバックグラウンドで実行するかフォアグラウンドで実行するかを宣言)、ComputedProperty / DeferredProperty という 2 つの新しいマクロ、そして AppIntentsPackage(Intent を Swift Package や静的ライブラリに配置可能にする)について解説しています。


詳細

SnippetIntent は新しいプロトコルです。「どのパラメータでレンダリングするか」と「どうレンダリングするか」を同じ型に束ねます。元の Intent は戻り値の型に ShowsSnippetIntent を加え、.result(...) に SnippetIntent インスタンスを渡すだけで済みます(04:08)。

struct ClosestLandmarkIntent: AppIntent {
    static let title: LocalizedStringResource = "Find Closest Landmark"

    @Dependency var modelData: ModelData

    func perform() async throws -> some ReturnsValue<LandmarkEntity> & ShowsSnippetIntent & ProvidesDialog {
        let landmark = await self.findClosestLandmark()

        return .result(
            value: landmark,
            dialog: IntentDialog(
                full: "The closest landmark is \(landmark.name).",
                supporting: "\(landmark.name) is located in \(landmark.continent)."
            ),
            snippetIntent: LandmarkSnippetIntent(landmark: landmark)
        )
    }
}

ポイント:

  • 戻り値の型は 3 つのプロトコルを組み合わせています: ReturnsValue<LandmarkEntity>(戻り値)、ShowsSnippetIntent(Snippet 表示)、ProvidesDialog(音声フィードバックも提供)。
  • dialogfullsupporting の 2 つのテキストから構成され、前者は完全な読み上げに、後者は表示領域が小さい場面に使われます。
  • snippetIntent パラメータは「Snippet をリフレッシュするたびに、この LandmarkEntity を再度渡してほしい」とシステムに伝えます。

SnippetIntent 自体は独立した AppIntent です(04:31)。

struct LandmarkSnippetIntent: SnippetIntent {
    static let title: LocalizedStringResource = "Landmark Snippet"

    @Parameter var landmark: LandmarkEntity
    @Dependency var modelData: ModelData

    func perform() async throws -> some IntentResult & ShowsSnippetView {
        let isFavorite = await modelData.isFavorite(landmark)

        return .result(
            view: LandmarkView(landmark: landmark, isFavorite: isFavorite)
        )
    }
}

ポイント:

  • @Parameter でマークしたプロパティはリフレッシュのたびにシステムが値を流し込み、AppEntity 型は Query を再度経由します。
  • @Dependency はランタイム依存を注入するもので、シリアライズ対象にはなりません。
  • perform は App の状態を変更してはいけません。ダークモード切り替えなどのイベントに応答するため、システムが複数回呼び出す可能性があるからです。
  • 戻り値の型に ShowsSnippetView を加え、.result(view:) に SwiftUI ビューを渡します。

Snippet 内のボタンは既存の Intent をそのまま再利用します05:45)。

struct LandmarkView: View {
    let landmark: LandmarkEntity
    let isFavorite: Bool

    var body: some View {
        // ...
        Button(intent: UpdateFavoritesIntent(landmark: landmark, isFavorite: !isFavorite)) { /* ... */ }

        Button(intent: FindTicketsIntent(landmark: landmark)) { /* ... */ }
        // ...
    }
}

ポイント:

  • Button(intent:) というイニシャライザは 2023 年にインタラクティブな Widget と一緒に登場したもので、ここではそれをそのまま再利用しています。
  • ユーザーがタップすると、システムが Intent を実行し、終了後に先に宣言した SnippetIntent でビューを再描画します。
  • 同じ API は Toggle にも使えます。

確認用の Snippet は requestConfirmation を使います06:53)。

struct FindTicketsIntent: AppIntent {

    func perform() async throws -> some IntentResult & ShowsSnippetIntent {
        let searchRequest = await searchEngine.createRequest(landmarkEntity: landmark)

        // Present a snippet that allows people to change
        // the number of tickets.
        try await requestConfirmation(
            actionName: .search,
            snippetIntent: TicketRequestSnippetIntent(searchRequest: searchRequest)
        )

        // Resume searching...
    }
}

ポイント:

  • requestConfirmation はユーザーが確認またはキャンセルするまで perform をブロックします。キャンセル時はエラーを投げるので、catch せずにそのまま終了させてください。
  • actionName: .search で確認ボタンの文言が決まります。
  • ユーザーが Snippet 内で変更した枚数などの状態は AppEntity(SearchRequestEntity)で保持でき、次回のリフレッシュ時には Query から最新の値が読み込まれます。

Snippet を能動的にリフレッシュする場合は静的メソッドの reload() を使います08:01)。

func performRequest(request: SearchRequestEntity) async throws {
    // Set to pending status...

    TicketResultSnippetIntent.reload()

    // Kick off search...

    TicketResultSnippetIntent.reload()
}

ポイント: reload() を呼ぶと、システムは該当 SnippetIntent のリフレッシュフロー全体を再実行します。「検索開始」や「検索完了」といった重要な状態遷移ポイントに適しています。

Visual Intelligence の画像検索は IntentValueQuery 経由で接続します09:24)。

struct LandmarkIntentValueQuery: IntentValueQuery {

    @Dependency var modelData: ModelData

    func values(for input: SemanticContentDescriptor) async throws -> [LandmarkEntity] {
        guard let pixelBuffer: CVReadOnlyPixelBuffer = input.pixelBuffer else {
            return []
        }

        let landmarks = try await modelData.searchLandmarks(matching: pixelBuffer)

        return landmarks
    }
}

ポイント:

  • SemanticContentDescriptor は選択範囲のピクセルを保持しており、VideoToolbox や CoreImage で CGImage に変換できます。
  • 返す AppEntity には OpenIntent も実装する必要があります。さもなければ App は検索パネルに現れません。
  • 結果は数ページ分にとどめ、検索時間も長くしすぎないようにしてください。

複数の結果型を扱う場合は @UnionValue を使います11:40)。

@UnionValue
enum VisualSearchResult {
    case landmark(LandmarkEntity)
    case collection(CollectionEntity)
}

struct LandmarkIntentValueQuery: IntentValueQuery {
    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        // ...
    }
}

struct OpenLandmarkIntent: OpenIntent { /* ... */ }
struct OpenCollectionIntent: OpenIntent { /* ... */ }

ポイント: 各 case に対応する Entity それぞれに OpenIntent が必要です。

ComputedProperty と DeferredProperty は新しいマクロです24:23 / 24:48)。

struct SettingsEntity: UniqueAppEntity {
    @ComputedProperty
    var defaultPlace: PlaceDescriptor {
        UserDefaults.standard.defaultPlace
    }

    init() {}
}

struct LandmarkEntity: IndexedEntity {
    @DeferredProperty
    var crowdStatus: Int {
        get async throws {
            await modelData.getCrowdStatus(self)
        }
    }
}

ポイント:

  • @ComputedProperty は UserDefaults やメモリといった軽量なソースから即時に計算できるプロパティに向いており、重複した保存を避けられます。
  • @DeferredProperty は非同期処理、ネットワーク、時間のかかる IO を伴うプロパティに向いており、明示的にリクエストされたときだけ計算されます。
  • IndexedEntity の Spotlight インデックス時には DeferredProperty はスキップされるため、毎回のインデックス処理で重い操作が走るのを避けられます。

App Intents が Package に配置できるようになりました25:50)。

// Framework or dynamic library
public struct LandmarksKitPackage: AppIntentsPackage { }

// App target
struct LandmarksPackage: AppIntentsPackage {
    static var includedPackages: [any AppIntentsPackage.Type] {
        [LandmarksKitPackage.self]
    }
}

ポイント: Framework や静的ライブラリ側で AppIntentsPackage を 1 つ宣言し、App 側のルート Package の includedPackages に列挙すれば、モジュール内の Intent が認識されるようになります。


核となる示唆

1.「結果カード」を「コントロールパネル」へ進化させる

なぜ取り組む価値があるか: これまでの Snippet は読み取り専用の結果表示にすぎず、ユーザーがさらに操作するには App を開く必要があり、画面遷移コストが高くつきました。Interactive Snippet は「データを一目で確認する」と「次の操作をすぐ実行する」を同じビューに統合し、Apple が示す「軽量・即時・グランス」原則に沿っています。

どこから始めるか: 現在 App で Snippet を返している Intent を洗い出し、ユーザーが結果を受け取った後によく行う 2、3 のアクション(お気に入り、共有、削除、再検索)をリストアップし、それらに対応する Intent を Button(intent:) でそのまま SnippetView に埋め込みます。追加のルーティングコードは不要です。

2. @DeferredProperty で AppEntity の初期化コストを削る

なぜ取り組む価値があるか: Spotlight のインデックス、Shortcuts のリスト、Snippet の描画はいずれも AppEntity を取得します。あるプロパティがネットワークリクエストを必要とするなら、初期化が遅くなってしまいます。DeferredProperty を使えば、コストの高いフィールドは Intent が実際に必要になった時にだけ計算されます。

どこから始めるか: すべての AppEntity のフィールドを見直し、「await が必要 / 失敗の可能性がある / 外部サービスに依存する」フィールドに @DeferredProperty を付け、UserDefaults やメモリから読むだけのものは @ComputedProperty に変更し、重複した保存を取り除きましょう。

3. Visual Intelligence の画像検索に対応し、システムのスクリーンショットパネルに App を載せる

なぜ取り組む価値があるか: iOS 26 ではユーザーが画像をハイライトするだけでシステムの検索パネルを呼び出せます。App がこのパネルに表示されるかどうかは IntentValueQuery を実装しているかどうかで決まります。早期に対応した App は、この新しい流入経路を獲得できます。

どこから始めるか: IntentValueQuery を 1 つ書き、SemanticContentDescriptor を受け取り、pixelBuffer を既存の画像検索バックエンドに渡します。返す Entity 型には OpenIntent を実装し、結果のタップで App の対応ページへ遷移できるようにします。複数の結果型がある場合は @UnionValue でラップしましょう。

4. App Intents を Swift Package に分割する

なぜ取り組む価値があるか: 大規模プロジェクトや複数の App でコードを共有するシナリオでは、App Intents はこれまでメインの App ターゲットにしか配置できず、再利用が困難でした。AppIntentsPackage により Framework や静的ライブラリも Intent を提供できるようになります。

どこから始めるか: 共通の Entity、Query、Intent を LandmarksKitPackage のような Package に切り出し、メイン App は自身の AppIntentsPackageincludedPackages で明示的に参照させます。


関連 Session

コメント

GitHub Issues · utterances