📱

App IntentのSnippetによるインタラクティブなUIの実現

に公開

wwdc2025で触れられていたApp Intentの機能強化のうちSnippet Intentが気になっていたので、サンプルアプリに導入してみつつ記事にしました。

AppIntentについて

AppIntentは、アプリの提供機能をショートカットやSiriなどのシステムに公開するためのフレームワークです。
iOS16以降で利用可能になっています。

本記事で使用する主な要素:

要素 説明
AppIntent アプリのアクションを定義するプロトコル
AppEntity アプリのデータをシステムに公開するための型
Intent間でデータを受け渡す際に使用する
SnippetIntent インタラクティブなビューを表示するIntent(iOS 26+)

サンプルアプリ

行った運動を記録し、サマリー表示できるアプリ。

  • 主な機能: 運動の開始・終了、今日のサマリー表示
  • 運動の記録(種目や時間、消費カロリーなど)はSwiftDataを利用して情報を保持

アプリ本体のイメージ

AppIntentを利用したコンテンツ表示

アプリ本体で提供している一部の機能をApp IntentやSnippet Intentを利用して、ショートカットやSiri等のシステムから取り扱えるようにしてみました。

今日の運動サマリーを表示する

実装

GetExerciseSummaryIntentで、今日の運動サマリーを表示するシンプルなIntentを実装しています。perform()メソッドでは、ExerciseSummaryView(SwiftUIView)という運動サマリーを表示するビューを返しています。

struct GetExerciseSummaryIntent: AppIntent {
    static var title: LocalizedStringResource = "今日の運動を表示"

    @MainActor
    func perform() async throws -> some IntentResult & ShowsSnippetView {
        let latest = try resolveExercise()
        let todayEntries = try await fetchTodayEntries()
        return .result {
            ExerciseSummaryView(
                latestEntry: latest,
                todayEntries: todayEntries
            )
        }
    }
}

運動の開始・終了

※動画はショートカット登録されたIntentをSiri経由で実行したものです

実装

iOS 26以上で対応のSnippetIntentを使うと、ボタンやトグルを含むインタラクティブなコンテンツを表示できます。

フロー図

StartExerciseIntent

システムから呼ばれるAppIntentです。ShowsSnippetIntentを返し、ActiveExerciseSnippetIntentに処理を委譲します。

  • .result(snippetIntent:)でSnippetIntentに準拠したActiveExerciseSnippetIntentを返しています
  • ActiveExerciseSnippetIntentには新しい運動記録を表しているExerciseEntryEntity(AppEntityに準拠)を渡しています
struct StartExerciseIntent: AppIntent {
    static let title: LocalizedStringResource = "運動を開始"

    @Parameter(title: "運動種別")
    var exerciseType: ExerciseType

    func perform() async throws -> some IntentResult & ShowsSnippetIntent {
        // SwiftDataに新しい運動記録を追加
        let container = try ModelContainer(for: ExerciseEntry.self)
        let context = ModelContext(container)

        let newExercise = ExerciseEntry(exerciseType: exerciseType, duration: 0)
        // isActiveは運動を開始している状態を表しています
        newExercise.isActive = true
        context.insert(newExercise)
        try context.save()

        // SnippetIntentに処理を委譲
        return .result(
            snippetIntent: ActiveExerciseSnippetIntent(exercise: newExercise.toEntity())
        )
    }
}

ActiveExerciseSnippetIntent

  • SnippetIntentプロトコルに準拠したIntentです。
  • perform()の戻りにShowsSnippetViewを指定し、ビューを返しています。
struct ActiveExerciseSnippetIntent: SnippetIntent {
    static let title: LocalizedStringResource = "Active Exercise Request Snippet"

    @Parameter var exercise: ExerciseEntryEntity

    func perform() async throws -> some IntentResult & ShowsSnippetView {
        return .result(view: ActiveExerciseView(exercise: exercise))
    }
}

ActiveExerciseView

ActiveExerciseViewの実装内容です。
先程のStartExerciseIntentから渡された運動記録の状態に応じて、表示するViewを切り替えています。

struct ActiveExerciseView: View {
    let exercise: ExerciseEntryEntity

    var body: some View {
        if exercise.isActive {
            // 開始している運動がある場合のビュー
            ActiveExerciseContentView(exercise: exercise)
        } else {
            // 運動が完了した状態のビュー
            CompletedExerciseView(exercise: exercise)
        }
    }
}

開始している運動がある場合のビューの実装内容です。
Snippet内でボタンを使うには、Button(intent:)を使用します。
ボタン押下により、StopExerciseIntentというIntentが実行されます。

struct ActiveExerciseContentView: View {
    let exercise: ExerciseEntryEntity

    var body: some View {
        VStack(spacing: 16) {
            // 運動情報の表示
            HStack {
                Image(systemName: exercise.exerciseType.iconName)
                Text(exercise.exerciseType.rawValue)
            }

            // 運動終了ボタン
            Button(intent: StopExerciseIntent(exerciseId: exercise.id)) {
                HStack {
                    Image(systemName: "stop.circle.fill")
                    Text("運動を終了")
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.red)
                .foregroundStyle(.white)
                .clipShape(RoundedRectangle(cornerRadius: 10))
            }
            .buttonStyle(.plain)
        }
        .padding()
    }
}

StopExerciseIntent

アクティブな運動を終了するためのIntentです。
iOS26+で利用可能なUndoableIntentに準拠しています。
perform()ではisActiveをfalse(運動を終了)にしたentityを返しています。
これにより、システムがActiveExerciseSnippetIntent.perform()を再実行し、最新の状態が反映されたビューが表示されるようになります。

struct StopExerciseIntent: UndoableIntent {
    static let title: LocalizedStringResource = "運動を終了"

    @Parameter(title: "運動ID")
    var exerciseId: String

    func perform() async throws -> some IntentResult & ReturnsValue<ExerciseEntryEntity> {
        // 運動を終了する処理
        exercise.isActive = false
        exercise.duration = elapsedMinutes
        try context.save()

        return .result(value: exercise.toEntity())
    }
}

AppShortcutsProvider

エントリーポイントとなるGetExerciseSummaryIntent,StartExerciseIntentをショートカットアプリから呼び出せるようにしたAppShortcutsProviderの実装です。
phrasesに、Siriがショートカットを実行するためのトリガーとなる音声フレーズを指定しています。

struct AppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        // 運動を開始
        AppShortcut(
            intent: StartExerciseIntent(),
            phrases: [
                "\(.applicationName)で運動を開始",
                "\(.applicationName)で運動を始める"
            ],
            shortTitle: "運動を開始",
            systemImageName: "figure.run"
        )

        // 今日の運動を表示
        AppShortcut(
            intent: GetExerciseSummaryIntent(),
            phrases: [
                "\(.applicationName)で今日の運動を見せて",
                "\(.applicationName)の運動記録"
            ],
            shortTitle: "今日の運動",
            systemImageName: "list.bullet.clipboard"
        )
    }
}

まとめ

今回、はじめてAppIntentを扱ってみました。
アプリを起動しなくても、アプリが提供する機能をショートカット・Siriなどのシステム経由で利用できるという点で、ユーザーに提供できる価値の幅も広がりそうです。
また、iOS26で対応されたSnippetIntentにより、通常のアプリに近い感覚で動的なコンテンツを実装できそうな点も良いですね。
もう少し深く学習し、個人開発や自社のプロダクトへの導入も検討していきたいです!

記事で触れたサンプルアプリは下記リポジトリで公開しています。
https://github.com/masapple/AppIntentTest

参考

dipテックブログ

Discussion