この投稿は何?
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「ミームメーカー」Appプロジェクトを学ぶための解説です。
学べること
- 非同期的にデータ(画像、JSON)を取得する仕組み
Appの概要
「ミームメーカー」は、WEBサイト上にあるJSONデータを取得してパンダミームを作成するアプリです。
ミームを作成するには、指定したURLから画像を読み込みます。
画像を読み込む際には、インターネット上のサーバに接続するので、状況によっては時間がかかることもあります。
そこで、非同期的なリクエストを使用します。
これによって、アプリは画像読み込みを待機している間も他の処理を継続し、ユーザの操作にも反応できるようになります。

Appプロジェクトの全体
アプリを構成するビューやデータモデルは、以下の6つです。
-
MemoCreatorApp
構造体
アプリ自体であり、ビュー階層のトップ。 -
Panda
構造体
パンダをモデル化したデータ。
説明文と画像を示すプロパティがある。 -
PandaCollection
構造体
パンダデータの配列。 -
PandaCollectionFetcher
クラス
ビュー階層全体にわたって共有されるデータモデル。
ObservableObject
プロトコルに準拠。 -
LoadableImage
構造体
非同期的に読み込まれる画像のビュー。 -
MemeCreator
構造体
アプリのメイン画面となるビュー。
解説
データをビュー階層全体で共有する
共有データを取得するために、@StateObject
属性のプロパティとしてPandaCollectionFetcher
インスタンスを作成します。
作成したPandaCollectionFetcher
型インスタンスは環境オブジェクトとして、MemeCreator
ビューに渡します。
環境オブジェクトを使用すると、他の下層ビューでもデータを利用できるようになります。
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ファイルのデータ形式を反映しています。
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
に設定します。
以上の手続きにより、パンダミームを作成するために必要なデータが完成します。
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
にエラーが設定された場合は、その旨を示す画像ビューを作成します。
画像の読み込み中は、プログレスバーのアニメーションを表示します。
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
になってテキストフィールドがフォーカスされます。
これによって、自動的にカーソルがテキストフィールドに出現し、テキストを編集できるようになります。
スライダーとピッカーは、それぞれがテキストのサイズと色を変更します。
これらを設定するプロパティはステート変数なので、値が変更されるとビューの描画も更新されます。
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")
}
}
