はじめに

こんにちは、システム開発部のT.Nです。
前回の記事、SwiftUIとUIKitで Foundation Models を使ってみた:オンデバイス要約の最小実装 では、Foundation Models の基本的な使い方を最小構成で紹介しました。

今回はその続編として、Foundation Models の @Generable を使い、生成結果をただの文字列ではなく Swift の型(struct)として受け取るところまで踏み込んでみます。
題材として「料理名を入力すると、材料リスト(必要なら簡単なレシピ)を出力する」ミニアプリを作ります。

文字列で返すだけでも動きますが、材料をUIで一覧表示したり、材料の分量を扱ったりすることを考えると、任意の型で扱えることがかなり効いてきます。この記事では、まず「料理名 → 材料リスト」を型で受け取り、UIに表示してみることをゴールにします。

今回やること(完成イメージ)

今回作るのは、料理名を入力すると材料リストを生成して表示するシンプルなSwiftUIアプリです。

まずは前回の延長として、Foundation Models に問い合わせて 文字列として材料を返すところから始めます。ここまでなら実装は簡単ですが、返ってくる内容が文章だとUI側で扱いづらく、材料を一覧表示しにくいという課題があります。

そこで次に、Foundation Models の @Generable を使って、生成結果を Swiftの型(struct)として受け取り、材料を List で表示できる形に作り替えます。これにより、UI側での整形や拡張がしやすくなります。

最後に発展編として、任意で レシピ手順も同じく型として追加し、「材料+手順」まで返せるようにしてみます。

⚠️ 注意:Foundation Models は iOS 26+(iPadOS/macOS/visionOS も同様)かつ Apple Intelligence 有効化が前提です。
詳細は前回の記事をご参照ください。

文字列で材料を返す

最初に、前回の記事と同じ流れで 「モデルに問い合わせて、結果を文字列として受け取る」 ところまでを作ります。
この段階の目的は、とにかく 最小構成で動かすことです。UIは「入力欄+送信ボタン+結果表示」だけに絞ります。

コード全体
import SwiftUI
import FoundationModels
struct ContentView: View {
@State private var text: String = ""
@State private var result: String = ""
@State private var isLoading: Bool = false
let model = SystemLanguageModel.default
private func send(text: String) async throws {
let instructions = """
あなたは料理の材料を教えてくれるアシスタントです。
ユーザーが入力した料理に必要な材料を返してください。
"""
let session = LanguageModelSession(model: model, instructions: instructions)
let prompt = """
ユーザーの入力は\(text)です。
この料理を作るのに必要な材料を教えてください。
"""
isLoading = true
let response = try await session.respond(to: prompt)
isLoading = false
self.result = response.content
}
var body: some View {
if model.isAvailable {
VStack {
Text("""
こんにちは!
今から作りたい料理を教えてください!
材料を教えます!
""")
.padding()
.fixedSize(horizontal: false, vertical: true)
TextField("作りたいものはなんですか?", text: $text)
.textFieldStyle(.roundedBorder)
.padding()
Button(action: {
Task {
do {
try await send(text: text)
} catch {
result = "エラーが発生しました: \(error.localizedDescription)"
}
}
}) {
Text("送る")
}
.buttonStyle(.borderedProminent)
.tint(.black)
if isLoading {
ProgressView()
} else {
ScrollView {
Text(result)
.padding()
}
}
}
} else {
Text("Not available")
}
}
}
出来上がったアプリ
A mobile app interface displaying a greeting in Japanese and a prompt asking what dish the user wants to make, along with a text input field and a send button.

実装としては LanguageModelSession を作り、respond(to:) を呼び出して返ってきた response.content(文字列)を画面に表示するだけです。

ただし、この方法だと返ってくるのはあくまで文章なので、例えば「材料を1件ずつ List に表示したい」「量だけ取り出したい」といった用途では、あとからパースや整形が必要になります。
次のセクションでは、この課題を @Generable で解決していきます。

@Generable を導入して、材料をListで扱える形にする

ここからは、Foundation Models の @Generable を使って、生成結果を「文章」ではなくSwift の型として受け取るようにします。

材料のクラスを @Generable で定義する

まず、モデルに生成してほしい材料の形を @Generable で定義します。今回は「材料名」と「目安の量」だけに絞ってシンプルにしました。

@Generable
public struct Ingredient {
@Guide(description: "材料の名前")
let name: String
@Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
let amount: String
}

生成結果を「Ingredient配列」として受け取る

次に、呼び出し側で respond(to:generating:) を使い、生成結果の型として [Ingredient] を指定します。これにより、返ってくる結果は文字列ではなく Ingredient の配列として扱えるようになります。

let response = try await session.respond(to: prompt, generating: [Ingredient].self)

この形にしておくと、SwiftUI 側は ForEach(ingredients, id: \.name) のようにそのままリスト表示できるため、コード上で扱いやすくなりました。

全体のコード
import SwiftUI
import FoundationModels
struct ContentView: View {
@State private var text: String = ""
@State private var result: [Ingredient] = []
@State private var error: String? = nil
@State private var isLoading: Bool = false
let model = SystemLanguageModel.default
private func send(text: String) async {
isLoading = true
defer { isLoading = false }
do {
let instructions = """
あなたは料理の材料を教えてくれるアシスタントです。
ユーザーが入力した料理に必要な材料を返してください。
材料は「材料名」「目安の量」を含めてください。
"""
let session = LanguageModelSession(model: model, instructions: instructions)
let prompt = """
ユーザーの入力は「\(text)」です。
この料理を作るのに必要な材料を教えてください。
"""
let response = try await session.respond(to: prompt, generating: [Ingredient].self)
self.result = response.content
self.error = nil
} catch {
self.error = "エラーが発生しました: \(error.localizedDescription)"
}
}
var body: some View {
if model.isAvailable {
VStack {
Text("""
こんにちは!
今から作りたい料理を教えてください!
材料を教えます!
""")
.padding()
.fixedSize(horizontal: false, vertical: true)
TextField("作りたいものはなんですか?", text: $text)
.textFieldStyle(.roundedBorder)
.padding()
Button("送る") {
Task { await send(text: text) }
}
.buttonStyle(.borderedProminent)
.tint(.black)
.disabled(isLoading)
if isLoading {
ProgressView()
} else {
ScrollView {
if let error {
Text(error)
} else {
ForEach(result, id: \.name) { ingredient in
HStack {
Text(ingredient.name)
.padding(.horizontal)
Text(ingredient.amount)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
.padding(.horizontal)
}
}
}
}
} else {
Text("Not available")
}
}
}
@Generable
public struct Ingredient {
@Guide(description: "材料の名前")
let name: String
@Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
let amount: String
}

@Generable によって生成結果を型として受け取れるため、UI側はシンプルに実装でき、拡張もしやすい構成になりました。

A mobile phone screen displaying a user interface that asks for a type of dish to be prepared. The text is in Japanese and includes a greeting and a prompt for ingredients, with a text input field and a send button.
材料とその分量を分けて表示したバージョン

応用編

@Generableと@Guideを使って材料だけでなく、調理法や難易度なども出力してみたいと思います。
@Guideには以下のように.rangeや.countをつけることによって値を制限することができます。


    @Guide(description: "何人前か", .range(1...6))
    let servings: Int


これらの機能を使って出力内容の情報を増やしてみます。

@Generable
public struct Recipe {
@Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。")
let title: String
@Guide(description: "何人前か", .range(1...6))
let servings: Int
@Guide(description: "調理時間(分)", .range(1...180))
let cookTimeMinutes: Int
@Guide(description: "難易度(easy/normal/hard のいずれか)")
let difficulty: Difficulty
@Guide(description: "材料の配列", .count(8))
let ingredients: [Ingredient]
@Guide(description: "手順(短文)", .count(6))
let steps: [String]
}
コード全体はこちら
import SwiftUI
import FoundationModels
struct ContentView: View {
@State private var text: String = ""
@State private var result: Recipe = .init(
title: "",
servings: 2,
cookTimeMinutes: 15,
difficulty: .easy,
ingredients: [],
steps: []
)
@State private var error: String? = nil
@State private var isLoading: Bool = false
let model = SystemLanguageModel.default
private func send(text: String) async {
isLoading = true
defer { isLoading = false }
do {
let instructions = """
あなたは料理の材料と手順を教えてくれるアシスタントです。
ユーザーが入力した料理に必要な材料と手順を返してください。
"""
let session = LanguageModelSession(model: model, instructions: instructions)
let prompt = """
ユーザーの入力は「\(text)」です。
この料理を作るのに必要な材料と手順を教えてください。
"""
let response = try await session.respond(to: prompt, generating: Recipe.self)
self.result = response.content
self.error = nil
} catch {
self.error = "エラーが発生しました: \(error.localizedDescription)"
}
}
var body: some View {
if model.isAvailable {
VStack(spacing: 12) {
Text("""
こんにちは!
今から作りたい料理を教えてください!
材料と手順を教えます!
""")
.padding(.top, 8)
.fixedSize(horizontal: false, vertical: true)
TextField("作りたいものはなんですか?(例: 親子丼)", text: $text)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button("送る") {
Task { await send(text: text) }
}
.buttonStyle(.borderedProminent)
.tint(.black)
.disabled(isLoading)
if isLoading {
ProgressView().padding(.top, 8)
}
if let error {
Text(error).foregroundStyle(.red).padding(.horizontal)
} else {
List {
Section("概要") {
Text(result.title.isEmpty ? "—" : result.title)
HStack {
Text("人数")
Spacer()
Text("\(result.servings)人前").foregroundStyle(.secondary)
}
HStack {
Text("調理時間")
Spacer()
Text("\(result.cookTimeMinutes)分").foregroundStyle(.secondary)
}
HStack {
Text("難易度")
Spacer()
Text(result.difficulty.label).foregroundStyle(.secondary)
}
}
Section("材料") {
if result.ingredients.isEmpty {
Text("—")
.foregroundStyle(.secondary)
} else {
ForEach(result.ingredients, id: \.name) { ingredient in
HStack {
Text(ingredient.name)
Spacer()
Text(ingredient.amount)
.foregroundStyle(.secondary)
}
}
}
}
Section("手順") {
if result.steps.isEmpty {
Text("—")
.foregroundStyle(.secondary)
} else {
ForEach(Array(result.steps.enumerated()), id: \.offset) { idx, step in
HStack(alignment: .top, spacing: 8) {
Text("\(idx + 1).")
.foregroundStyle(.secondary)
Text(step)
}
}
}
}
}
}
}
} else {
Text("Not available")
}
}
}
@Generable
public struct Recipe {
@Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。")
let title: String
@Guide(description: "何人前か", .range(1...6))
let servings: Int
@Guide(description: "調理時間(分)", .range(1...180))
let cookTimeMinutes: Int
@Guide(description: "難易度(easy/normal/hard のいずれか)")
let difficulty: Difficulty
@Guide(description: "材料の配列", .count(8))
let ingredients: [Ingredient]
@Guide(description: "手順(短文)", .count(6))
let steps: [String]
}
@Generable
public struct Ingredient {
@Guide(description: "材料の名前")
let name: String
@Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
let amount: String
}
@Generable
public enum Difficulty: String, CaseIterable {
case easy
case normal
case hard
var label: String {
switch self {
case .easy: return "かんたん"
case .normal: return "ふつう"
case .hard: return "むずかしい"
}
}
}

完成したアプリ

A digital interface in Japanese asking for a recipe, including input fields for number of servings, preparation time, difficulty level, ingredients, and procedure.

少し調理手順が怪しい(カレーソースの作り方が斬新)ですが、かなり機能が充実しましたね!

終わりに

今回は、Foundation Models の @Generable を使って、生成結果を文字列ではなく Swift の型(struct)として受け取る実装を試してみました。

まずは respond(to:) で文字列として材料を返すところから始め、次に respond(to: generating:)[Ingredient] を直接受け取るようにすることで、SwiftUI 側は ForEach でそのまま表示でき、後処理のパースが不要になりました。
さらに応用編では、Recipe(材料+手順+メタ情報)に拡張し、@Guide(.range)@Guide(.count)enum を組み合わせることで、出力の揺れを抑えながら情報量を増やすことができました。

生成結果の品質(今回だと調理手順の妥当性)は、プロンプトや @Guide の設計でまだ改善の余地がありますが、「生成AIの結果をアプリのデータとして扱える」という体験はかなり強力でした。
次のステップとしては、材料のカテゴリ分け(野菜/肉/調味料)を型で固定して表示を整える、ユーザー条件(人数・アレルギー・時短など)を入力として追加して精度を上げる、といった方向に拡張していくと実用度がさらに上がりそうです。

ここまで読んでいただき、ありがとうございました。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる