はじめに
フリュー株式会社スマートフォンゲーム部でエンジニアをしています佐藤です。
業務で数年バックエンド開発を経験後、クライアント側にシフトし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
 
