App Intentsチュートリアル - Part 2 エンティティとクエリ
WWDC22の "Dive into App Intents" (Appインテントの詳細) というセッションで、読書アプリにApp Intentsを実装していく事例があり、具体的で非常にわかりやすかったので、記事としてまとめてみました。
前回記事「Part 1」では、読書アプリの特定のタブを素早く開くインテントの実装を通して、App Intentsの基礎について解説しました。
本記事「Part 2」では、前回の内容を発展させ、エンティティとクエリを用いて読書アプリに「ユーザーが選択した本を開く」インテントや「ライブラリに本を追加する」インテント、さらに 「ライブラリに本を追加し、その本を開く」というインテント連携等の実装方法について解説します。
アプリの説明
「読みたい本(Want to Read)」「読んだ本(Read)」「現在読んでいる本(Currently Reading)」を記録するアプリ。
アプリには3つのタブがあり、それぞれのリストを "Shelf" とセッション内では呼んでいる。
本を開くインテント
Bookを開くインテントを 構築したい場合、そのセットが固定ではなく、動的なものであればどうでしょうか。
そのために エンティティが必要になります。
エンティティとは Appインテントに Appが公開するコンセプトです。メモAppのノートや 写真Appの写真やアルバムなど 値が動的または ユーザー定義の場合は 列挙型の代わりにエンティティを 使用する必要があります。
エンティティのインスタンスを提供するために、Appでクエリを実装し、インテントからResultとしてエンティティを返すことができます。
本を開くインテントの作成を始めましょう。
ショートカットエディタでは このように表示されます。Bookパラメータをタップすると、
Appが提供する 提案された エンティティのセットを含む 本を選択するピッカーが 利用できます。
ピッカーの上部にある検索フィールドを使えば、ライブラリにあるどんな本でも探すことができます。
エンティティ
インテント自体を 構築する前に、本のエンティティと、それに対応する クエリを作成する必要があります。
エンティティは少なくとも 識別子・ディスプレイ表現・エンティティ型の名前 3つを含みます
struct BookEntity: AppEntity, Identifiable {
var id: UUID
var displayRepresentation: DisplayRepresentation { "\(title)" }
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
}
-
struct
をAppEntity
プロトコルに適合させる- ここでは
BookEntity
の 新しい構造体を定義しますが、モデルから既存の型を 適合させることもできます
- ここでは
-
Identifiable
プロトコルにエンティティを 適合させることで 識別子を提供する-
Appインテントはこの識別子を使用してAppとシステムの他の部分との間で送信されるエンティティを参照します
-
識別子はユーザーが作成した ショートカットに保存される可能性があるため、安定かつ永続的で あることが必要です
-
-
displayRepresentation
はこのエンティティをユーザーに 表示するために使用される-
これは本のタイトルなどのテキストの文字列を単純なものにできます
-
サブタイトルや画像を提供することも可能です
-
-
typeDisplayName
はエンティティ型を表す人が読みやすい文字列- この例では「Book」
クエリ
ここで本のエンティティを 実行するためにクエリを追加する 必要があります。
クエリはAppからエンティティを取得するためのインターフェイスをシステムに提供します。
クエリではいくつかの方法で エンティティを検索できます。
- すべてのクエリは 識別子に基づいた エンティティを検索できる 必要があります
-
StringQuery
で検索 をサポートします - 後ほど より柔軟性の高い
PropertyQuery
をご案内します - すべてのクエリで、ユーザーがリストから選択できるように エンティティの提案を提供することもできます
すべてのエンティティはクエリと 関連付けられる必要があるため、
システムがそのエンティティの インスタンスを検索できるようになります。
EntityQuery
プロトコルに準拠した構造体を作成してクエリを提供します。
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
}
EntityQuery
の必須メソッド entities(for:)
を実装することで、識別子の配列を取得して エンティティを解決します。
モデルデータベースにアクセスし、その識別子に一致する本を探すことでこれを実装しました。
クエリをエンティティに フックする必要があります。
struct BookEntity: AppEntity, Identifiable {
...
static var defaultQuery = BookQuery()
}
staticな BookEntity
型の defaultQuery
プロパティを実装し、 BookQuery
の インスタンスを返すことで これを実現しています。
ユーザーが本を選ぶ際に、その識別子が ショートカットに保存されます。
ショートカットを 実行すると Appインテントは この識別子をクエリに渡し BookEntity
インスタンスを取得します。
struct OpenBook: AppIntent {
@Parameter(title: "Book")
var book: BookEntity
static var title: LocalizedStringResource = "Open Book"
static var openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
guard try await $book.requestConfirmation(for: book, dialog: "Are you sure you want to clear read state for \(book)?") else {
return .result()
}
Navigator.shared.openBook(book)
return .result()
}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$book)")
}
init() {}
init(book: BookEntity) {
self.book = book
}
}
BookEntity
型が AppEntity
プロトコル に適合することで OpenBook
インテントの パラメータとして使用できます。
perform
メソッドは Navigator
を介して 本へと遷移させます。
本のピッカーを サポートするために、
クエリは提案された結果も提供する必要があります。
そのためには読書Appに 追加されたすべての本を返す もう1つのメソッドをクエリに実装する必要があります。
struct BookQuery: EntityStringQuery {
...
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.books
}
}
ショートカットは これらの結果でピッカーを埋めます。
ショートカットUIには上部に 検索フィールドがあります。
Appはたくさんの本の エンティティを持つ可能性があるため 本当はAppのプロセスで 直接データベースに対して 検索を実行する必要があります。
StringQuery APIを使うと それができます。
struct BookQuery: EntityStringQuery {
...
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
}
StringQuery
サブプロトコル を採用することで 文字列を指定して結果を返す entity (matching string:)
という もう一つのメソッドを 実装できるようになりました。
ここでは 本のタイトルに対して単純に大文字小文字を区別しないマッチングで実装していますが、例えば著者名やシリーズ名から検索するなど よりファンシーなことも可能です。
膨大な数の本と、少数のお気に入りの本のリストがある場合、suggestedEntities
で お気に入りだけを返し entities (matching string:)
で ユーザーが長いリスト全体を検索できます。
Appで本を開く方法を公開し、その過程で本のエンティティと本のクエリを構築しました。
struct BookQuery: EntityStringQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.books
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
}
ライブラリに本を追加するインテント
同じエンティティとクエリを使用して インテントをもっと作成できます。
次のタスクは インテントを構築して ライブラリに本を 追加することです。
ユーザーは共有シートの ショートカットを使用して オンラインを閲覧しながら 素早く本を追加したり 画面を見ることなく HomePodのSiriに 本の追加を指示したり することが可能です。
このようにUIを表示することなく モデルを直接操作する インテントを構築することで ユーザーに力を与えることができます。
AddBookインテント の実装:
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & OpensIntent {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(
value: book,
openIntent: OpenBook(book: book)
)
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
-
本のタイトルと 著者のオプション名をパラメータとして取る
-
どの友人がその本を勧めたか を記録するための メモもオプションで 用意されています。
-
perform
メソッドでは async/awaitを使用したAPIコールで本を調べて ライブラリに追加します。- 一致するものが見つからない場合は エラーを投げます
-
このエラーをローカライズ するためにエラー型を
CustomLocalizedStringResourceConvertible
プロトコルに適合します。-
localizedStringResource
プロパティから ローカライズされた 文字列のキーを返して そのキーをstringsファイルに追加します
-
インテント同士の連携 - 本を追加し、その本を開く
このAdd Bookインテントは Siriやウィジェットなどのために そのままでも 驚くほど便利です。
他のインテントと組み合わせる ことができる場合 さらに柔軟性が高まります。
少しの作業をすることで
AddBook
インテントを 先ほど構築した OpenBook
インテント と組み合わせ 一方からもう一方に 結果を渡すことができます
そのために AddBook
インテントの結果の 一部として 値を返すようにします。
perform
メソッド の戻り値の型に 新しいプロトコルが 追加されたこと に注目してください
ユーザーはこのインテントの 結果値をパラメータとして 本のエンティティを受け取る 他のエンティティに接続ができます。
AddBookインテントと OpenBookインテントが 自然に組み合わさるため 本を追加してすぐに ライブラリで開く ショートカットを 作成することができます。
Open When Run
インテントから結果を返してそれをアプリで開くというのはよくあるケースです。
Appインテントには openIntent
という 表現方法が 組み込まれています。
openIntent
を追加すると、ユーザーはショートカットで 「Open When Run」という新しいスイッチを得ることになります。
スイッチをオフにする場合 このインテントを ショートカットの一部として バックグラウンドで中断することなく 使用できるようになります。スイッチを 入れたままにした場合、新しく追加された本がすぐに 読書Appで開きます。
openIntent
の採用は、 OpenBook
インテントの インスタンスを作成し 結果の一部として 返すだけです。
このインテントを 実行する際に Open When Runスイッチが オンになっていると AddBook
インテントが 終了した後に OpenBook
インテントが 自動的に実行されます。
次のステップ
「Part 3」では、エンティティのプロパティを用いて、例えば読書日・タイトル・著者名などに基づいて本を検索・フィルタリングして表示したりできるようにする方法について解説します。
これにより、ユーザーは「最近読んだ本を素早く見つける」「特定の著者の本を一覧化する」など、アプリの外でもインテントを通じて柔軟な条件でデータを活用できるようになります。
この機能を使えば、ユーザーはアプリを開かずに欲しい情報を簡単に取り出したり、特定条件に応じたデータ操作をSiri/Apple Intelligenceを通じて直接行えるようになります。
ひとりアドベントカレンダーやってます!
こちらはひとりで全日埋めるアドベントカレンダー iOS by shu223 - Qiita Advent Calendar 2024 - Qiita の3日目の記事です。がんばって完走目指すので、カレンダーの購読や記事のLikeをポチッとしていただけると嬉しいです。
Discussion