Swift + ChatGPT APIで撮った写真にいい感じのタイトルをつけてくれるアプリを作った

# はじめに
フリュー株式会社スマートフォンゲーム部でエンジニアをしています佐藤です。
業務で数年バックエンド開発を経験後、クライアント側にシフトし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のアカウント作成手順は省略します。[公式サイト](https://platform.openai.com/)から作成可能です。
ログイン後、`Dashboard`画面の`API keys`を選択し、`Create new secret key`ボタンからシークレットキーを作成します。
![スクリーンショット 2024-12-05 15.35.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/9e640a29-15f3-49f2-9545-feff0186f345.png)
`Name`は適当に分かりやすいもので大丈夫です。
`Project`は`Default project`、`Permissions`は`All`でよいです。
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/94ab20fe-01dc-ece0-f301-f455cf92cbb5.png">

作成できたシークレットキーはコピーして覚えておきます。
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/fa748fca-9c17-2236-6349-861c74d9d5ea.png">

# 実装
ここからXcodeを起動し、実装に入ります。
`iOS`の`App`を選びます。
`macOS`だと`UIKit`が使用できないためご注意ください。
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/3c2bc35d-c452-e7c2-9c6e-8d79f8fd1a1d.png">
`Interface`に`SwiftUI`を指定して新しくプロジェクトを作成します。
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/06fd5bdf-eba2-2304-0deb-0c4d059e2825.png">

## レイアウト作成

まずは画面のUIから組み立てていきます。

```swift:ContentView.swift
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:)`を使ってアイコン表示しています。
```swift
// カメラのアイコン
Image(systemName: "camera")
```
`systemName`に設定可能な名前は以下の記事を参考にしてください。

https://qiita.com/kazy_dev/items/4983faa45630afa75b06

この状態で実行すると以下のような画面が出来上がりました。
撮影した画像の表示、その下にカメラ起動ボタンとタイトル生成ボタンが配置されています。
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/14d7a68e-d4f8-c3e8-782b-42b706f78ef7.png">


## カメラからの画像取得
次にカメラを起動して撮影した画像を取得します。
画像取得用として`ImagePicker`クラスを作ります。
<details><summary>ImagePicker.swift</summary>

```swift: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) {}
}
```

</details>

ここでは`UIKit`の仕組みを利用し、カメラ機能の操作をしています。
`UIViewControllerRepresentable`を利用することにより、`UIImagePickerController`（UIKitのビューコントローラ）をSwiftUIで使用することが可能です。
`@Binding var image`でこのクラスの外側から画像を取得できるようにし、`@Environment(\.dismiss) var dismiss`で現在のカメラ表示を閉じるアクションの参照を取得しています。

### Coordinator
```swift
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
```swift
func makeUIViewController(context: Context) -> UIImagePickerController {
    let picker = UIImagePickerController()
    picker.delegate = context.coordinator
    picker.sourceType = .camera // カメラで撮った写真を使う
    return picker
}
```

`makeUIViewController`では、UIKitの`UIImagePickerController`を作成しています。カメラで撮った画像を使用するため、`sourceType`では`.camera`を指定します。
※フォトライブラリから画像を取得する場合はここで`.photoLibrary`を指定することになります。

### updateUIViewController
```swift
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
```

カメラビューは一度作成されたあと、更新する必要がないため`updateUIViewController`では何も処理を書きません。

### ContentView側からの呼び出し処理

`ContentView`側でカメラ画面の起動と画像取得処理を追加します。
```swift:ContentView.swift
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`を追加することになります。
![スクリーンショット 2024-12-09 11.28.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/ee776c33-3006-945b-9cf8-1170a7bbe17a.png)

また、カメラ機能を使用する場合Xcodeのシミュレーターでは動作確認ができません。そのため、動作確認をする際は実機を繋ぐ必要があるのでご注意ください。

## タイトル生成
ここからはChatGPT APIを使ったタイトル生成処理です。
`ContentView`にタイトル生成処理のメソッドを追加します。
<details><summary>ContentView.swift</summary>

```swift: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)"])
        }
    }
}

```
</details>
以下、部分的に解説をしていきます。

### API Keyの設定
まず発行したAPI Keyを設定します。
Xcodeメニューの`Product`＞`Scheme`＞`Edit Scheme...`で設定ダイアログを表示し、`Run`画面の`Argument`タブを選択します。
`Environment Variables`に`OPENAI_API_KEY`という`Name`でAPI Keyを設定します。
![スクリーンショット 2024-12-11 18.19.56（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/88da3640-d571-fdf7-340a-b60203755feb.png)

そして`ContentView`の`init`で変数`apiKey`に値を設定することによりソースコード内で使用できるようにします。
```swift
private var apiKey: String
    
    init() {
        apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "API Key not Set"
    }
```



### タイトル表示部分

```swift
@State private var generatedTitle: String = ""

// 〜略〜

ZStack {
    // タイトル表示位置
    Rectangle()
        .stroke(Color.gray, lineWidth: 5)
        .frame(height: 80)
    
    if !generatedTitle.isEmpty {
        Text("\(generatedTitle)")
    }
}
```
上記は生成されたタイトルを状態変数`generatedTitle`として定義し、値が入ったらタイトル位置に表示しています。

### タイトル生成ボタン

```swift
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

```swift
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リクエストの準備

```swift
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リクエストボディの構築
```swift
"model": "gpt-4o-mini",
"max_completion_tokens": 20,
```
使用するモデルは比較的コスパが良いとされている`gpt-4o-mini`を採用します。また必要以上に応答のトークンが消費されないよう`max_completion_tokens`で上限を設定しています。
```swift
"role": "system",
"content": [
    [
        "type": "text",
        "text": "あなたはプロのコピーライターです。"
    ]
]
```
上記でシステムプロンプト（前提条件）を設定しています。
```swift
"role": "user",
"content": [
    [
        "type": "text",
        "text": "提供された画像にセンスある15文字以下のタイトルをつけてください"
    ],
    [
        "type": "image_url",
        "image_url": [
            "url": "data:image/jpeg;base64,\(imageBase64)"
        ]
    ]
]
```
上記でメインとなるテキスト指示と画像データ（base64エンコードされたデータ）を設定しています。

#### APIリクエストの送信
```swift
let (data, response) = try await URLSession.shared.data(for: request)
```
非同期でリクエストを送信しています。

#### HTTPレスポンスのチェック
```swift
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"])
}
```
レスポンスのステータスを確認し、問題があればエラーを投げるようにしています。

#### レスポンスの解析
```swift
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に記載があるので、ここでは割愛します。

https://github.com/airbnb/lottie-ios

[Lottie公式サイト](https://lottiefiles.com/jp/)から使用したいアニメーションを選びます。
今回は以下リンク先のLoadingアニメーションにしました。

https://lottiefiles.com/free-animation/loading-animation-dDllMXjht9

JSONをダウンロードし、`loading.json`とファイル名を変え、プロジェクト配下に置き`ContentView`側から呼び出す処理を追加します。
```swift:ContentView.swift
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`クラスを作成しました。

https://qiita.com/chitomo12/items/13a463ae6dbaf9b192ea

<details><summary>BlurView.swift</summary>

```swift: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
            }
        }
    }
}
```
</details>

さらに`ContentView`側で`BlurView`を呼び出すように実装を修正します。
```swift:ContentView.swift
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を撮影してタイトルを生成してみました。
![デモ動画 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211864/5cee705e-dee4-f16c-6ee0-532756edcba3.gif)
スタイリッシュなMagic Keyboardを「エレガントな入力」となかなかおしゃれに表現してくれています。
タイトルの出来具合についてはプロンプト次第なので、工夫の余地がありそうです。

# まとめ
全体のソースコードは以下になります。

<details><summary>ContentView.swift</summary>

```swift: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)"])
        }
    }
}
```
</details>

<details><summary>ImagePicker.swift</summary>

```swift: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) {}
}
```
</details>

<details><summary>BlurView.swift</summary>

```swift: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
            }
        }
    }
}
```
</details>

SwiftUIでのアプリ開発は初めてでしたが、導入自体は簡単で、宣言的UIや状態管理も分かりやすく、初心者でも本格的なアプリ開発が可能であることを実感できました。
ChatGPT APIに関しては、今回は`URLSession`を使用して直接HTTPリクエストを送信しましたが、Open AIのClient Libraryもあるようなので、そちらを使うとより簡単に実装できるようになるかと思います。

# バージョン情報
- Xcode 16.1
- iOS 15.8.3
- Lottie 4.5