オブジェクト間のバインディング
オブジェクト間の関係を整理する
6.2 データバインディング でデータバインディングは、UIとコード内の変数であることを説明しました。今回のサンプルは、グリッド表示のUIにImageLoaderクラスのimageDatasプロパティの値をバインディングして、検索結果を表示させたいです。
バインディングの本質に戻ると、変数の値に変化があった場合に、その変化がUIにも反映されます。
今回のサンプルでは、ImageLoaderクラスのimageDatasプロパティの値に変化があった場合に、その値の変化がグリッド表示に反映されるようにしたいです。
図示すると、このようにオブジェクトを2段階経由したバインディングとなります。
ImageLoaderクラス ー ContentView構造体 ー 画面のグリッド表示のUI

「ImageLoaderクラス ー ContentView構造体 」「 ContentView構造体 ー 画面のグリッド表示のUI」の2つのバインディング関係を実装していきます。
ImageLoaderクラス ー ContentView構造体 の関係
まず最初に「ImageLoaderクラス ー ContentView構造体」のバインディング関係を実装します。
ContentView構造体というUIを管理するコードから、別のクラスをバインディングに利用する場合は、次のように ObservableObject プロトコルを利用します。
https://developer.apple.com/documentation/combine/observableobject
ImageLoader.swift を次のように編集してください。
動作確認の際には、コード内の api_key の値を取得した Pixabay API の値に書き換えて実行してください。
import Foundation class ImageLoader: ObservableObject { // Pixabay API key let api_key = "xxxxxxxxxxxxxx" // 素材情報 @Published var imageDatas: [ImageData] = [] init() { self.imageDatas = [] } # 略
バインディングに利用したいクラスにObservableObject プロトコルを実装します。
プロパティには「@Published」をつけてバインディングに利用するプロパティであることを明記します。
「@Published」をつけたプロパティは、構造体でUIと接続します。つまり、プロパティの値が変更された場合は、UIが更新されるという意味です。
iOSアプリでは、複数のタスクやプログラムが同時に動いています。それぞれのタスクやプログラムを実行している機能の単位はスレッドと呼ばれます。UIの表示はアプリの中の画面表示を担うメインスレッドというタスクの中で処理されます。したがって「@Published」をつけたプロパティはメインスレッドの中で値を更新するように処理を書く必要があります。
メインスレッドで処理を行う処理は DispatchQueue.main.async ブロックで囲うことで実装できます。
サンプルでは次のように ImageLoader クラスの searchImages メソッド内の imageDatas プロパティの値を更新する処理を DispatchQueue.main.async ブロックで囲います。
func searchImages(keyword: String) async throws {
    DispatchQueue.main.async {    // メインスレッドで処理
      self.imageDatas = []
    } 
    let urlStr = "https://pixabay.com/api/?key=\(self.api_key)&q=\(keyword)"
    let url = URL(string: urlStr.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)!)!
    let (data, response) = try await URLSession.shared.data(from: url)
    // ステータスコードが200でなければエラー
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw PixabayAPIError.serverError }
    // JSONを解析して作成した構造体の通りにマッピング
    guard let decoded = try? JSONDecoder().decode(SearchImageDataModel.self, from: data) else { throw PixabayAPIError.noData }
    DispatchQueue.main.async {    // メインスレッドで処理
      self.imageDatas = decoded.hits
    }
}
ContentView構造体 ー 画面のグリッド表示のUI の関係
「ContentView構造体 ー 画面のグリッド表示のUI」のバインディング関係を実装します。
グリッド表示については次の節で説明しますので、ここでは先にインスタンスの対応を行います。
バインディングに利用したいクラスのインスタンスに「@StateObject」をつけます。
ContentView.swift を次のように編集してください。
struct ContentView: View {
    
    @State var inputText = ""
    @State var buttonEnabled = false
    @StateObject var imageLoader = ImageLoader()
    
    var body: some View {
# 略
「@StateObject」をつける場合は、let でなく var でインスタンスを宣言することに気をつけてください。
上記2つの処理でオブジェクトを2段階経由したバインディングを実現できます。
コードの確認
説明が長くなりましたので、サンプルコードは抜粋しか掲載していませんでした。
ここで修正したサンプルコードの全体を確認します。
ImageLoader.swift
import Foundation class ImageLoader: ObservableObject { // Pixabay API key let api_key = "xxxxxxxxxxxxxx" // 素材情報 @Published var imageDatas: [ImageData] = [] init() { self.imageDatas = [] } func searchImages(keyword: String) async throws { DispatchQueue.main.async { // メインスレッドで処理 self.imageDatas = [] } let urlStr = "https://pixabay.com/api/?key=\(self.api_key)&q=\(keyword)" let url = URL(string: urlStr.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)!)! let (data, response) = try await URLSession.shared.data(from: url) // ステータスコードが200でなければエラー guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw PixabayAPIError.serverError } // JSONを解析して作成した構造体の通りにマッピング guard let decoded = try? JSONDecoder().decode(SearchImageDataModel.self, from: data) else { throw PixabayAPIError.noData } DispatchQueue.main.async { // メインスレッドで処理 self.imageDatas = decoded.hits } } }
ContentView.swift
import SwiftUI
struct ContentView: View {
    
    @State var inputText = ""
    @State var buttonEnabled = false
    @StateObject var imageLoader = ImageLoader()
    
    var body: some View {
        VStack {
            HStack {
                TextField("検索キーワード", text: $inputText)
                    .onChange(of: self.inputText) {
                        // 3文字以上でボタン押下可能
                        if ($0.count >= 3) {
                            self.buttonEnabled = true
                        } else {
                            self.buttonEnabled = false
                        }
                    }
                    .textFieldStyle(.roundedBorder)
                    .frame(height: 32)
                    .padding([.leading, .trailing], 8)
                Button("検索") {
                    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                    
                    Task {
                        do {
                            // 画像検索
                            try await self.imageLoader.searchImages(keyword: self.inputText)
                        } catch {
                            print(error)
                        }
                    }
   
                }.disabled(!self.buttonEnabled)
            }
            .frame(height: 64)
            .padding([.leading, .trailing], 16)
            
            ScrollView {
            }
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Models.swift
import Foundation
// JSON全体を格納する構造体
struct SearchImageDataModel:Codable{
    let total: Int
    let totalHits: Int
    let hits : [ImageData]
}
// 素材情報を格納す構造体
struct ImageData: Identifiable, Codable  {
    let id: Int
    let largeImageURL: String
}
enum PixabayAPIError: Error {
    case serverError       // サーバーで発生したエラー
    case noData              // データがないエラー
}
      
