LoginSignup
1
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-04-20

この投稿は何?

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

学べること

  • 非同期的にデータ(画像、JSON)を取得する仕組み

Appの概要

「ミームメーカー」は、WEBサイト上にあるJSONデータを取得してパンダミームを作成するアプリです。

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

Appプロジェクトの全体

アプリを構成するビューやデータモデルは、以下の6つです。

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

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

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

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

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

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

解説

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

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

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から取得できるJSONファイルに基づいて定義されています。
説明文はString型のdescriptionプロパティです。
画像元のURLはURL型のimageUrlプロパティです。

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

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に設定します。

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

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にエラーが設定された場合は、その旨を示す画像ビューを作成します。
画像の読み込み中は、プログレスバーのアニメーションを表示します。

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モディファイアには「ビューが最初に表示されるときに実行したい手続き」を指定できます。
このモディファイアの中でfetcherfetchData()メソッドを呼び出すことで、JSONデータを事前に取得できます。

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

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

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

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

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

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

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

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")
    }
}
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1