はじめに
フリュー株式会社スマートフォンゲーム部でエンジニアをしています佐藤です。
業務で数年バックエンド開発を経験後、クライアント側にシフトしUnityでの開発に携わっています。
今回はこれまで経験がなかったSwiftとChatGPT APIを使ってさくっとiOSアプリを作ってみましたので、ご紹介します。
対象
- SwiftUIを少し触ったことがあるくらいの方
- ChatGPTのAPIを使ってみたい方
作成するアプリ
アプリ内でカメラを起動し、撮った写真に対してChatGPTがいい感じのタイトルをつけてくれるというアプリを作ります。
写真にタイトルがつくだけで一気に「作品感」が出ますよね。それを目指します。
Swiftのデザインフレームワーク
SwiftでUIを作成する方法としていくつかのデザインフレームワークが存在します。
- SwiftUI
- 2019年にリリースされたコードベースのフレームワーク
- UIを状態に基づいて記述し、状態が変わるとUIが自動的に更新される宣言型のアプローチ
- シンプルなコードのみでレイアウトの実装が可能
- Xcode上でリアルタイムプレビューが可能
- UIKit
- iOSリリース当初から採用されているコードベースのフレームワーク
- コードでUIを操作する命令型のアプローチ
- SwiftUIと比較して詳細なUIの制御が可能
- 部分的にSwiftUIへカスタマイズすることが可能
- Storyboard
- Xcodeに組み込まれているデザインツール
- オブジェクトをドラッグ&ドロップで配置する
- 主にUIKitと共に使う
今回は、現在では主流となっており初心者でも学びやすいとされているSwiftUI
を採用します。
ただ、カメラで撮った写真を取り込む場合UIKit
の機能を使わないと取り込めないため、今回は部分的にUIKit
もカスタマイズして使います。
ChatGPT API
基本的にAPIの利用は有料です。
Open AIのアカウント作成時にクレジットカードを登録し、あらかじめクレジットをチャージしておくシステムとなっています。
クレジットが0になったとき自動課金するかどうかの設定も可能なため、OFFにしておくと安心です。
API keyの発行
Open AIのアカウント作成手順は省略します。公式サイトから作成可能です。
ログイン後、Dashboard
画面のAPI keys
を選択し、Create new secret key
ボタンからシークレットキーを作成します。
Name
は適当に分かりやすいもので大丈夫です。
Project
はDefault project
、Permissions
はAll
でよいです。
実装
ここからXcodeを起動し、実装に入ります。
iOS
のApp
を選びます。
macOS
だとUIKit
が使用できないためご注意ください。
Interface
にSwiftUI
を指定して新しくプロジェクトを作成します。
レイアウト作成
まずは画面のUIから組み立てていきます。
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
// 背景色を設定
Color.gray.edgesIgnoringSafeArea(.all)
VStack {
// タイトル表示位置
Rectangle()
.stroke(Color.gray, lineWidth: 5)
.frame(height: 80)
// 画像表示位置
Rectangle()
.fill(Color.black)
.frame(width: 200, height: 300, alignment: .center)
.overlay(Text("No Image").foregroundColor(.white))
HStack {
// カメラボタン
Button(action: {
print("カメラ起動")
}) {
Image(systemName: "camera") // システムのカメラアイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
// タイトル生成ボタン
Button(action: {
print("タイトル生成");
}) {
Image(systemName: "brain.head.profile") // 生成アイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
}
}
}
}
}
VStack
、HStack
、ZStack
を使ってそれぞれの要素を配置します。
各ボタンはImage(systemName:)
を使ってアイコン表示しています。
// カメラのアイコン
Image(systemName: "camera")
systemName
に設定可能な名前は以下の記事を参考にしてください。
この状態で実行すると以下のような画面が出来上がりました。
撮影した画像の表示、その下にカメラ起動ボタンとタイトル生成ボタンが配置されています。
カメラからの画像取得
次にカメラを起動して撮影した画像を取得します。
画像取得用としてImagePicker
クラスを作ります。
ImagePicker.swift
import SwiftUI
import UIKit
// カメラで撮った写真を取り出すためのクラス
struct ImagePicker: UIViewControllerRepresentable {
// 撮った写真の画像
@Binding var image: UIImage?
// カメラ表示を閉じるアクション
@Environment(\.dismiss) var dismiss
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
var parent: ImagePicker
init(parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.dismiss()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera // カメラで撮った写真を使う
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
}
ここではUIKit
の仕組みを利用し、カメラ機能の操作をしています。
UIViewControllerRepresentable
を利用することにより、UIImagePickerController
(UIKitのビューコントローラ)をSwiftUIで使用することが可能です。
@Binding var image
でこのクラスの外側から画像を取得できるようにし、@Environment(\.dismiss) var dismiss
で現在のカメラ表示を閉じるアクションの参照を取得しています。
Coordinator
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
var parent: ImagePicker
init(parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.dismiss()
}
}
上記部分はUIImagePickerController
のデリゲートを実装し、カメラの撮影結果の受け取り処理をしています。
imagePickerController
メソッドでは、撮影された画像(UIImage
)を info から取得し、parent.image
にセットしています。また、取得後はparent.presentationMode.wrappedValue.dismiss()
でカメラビューを閉じています。
さらにUIViewControllerRepresentable
を継承する場合、以下で説明するmakeUIViewController
とupdateUIViewController
を実装する必要があります。
makeUIViewController
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera // カメラで撮った写真を使う
return picker
}
makeUIViewController
では、UIKitのUIImagePickerController
を作成しています。カメラで撮った画像を使用するため、sourceType
では.camera
を指定します。
※フォトライブラリから画像を取得する場合はここで.photoLibrary
を指定することになります。
updateUIViewController
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
カメラビューは一度作成されたあと、更新する必要がないためupdateUIViewController
では何も処理を書きません。
ContentView側からの呼び出し処理
ContentView
側でカメラ画面の起動と画像取得処理を追加します。
struct ContentView: View {
@State private var showImagePicker = false
@State private var uiImage: UIImage?
var body: some View {
ZStack {
// 〜略〜
// 画像表示位置
if let image = uiImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(height: 300, alignment: .center)
} else {
Rectangle()
.fill(Color.black)
.frame(width: 200, height: 300, alignment: .center)
.overlay(Text("No Image").foregroundColor(.white))
}
HStack {
// カメラボタン
Button(action: {
showImagePicker = true
}) {
Image(systemName: "camera") // システムのカメラアイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
// 〜略〜
}
}
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $uiImage)
}
}
}
showImagePicker
でカメラの表示状態を保持し、uiImage
でImagePicker
から取得できる画像データを保持しています。
またuiImage
が存在するかどうかで、画像表示部分のUIを書き換えており、カメラアイコン押下でshowImagePicker
をtrue
にし、.sheet
を使うことによってモーダルビューでカメラ画面を起動しています。
プライバシー権限の設定
iOSアプリがカメラにアクセスするには、Info.plist
に適切なプライバシー権限を追加する必要があります。
Xcode上でのプロジェクトのTARGETS
からinfo
タブを選択し、Privacy - Camera Usage Description
というkeyでカメラ使用時の文言を設定します。
※フォトライブラリから画像を取得する場合はPrivacy - Photo Library Usage Description
を追加することになります。
また、カメラ機能を使用する場合Xcodeのシミュレーターでは動作確認ができません。そのため、動作確認をする際は実機を繋ぐ必要があるのでご注意ください。
タイトル生成
ここからはChatGPT APIを使ったタイトル生成処理です。
ContentView
にタイトル生成処理のメソッドを追加します。
ContentView.swift
import SwiftUI
import Foundation
struct ContentView: View {
@State private var showImagePicker = false
@State private var uiImage: UIImage?
@State private var generatedTitle: String = ""
private var apiKey: String
init() {
apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
}
var body: some View {
ZStack {
// 背景色を設定
Color.gray.edgesIgnoringSafeArea(.all)
VStack {
ZStack {
// タイトル表示位置
Rectangle()
.stroke(Color.gray, lineWidth: 5)
.frame(height: 80)
if !generatedTitle.isEmpty {
Text("\(generatedTitle)")
}
}
// 〜略〜
Button(action: {
if let image = uiImage {
Task {
await generateTitle(image: image)
}
}
}) {
Image(systemName: "brain.head.profile") // 生成アイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
// 〜略〜
/// タイトルを生成する
///
/// - Parameters:
/// - image: 撮った画像データ
func generateTitle(image: UIImage) async {
// UIImageをbase64に変換
let imageData = image.jpegData(compressionQuality: 0.5)!
let base64String = imageData.base64EncodedString()
do {
// chatgpt_apiへ送信
let title = try await fetchGeneratedTitle(imageBase64: base64String)
generatedTitle = title
} catch {
generatedTitle = "生成失敗: \(error.localizedDescription)"
}
}
/// ChatGPT APIを使用して生成されたタイトルを取得する
///
/// - Parameters:
/// - imageBase64: 撮った写真をbase64変換したもの
func fetchGeneratedTitle(imageBase64: String) async throws -> String {
let url = URL(string: "https://api.openai.com/v1/chat/completions")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
// chatgpt_apiへ送るbodyの組み立て
let requestBody = [
"model": "gpt-4o-mini",
"max_completion_tokens": 20,
"messages": [
[
"role": "system",
"content": [
[
"type": "text",
"text": "あなたはプロのコピーライターです。"
]
]
],
[
"role": "user",
"content": [
[
"type": "text",
"text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
],
[
"type": "image_url",
"image_url": [
"url": "data:image/jpeg;base64,\(imageBase64)"
]
]
]
]
]
] as [String : Any]
// bodyの配列をjson形式に変換
do {
let json = try JSONSerialization.data(withJSONObject: body)
print("json request: \(String(bytes: json, encoding: .utf8)!)")
request.httpBody = json
} catch(let e) {
print(e)
}
// API実行
let (data, response) = try await URLSession.shared.data(for: request)
// HTTPレスポンスのチェック
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
}
// レスポンスデータの解析
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any],
let text = message["content"] as? String {
return text.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
}
} catch {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
}
}
}
API Keyの設定
まず発行したAPI Keyを設定します。
XcodeメニューのProduct
>Scheme
>Edit Scheme...
で設定ダイアログを表示し、Run
画面のArgument
タブを選択します。
Environment Variables
にOPENAI_API_KEY
というName
でAPI Keyを設定します。
そしてContentView
のinit
で変数apiKey
に値を設定することによりソースコード内で使用できるようにします。
private var apiKey: String
init() {
apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
}
タイトル表示部分
@State private var generatedTitle: String = ""
// 〜略〜
ZStack {
// タイトル表示位置
Rectangle()
.stroke(Color.gray, lineWidth: 5)
.frame(height: 80)
if !generatedTitle.isEmpty {
Text("\(generatedTitle)")
}
}
上記は生成されたタイトルを状態変数generatedTitle
として定義し、値が入ったらタイトル位置に表示しています。
タイトル生成ボタン
Button(action: {
if let image = uiImage {
// タイトル生成APIの実行
Task {
await generateTitle(image: image)
}
}
}) {
Image(systemName: "brain.head.profile") // 生成アイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
上記のようにタイトル生成ボタンのaction処理を追加します。
uiImage
がnullではない状態でボタンを押すと、generateTitle
メソッドが実行されます。
generateTitle
func generateTitle(image: UIImage) async {
// UIImageをbase64に変換
let imageData = image.jpegData(compressionQuality: 0.5)!
let base64String = imageData.base64EncodedString()
do {
// chatgpt_apiへ送信
let title = try await fetchGeneratedTitle(imageBase64: base64String)
generatedTitle = title
} catch {
generatedTitle = "生成失敗: \(error.localizedDescription)"
}
}
上記のメソッドでは、まずカメラで撮影した画像データを受け取り、image.jpegData()
でUIImage
型からjpegのData
型に変換しています。このとき引数のcompressionQuality
で圧縮率を設定しています。
さらにimageData.base64EncodedString()
でbase64変換し文字列にします。
そして以下に解説するfetchGeneratedTitle
メソッドでChatGPT APIを実行し、その結果を状態変数generatedTitle
に設定しています。
fetchGeneratedTitle
APIリクエストの準備
let url = URL(string: "https://api.openai.com/v1/chat/completions")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
apiKey
はBearer
として設定します。
JSONリクエストボディの構築
"model": "gpt-4o-mini",
"max_completion_tokens": 20,
使用するモデルは比較的コスパが良いとされているgpt-4o-mini
を採用します。また必要以上に応答のトークンが消費されないようmax_completion_tokens
で上限を設定しています。
"role": "system",
"content": [
[
"type": "text",
"text": "あなたはプロのコピーライターです。"
]
]
上記でシステムプロンプト(前提条件)を設定しています。
"role": "user",
"content": [
[
"type": "text",
"text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
],
[
"type": "image_url",
"image_url": [
"url": "data:image/jpeg;base64,\(imageBase64)"
]
]
]
上記でメインとなるテキスト指示と画像データ(base64エンコードされたデータ)を設定しています。
APIリクエストの送信
let (data, response) = try await URLSession.shared.data(for: request)
非同期でリクエストを送信しています。
HTTPレスポンスのチェック
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
}
レスポンスのステータスを確認し、問題があればエラーを投げるようにしています。
レスポンスの解析
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any],
let text = message["content"] as? String {
return text.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
}
} catch {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
}
レスポンスをJSON形式でパースし、解答を取り出していきます。
知りたい解答はchoices
内のmessage
内のcontent
に入っているので、順次取り出し、ついでに前後の空白を削除した上で取得しています。
アニメーション
ここまでで、撮影した画像にChatGPTがいい感じのタイトルをつけてくれるようになりました。
ただタイトル表示の仕方が味気ないので、少しだけアニメーションをつけましょう。
Loading
APIを実行してからレスポンスがあるまで少し時間があるので、その間Loadingアニメーションをつけます。
Lottieアニメーション
Lottieは、簡単にアプリにアニメーションを組み込むことができるJSON形式のアニメーションファイルです。
インストール方法は以下READMEに記載があるので、ここでは割愛します。
Lottie公式サイトから使用したいアニメーションを選びます。
今回は以下リンク先のLoadingアニメーションにしました。
JSONをダウンロードし、loading.json
とファイル名を変え、プロジェクト配下に置きContentView
側から呼び出す処理を追加します。
import SwiftUI
import Lottie
struct ContentView: View {
@State private var isLoading = false // Loading中であることの状態変数を追加
// 〜略〜
// タイトル表示位置
Rectangle()
.stroke(Color.gray, lineWidth: 5)
.frame(height: 80)
if isLoading {
// ローディング中はLoadingアニメーションを再生させる
LottieView(animation: .named("loading"))
.looping()
.frame(width: 300, height: 80)
} else if !generatedTitle.isEmpty {
Text("\(generatedTitle)")
}
// 〜略〜
func generateTitle(image: UIImage) async {
isLoading = true
defer { isLoading = false } // 処理終了後にローディングを停止
// 〜略〜
}
これにより、タイトル生成ボタンを押してから生成結果が返ってくるまでLoadingアニメーションが再生されるようになりました。
Lottie
のバージョンが4.3.0
より前だと、UIKit
のLottieAnimationView
を使って実装する必要がありますのでご注意ください。
タイトル表示演出
タイトル表示は、1文字ずつ徐々に表示されるようアニメーションをつけます。以下記事を参考にしBlurView
クラスを作成しました。
BlurView.swift
import SwiftUI
// テキストを1文字ずつ徐々に表示させる
struct BlurView: View {
let characters: Array<String.Element>
let baseTime: Double
let textSize: Double
@State var blurValue: Double = 10 // ぼかしの値
@State var opacity: Double = 0 // 透明度
init(text:String, textSize: Double, startTime: Double) {
characters = Array(text)
self.textSize = textSize
baseTime = startTime
}
var body: some View {
HStack(spacing: 1) {
ForEach(0..<characters.count) { index in
Text(String(self.characters[index]))
.font(.custom("HiraMinProN-W3", fixedSize: textSize))
.foregroundColor(.black)
.blur(radius: blurValue) // ぼかしの設定
.opacity(opacity) // 透明度の設定
.animation(.easeInOut.delay( Double(index) * 0.15 ), value: blurValue) // 1文字ずつ表示されるようdelayでずらしてアニメーション
}
}
.onAppear {
// 初回表示時のみアニメーション開始
DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
blurValue = 0
opacity = 1
}
}
}
}
さらにContentView
側でBlurView
を呼び出すように実装を修正します。
if isLoading {
LottieView(animation: .named("loading"))
.looping()
.frame(width: 300, height: 80)
} else if !generatedTitle.isEmpty {
// 1文字ずつ徐々に表示させる
BlurView(text: "\(generatedTitle)", textSize: 38, startTime: 0.41)
}
これにより、生成されたタイトルが1文字ずつ徐々に表示されるようなアニメーションになりました。
最終的な挙動
試しに机の上のMagic Keyboardを撮影してタイトルを生成してみました。
スタイリッシュなMagic Keyboardを「エレガントな入力」となかなかおしゃれに表現してくれています。
タイトルの出来具合についてはプロンプト次第なので、工夫の余地がありそうです。
まとめ
全体のソースコードは以下になります。
ContentView.swift
import SwiftUI
import Foundation
import Lottie
struct ContentView: View {
@State private var showImagePicker = false
@State private var uiImage: UIImage?
@State private var generatedTitle: String = ""
@State private var isLoading = false
private var apiKey: String
init() {
apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
}
var body: some View {
ZStack {
// 背景色を設定
Color.gray.edgesIgnoringSafeArea(.all)
VStack {
ZStack {
// タイトル表示位置
Rectangle()
.stroke(Color.gray, lineWidth: 5)
.frame(height: 80)
if isLoading {
LottieView(animation: .named("loading"))
.looping()
.frame(width: 300, height: 80)
} else if !generatedTitle.isEmpty {
BlurView(text: "\(generatedTitle)", textSize: 38, startTime: 0.41)
}
}
// 画像表示位置
if let image = uiImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(height: 300, alignment: .center)
} else {
Rectangle()
.fill(Color.black)
.frame(width: 200, height: 300, alignment: .center)
.overlay(Text("No Image").foregroundColor(.white))
}
HStack {
Button(action: {
showImagePicker = true
}) {
Image(systemName: "camera") // システムのカメラアイコン
.resizable()
.frame(width: 50, height: 40) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
Button(action: {
if let image = uiImage {
Task {
await generateTitle(image: image)
}
}
}) {
Image(systemName: "brain.head.profile") // 生成アイコン
.resizable()
.frame(width: 50, height: 50) // 画像のサイズ調整
.foregroundColor(.white) // アイコンの色
}
.padding()
}
}
}
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $uiImage)
}
}
/// タイトルを生成する
///
/// - Parameters:
/// - image: 撮った画像データ
func generateTitle(image: UIImage) async {
isLoading = true
defer { isLoading = false } // 処理終了後にローディングを停止
// UIImageをbase64に変換
let imageData = image.jpegData(compressionQuality: 0.5)!
let base64String = imageData.base64EncodedString()
do {
// chatgpt_apiへ送信
let title = try await fetchGeneratedTitle(imageBase64: base64String)
generatedTitle = title
} catch {
generatedTitle = "生成失敗: \(error.localizedDescription)"
}
}
/// ChatGPT APIを使用して生成されたタイトルを取得する
///
/// - Parameters:
/// - imageBase64: 撮った写真をbase64変換したもの
func fetchGeneratedTitle(imageBase64: String) async throws -> String {
let url = URL(string: "https://api.openai.com/v1/chat/completions")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
// chatgpt_apiへ送るbodyの組み立て
let requestBody = [
"model": "gpt-4o-mini",
"max_completion_tokens": 20,
"messages": [
[
"role": "system",
"content": [
[
"type": "text",
"text": "あなたはプロのコピーライターです。"
]
]
],
[
"role": "user",
"content": [
[
"type": "text",
"text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
],
[
"type": "image_url",
"image_url": [
"url": "data:image/jpeg;base64,\(imageBase64)"
]
]
]
]
]
] as [String : Any]
// bodyの配列をjson形式に変換
do {
let json = try JSONSerialization.data(withJSONObject: requestBody)
print("json request: \(String(bytes: json, encoding: .utf8)!)")
request.httpBody = json
} catch(let e) {
print(e)
}
// API実行
let (data, response) = try await URLSession.shared.data(for: request)
// HTTPレスポンスのチェック
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? -1, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"])
}
// レスポンスデータの解析
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any],
let text = message["content"] as? String {
return text.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])
}
} catch {
throw NSError(domain: "ParsingError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response: \(error.localizedDescription)"])
}
}
}
ImagePicker.swift
import SwiftUI
import UIKit
// カメラで撮った写真を取り出すためのクラス
struct ImagePicker: UIViewControllerRepresentable {
// 撮った写真の画像
@Binding var image: UIImage?
// カメラ表示を閉じるアクション
@Environment(\.dismiss) var dismiss
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
var parent: ImagePicker
init(parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.dismiss()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera // カメラで撮った写真を使う
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
}
BlurView.swift
import SwiftUI
// テキストを1文字ずつ徐々に表示させる
struct BlurView: View {
let characters: Array<String.Element>
let baseTime: Double
let textSize: Double
@State var blurValue: Double = 10 // ぼかしの値
@State var opacity: Double = 0 // 透明度
init(text:String, textSize: Double, startTime: Double) {
characters = Array(text)
self.textSize = textSize
baseTime = startTime
}
var body: some View {
HStack(spacing: 1) {
ForEach(0..<characters.count) { index in
Text(String(self.characters[index]))
.font(.custom("HiraMinProN-W3", fixedSize: textSize))
.foregroundColor(.black)
.blur(radius: blurValue) // ぼかしの設定
.opacity(opacity) // 透明度の設定
.animation(.easeInOut.delay( Double(index) * 0.15 ), value: blurValue) // 1文字ずつ表示されるようdelayでずらしてアニメーション
}
}
.onAppear {
// 初回表示時のみアニメーション開始
DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
blurValue = 0
opacity = 1
}
}
}
}
SwiftUIでのアプリ開発は初めてでしたが、導入自体は簡単で、宣言的UIや状態管理も分かりやすく、初心者でも本格的なアプリ開発が可能であることを実感できました。
ChatGPT APIに関しては、今回はURLSession
を使用して直接HTTPリクエストを送信しましたが、Open AIのClient Libraryもあるようなので、そちらを使うとより簡単に実装できるようになるかと思います。
バージョン情報
- Xcode 16.1
- iOS 15.8.3
- Lottie 4.5