Swift Playgrounds「ミームメーカー」Appプロジェクトを解説する


# この投稿は何？

iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「ミームメーカー」Appプロジェクトを学ぶための解説です。

学べること

- 非同期的にデータ（画像、JSON）を取得する仕組み

# Appの概要
「ミームメーカー」は、[WEBサイト上にあるJSONデータ](http://playgrounds-cdn.apple.com/assets/pandaData.json)を取得してパンダミームを作成するアプリです。

ミームを作成するには、指定したURLから画像を読み込みます。
画像を読み込む際には、インターネット上のサーバに接続するので、状況によっては時間がかかることもあります。
そこで、非同期的なリクエストを使用します。
これによって、アプリは画像読み込みを待機している間も他の処理を継続し、ユーザの操作にも反応できるようになります。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/64b44c0c-08dd-abe9-928f-4dff3447cc96.jpeg">



# Appプロジェクトの全体
アプリを構成するビューやデータモデルは、以下の6つです。

- `MemoCreatorApp`構造体
アプリ自体であり、ビュー階層のトップ。

- `Panda`構造体
パンダをモデル化したデータ。
説明文と画像を示すプロパティがある。

- `PandaCollection`構造体
パンダデータの配列。

- `PandaCollectionFetcher`クラス
ビュー階層全体にわたって共有されるデータモデル。
`ObservableObject`プロトコルに準拠。

- `LoadableImage`構造体
非同期的に読み込まれる画像のビュー。

- `MemeCreator`構造体
アプリのメイン画面となるビュー。


# 解説

## データをビュー階層全体で共有する

共有データを取得するために、`@StateObject`属性のプロパティとして`PandaCollectionFetcher`インスタンスを作成します。
作成した`PandaCollectionFetcher`型インスタンスは環境オブジェクトとして、`MemeCreator`ビューに渡します。
環境オブジェクトを使用すると、他の下層ビューでもデータを利用できるようになります。

```Swift:MemeCreatorApp.swift
import SwiftUI

@main
struct MemeCreatorApp: App {
    @StateObject private var fetcher = PandaCollectionFetcher()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MemeCreator()
                    .environmentObject(fetcher)
            }
            .navigationViewStyle(.stack)
        }
    }
}
```

## JSONデータをモデル化する

`Panda`型はパンダをモデル化します。
[パンダのURL](http://playgrounds-cdn.apple.com/assets/pandaData.json)から取得できるJSONファイルに基づいて定義されています。
説明文は`String`型の`description`プロパティです。
画像元のURLは`URL`型の`imageUrl`プロパティです。

`PandaCollection`はパンダの配列です。
簡単にデコードできるようにするため、JSONファイルのデータ形式を反映しています。

```Swift:Panda.swift
import SwiftUI

struct Panda: Codable {
    var description: String
    var imageUrl: URL?
    
    static let defaultPanda = Panda(description: "Cute Panda",
                                    imageUrl: URL(string: "https://assets.devpubs.apple.com/playgrounds/_assets/pandas/pandaBuggingOut.jpg"))
}

struct PandaCollection: Codable {
    var sample: [Panda]
}
```

## 非同期関数を構成する

`PandaCollectionFetcher`クラスは、「アプリで利用されるデータ」を取得する手続きを行います。
このクラスはオブザーバブルオブジェクトなので、プロパティ値が変化すると、その値を監視しているすべてのビューに対して描画の更新を通知します。
実際には、パンダの画像と説明文を更新を待機するビューがあります。

`@Published`属性の公開値は2つです。
1つは、パンダの配列である`imageData`プロパティです。
もう一つは、パンダのモデルを示す`currentPanda`です。
アプリのUIにはパンダのモデルが表示されます。

アプリで利用するJSONデータを取得するための`fetchData()`メソッドは、`async`がマークされた非同期関数です。
インターネットからデータをダウンロードする際に時間がかかる可能性があります。
そのため、非同期関数はダウンロードが完了するまでは一時的にコードが停止します。
しかしながら、非同期関数はその間もアプリはバックグラウンドで他のコードを実行し続けることができます。
このメソッドに`async`キーワードをマークしなかった場合、ダウンロード完了までアプリのコードが完全に停止するため、ユーザーはアプリの挙動にラグを感じるかもしれません。

`fetchData()`メソッドには、エラーをスローできる`throws`もマークされています。
したがって、このメソッドを呼び出す際は、発生しうるエラーに対処する必要があります。

メソッドのボディでは最初に、`guard-let`構文を使って「URLが有効かどうか」を確認しています。
次に、別の非同期関数として`URLSession.shared`インスタンスの`data(for:)`メソッドを呼び出しています。
この`data(for:)`メソッドはパラメータとしてURLリクエストを受け取ります。
ここでも、URLリクエストのレスポンスを待機する間にコードが一時的に停止するため、メソッド呼び出しに`await`をマークします。
したがって、レスポンス待機中も「アプリの他のコード」は実行を継続できます。

レスポンスを得られたら、エラーでない（つまり、ステータスコードが`200`である）ことを確認します。
レスポンスがエラーだった場合は、その旨をスローします。

`data(for:)`メソッドは最後に、JSONファイルをデコードします。
デコードした結果は、公開値の`imageData`に設定します。

以上の手続きにより、パンダミームを作成するために必要なデータが完成します。

```Swift:PandaCollectionFetcher.swift
import SwiftUI

@MainActor
class PandaCollectionFetcher: ObservableObject {
    @Published var imageData = PandaCollection(sample: [Panda.defaultPanda])
    @Published var currentPanda = Panda.defaultPanda
    
    let urlString = "http://playgrounds-cdn.apple.com/assets/pandaData.json"
    
    enum FetchError: Error {
        case badRequest
        case badJSON
    }
    
    @available(iOS 15.0, *)
    func fetchData() async throws {
        guard let url = URL(string: urlString) else { return }
        let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badRequest }

        imageData = try JSONDecoder().decode(PandaCollection.self, from: data)
    }
    
}
```

## 非同期的に画像を読み込む

`LoadableImage`ビューでは、非同期的に画像を読み込む`AsyncImage`ビューを利用しています。
JSONデータに指定されている画像名をもとに、インターネットから画像を読み込みます。

パンダのモデルを示す`imageMetaData`プロパティには、読み込むパンダの画像URLと説明文が含まれます。

`AsyncImage`ビューはパラメータとして画像のURLを受け取ります。
非同期的に画像を読み込む際、表示までに時間がかかる場合にプログレスバーを表示します。あるいは画像が読み込めなかった場合には「別の何か」を表示します。これらのロジックは引数`phase`に基づいたifステートメントで実装します。

`AsyncImage`ビューのイニシャライザでは、クロージャ内で`phase`を利用できます。
この`phase`から「最新の読み込み状況」を得ることができます。

読み込みが完了すると、`phase.image`から画像を取得できます。
`phase`にエラーが設定された場合は、その旨を示す画像ビューを作成します。
画像の読み込み中は、プログレスバーのアニメーションを表示します。

```Swift:LoadableImage.swift
import SwiftUI

struct LoadableImage: View {
    var imageMetadata: Panda

    var body: some View {
        AsyncImage(url: imageMetadata.imageUrl) { phase in
            if let image = phase.image {
                image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .cornerRadius(15)
                    .shadow(radius: 5)
                    .accessibility(label: Text(imageMetadata.description))
            } else if phase.error != nil {
                VStack {
                    Image("pandaplaceholder")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(maxWidth: 300)
                    Text("The pandas were all busy.")
                        .font(.title2)
                    Text("Please try again.")
                        .font(.title3)
                }
                
            } else {
                ProgressView()
            }
        }
    }
}

struct Panda_Previews: PreviewProvider {
    static var previews: some View {
        LoadableImage(imageMetadata: Panda.defaultPanda)
    }
}
```


## ミームを作成するUIの構築

アプリのメイン画面となる`MemeCreator`ビューでは、パンダミームを作成するためのUIを構築します。
ここには、パンダ画像と追加編集可能なテキストを表示します。

`MemeCreator`ビューは、`MemeCreatorApp`から「データモデルである`PandaCollectionFetcher`インスタンス」を環境オブジェクトとして受け取ります。
そのため、`fetcher`プロパティを`@EnvironmentObject`属性でマークしています。

パンダ画像を読み込むには、事前に`fetcher`がJSONデータを取得している必要があります。
`.task`モディファイアには「ビューが最初に表示されるときに実行したい手続き」を指定できます。
このモディファイアの中で`fetcher`の`fetchData()`メソッドを呼び出すことで、JSONデータを事前に取得できます。

`fetchData()`メソッドは非同期かつスロー関数です。
したがって、呼び出し時に`try? await`をマークします。
`await`キーワードは、メソッドが結果を返すまでの間にコードが待機することを示します。
`try?`キーワードは、スローされたエラーを無視することを示します。

ビューのUIでは、`LoadableImage`ビューがパンダ画像を非同期的に表示します。
表示されるパンダは`fetcher`の`currentPanda`プロパティです。
JSONデータ取得以前でも、デフォルトの`currentPanda`が提供されます。

パンダ画像はテキストをオーバーレイ表示します。
テキストとそのサイズ、色はステート変数なので、ユーザーの操作に応じて描画が更新されます。

このテキストフィールドビューには`.focused`もディファイアを設定します。
`.focused`モディファイアに`@FocusState`属性プロパティのバインディングを渡すことによって、テキストフィールドにフォーカスを合わせることができます。

パンダ画像を変更するためのボタンもあります。
ユーザーがボタンをタップすると、`PandaCollection`からランダムに取得された`Panda`が`fetcher`の`currentPanda`に設定されます。
`currentPanda`プロパティはオブザーバブルオブジェクトの公開値なので、変更に応じて`LoadableImage`ビューの描画も更新されます。

もうひとつのボタンはテキストを追加します。
タップされると、`isFocused`プロパティが`true`になってテキストフィールドがフォーカスされます。
これによって、自動的にカーソルがテキストフィールドに出現し、テキストを編集できるようになります。

スライダーとピッカーは、それぞれがテキストのサイズと色を変更します。
これらを設定するプロパティはステート変数なので、値が変更されるとビューの描画も更新されます。

```Swift:MemeCreator.swift
import SwiftUI

struct MemeCreator: View {
    @EnvironmentObject var fetcher: PandaCollectionFetcher

    @State private var memeText = ""
    @State private var textSize = 60.0
    @State private var textColor = Color.white

    @FocusState private var isFocused: Bool

    var body: some View {
        VStack(alignment: .center) {
            Spacer()
            LoadableImage(imageMetadata: fetcher.currentPanda)
                .overlay(alignment: .bottom) {
                    TextField(
                        "Meme Text",
                        text: $memeText,
                        prompt: Text("")
                    )
                    .focused($isFocused)
                    .font(.system(size: textSize, weight: .heavy))
                    .shadow(radius: 10)
                    .foregroundColor(textColor)
                    .padding()
                    .multilineTextAlignment(.center)
                }

            Spacer()
            
            if !memeText.isEmpty {
                VStack {
                    HStack {
                        Text("Font Size")
                            .fontWeight(.semibold)
                        Slider(value: $textSize, in: 20...140)
                    }
                    
                    HStack {
                        Text("Font Color")
                            .fontWeight(.semibold)
                        ColorPicker("Font Color", selection: $textColor)
                            .labelsHidden()
                        Spacer()
                    }
                }
                .padding(.vertical)
                .frame(maxWidth: 325)
                
            }

            HStack {
                Button {
                    if let randomImage = fetcher.imageData.sample.randomElement() {
                        fetcher.currentPanda = randomImage
                    }
                } label: {
                    VStack {
                        Image(systemName: "photo.on.rectangle.angled")
                            .font(.largeTitle)
                            .padding(.bottom, 4)
                        Text("Shuffle Photo")
                    }
                    .frame(maxWidth: 180, maxHeight: .infinity)
                }
                .buttonStyle(.bordered)
                .controlSize(.large)

                Button {
                    isFocused = true
                } label: {
                    VStack {
                        Image(systemName: "textformat")
                            .font(.largeTitle)
                            .padding(.bottom, 4)
                        Text("Add Text")
                    }
                    .frame(maxWidth: 180, maxHeight: .infinity)
                }
                .buttonStyle(.bordered)
                .controlSize(.large)
            }
            .fixedSize(horizontal: false, vertical: true)
            .frame(maxHeight: 180, alignment: .center)
        }
        .padding()
        .task {
            try? await fetcher.fetchData()
        }
        .navigationBarTitle("Meme Creator")
    }
}
```


<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/56867235-a62b-7287-a199-820ee28c55fb.jpeg">