オプティミスティックUI
Amplify DataでオプティミスティックUIを実装することで、CRUD操作がリクエストのラウンドトリップが完了する前にUIに即座にレンダリングされ、APIコールが失敗した場合はUIの変更をロールバックできます。
以下の例では、新しく作成されたアイテム、更新、削除をオプティミスティックにレンダリングするリストビューを作成します。Dataスキーマを修正して、この「Real Estate Property」の例を使用します:
const schema = a.schema({ RealEstateProperty: a.model({ name: a.string().required(), address: a.string(), }).authorization(allow => [allow.guest()])})
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({ schema, authorizationModes: { defaultAuthorizationMode: 'iam', },});ファイルを保存し、npx ampx sandboxを実行してバックエンドのクラウドサンドボックスに変更をデプロイします。このガイドでは、Real Estate Propertyリスティングアプリケーションを構築します。
バックエンドがプロビジョニングされたら、npx ampx generate graphql-client-code --format modelgen --model-target swift --out <path_to_swift_project>/AmplifyModelsを実行してアプリ用のSwiftモデル型を生成します。
次に、Amplifyパッケージ(https://github.com/aws-amplify/amplify-swift.git)をXcodeプロジェクトに追加し、プロンプトが表示されたときに次のモジュールを選択してインポートします:
- AWSAPIPlugin
- AWSCognitoAuthPlugin
- AWSS3StoragePlugin
- Amplify
Swiftアクターを使用してオプティミスティックUI更新を実行する方法
Swiftアクターは、基になるプロパティへのアクセスをシリアライズします。この例では、アクターがアイテムのリストを保持し、リストにアクセスされるたびにCombineパブリッシャーを通じてUIに公開されます。高レベルでは、アクターのメソッドは以下を実行します:
- 新しいモデルを作成し、リストに追加し、APIリクエストが失敗した場合は新しく追加されたアイテムをリストから削除
- 既存のモデルをリストで更新し、APIリクエストが失敗した場合は更新をリストでリバート
- リストから既存のモデルを削除し、APIリクエストが失敗した場合はアイテムをリストに戻す
アクターオブジェクトを通じてこれらのメソッドを提供することで、基になるリストはシリアルでアクセスされるため、必要に応じて操作全体をロールバックできます。
オプティミスティックUI更新を可能にするアクターオブジェクトを作成するには、新しいファイルを作成して次のコードを追加します。
import Amplifyimport SwiftUIimport Combine
actor RealEstatePropertyList {
private var properties: [RealEstateProperty?] = [] { didSet { subject.send(properties.compactMap { $0 }) } }
private let subject = PassthroughSubject<[RealEstateProperty], Never>() var publisher: AnyPublisher<[RealEstateProperty], Never> { subject.eraseToAnyPublisher() }
func listProperties() async throws { let result = try await Amplify.API.query(request: .list(RealEstateProperty.self)) guard case .success(let propertyList) = result else { print("Failed with error: ", result) return } properties = propertyList.elements }}listProperties()メソッドを呼び出すと、Amplify Data APIでクエリが実行され、結果がpropertiesプロパティに保存されます。このプロパティが設定されると、リストがサブスクライバーに送信されます。UIで、ビューモデルを作成して更新をサブスクライブします:
class RealEstatePropertyContainerViewModel: ObservableObject { @Published var properties: [RealEstateProperty] = [] var sink: AnyCancellable?
var propertyList = RealEstatePropertyList() init() { Task { sink = await propertyList.publisher .receive(on: DispatchQueue.main) .sink { properties in print("Updating property list") self.properties = properties } } }
func loadList() { Task { try? await propertyList.listProperties() } }}
struct RealEstatePropertyContainerView: View { @StateObject var vm = RealEstatePropertyContainerViewModel() @State private var propertyName: String = ""
var body: some View { Text("Hello") }}新しく作成されたレコードをオプティミスティックにレンダリングする
Amplify Data APIから返された新しく作成されたレコードをオプティミスティックにレンダリングするには、actor RealEstatePropertyListにメソッドを追加します:
func createProperty(name: String, address: String? = nil) { let property = RealEstateProperty(name: name, address: address) // オプティミスティックに新しく作成されたプロパティを送信し、UIでレンダリング properties.append(property)
Task { do { // プロパティレコードを作成 let result = try await Amplify.API.mutate(request: .create(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) // 新しく作成されたプロパティを削除 if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } catch { print("Failed with error: ", error) // 新しく作成されたプロパティを削除 if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } }}レコード更新をオプティミスティックにレンダリングする
単一のアイテムの更新をオプティミスティックにレンダリングするには、以下のようなコードスニペットを使用します:
func updateProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to update") return }
// オプティミスティックにプロパティを更新し、UIでレンダリング let rollbackProperty = properties[index] properties[index] = property
do { // プロパティレコードを更新 let result = try await Amplify.API.mutate(request: .update(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) properties[index] = rollbackProperty } catch { print("Failed with error: ", error) properties[index] = rollbackProperty }}レコード削除をオプティミスティックにレンダリングする
Amplify Data API削除をオプティミスティックにレンダリングするには、以下のようなコードスニペットを使用します:
func deleteProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to remove") return }
// オプティミスティックにプロパティを削除し、UIでレンダリング let rollbackProperty = properties[index] properties[index] = nil
do { // プロパティレコードを削除 let result = try await Amplify.API.mutate(request: .delete(property)) switch result { case .success: // 削除を確定 properties.remove(at: index) case .failure(let graphQLResponse): print("Failed with error: ", graphQLResponse) // 削除を取り消し properties[index] = rollbackProperty }
} catch { print("Failed with error: ", error) // 削除を取り消し properties[index] = rollbackProperty }}完全な例
import SwiftUIimport Amplifyimport AWSAPIPlugin
@mainstruct OptimisticUIApp: App {
init() { do { Amplify.Logging.logLevel = .verbose try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels())) try Amplify.configure(with: .amplifyOutputs) print("Amplify configured with API, Storage, and Auth plugins!") } catch { print("Failed to initialize Amplify with \(error)") } }
var body: some Scene { WindowGroup { RealEstatePropertyContainerView() } }}
// モデルを拡張してSwiftUIの`ForEach`と互換性を持たせるためにIdentifiableに対応させる。extension RealEstateProperty: Identifiable { }
struct TappedButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding(10) .background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) }}actor RealEstatePropertyList {
private var properties: [RealEstateProperty?] = [] { didSet { subject.send(properties.compactMap { $0 }) } }
private let subject = PassthroughSubject<[RealEstateProperty], Never>() var publisher: AnyPublisher<[RealEstateProperty], Never> { subject.eraseToAnyPublisher() }
func listProperties() async throws { let result = try await Amplify.API.query(request: .list(RealEstateProperty.self)) guard case .success(let propertyList) = result else { print("Failed with error: ", result) return } properties = propertyList.elements }
func createProperty(name: String, address: String? = nil) { let property = RealEstateProperty(name: name, address: address) // オプティミスティックに新しく作成されたプロパティを送信し、UIでレンダリング properties.append(property)
Task { do { // プロパティレコードを作成 let result = try await Amplify.API.mutate(request: .create(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) // 新しく作成されたプロパティを削除 if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } catch { print("Failed with error: ", error) // 新しく作成されたプロパティを削除 if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } } }
func updateProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to update") return }
// オプティミスティックにプロパティを更新し、UIでレンダリング let rollbackProperty = properties[index] properties[index] = property
do { // プロパティレコードを更新 let result = try await Amplify.API.mutate(request: .update(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) properties[index] = rollbackProperty } catch { print("Failed with error: ", error) properties[index] = rollbackProperty } }
func deleteProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to remove") return }
// オプティミスティックにプロパティを削除し、UIでレンダリング let rollbackProperty = properties[index] properties[index] = nil
do { // プロパティレコードを削除 let result = try await Amplify.API.mutate(request: .delete(property)) switch result { case .success: // 削除を確定 properties.remove(at: index) case .failure(let graphQLResponse): print("Failed with error: ", graphQLResponse) // 削除を取り消し properties[index] = rollbackProperty }
} catch { print("Failed with error: ", error) // 削除を取り消し properties[index] = rollbackProperty } }}class RealEstatePropertyContainerViewModel: ObservableObject { @Published var properties: [RealEstateProperty] = [] var sink: AnyCancellable?
var propertyList = RealEstatePropertyList() init() { Task { sink = await propertyList.publisher .receive(on: DispatchQueue.main) .sink { properties in print("Updating property list") self.properties = properties } } }
func loadList() { Task { try? await propertyList.listProperties() } } func createPropertyButtonTapped(name: String) { Task { await propertyList.createProperty(name: name) } }
func updatePropertyButtonTapped(_ property: RealEstateProperty) { Task { await propertyList.updateProperty(property) } }
func deletePropertyButtonTapped(_ property: RealEstateProperty) { Task { await propertyList.deleteProperty(property) } }}
struct RealEstatePropertyContainerView: View { @StateObject var viewModel = RealEstatePropertyContainerViewModel() @State private var propertyName: String = ""
var body: some View { VStack { ScrollView { LazyVStack(alignment: .leading) { ForEach($viewModel.properties) { $property in HStack { TextField("Update property name", text: $property.name) .textFieldStyle(RoundedBorderTextFieldStyle()) .multilineTextAlignment(.center) Button("Update") { viewModel.updatePropertyButtonTapped(property) } Button { viewModel.deletePropertyButtonTapped(property) } label: { Image(systemName: "xmark") .foregroundColor(.red) }
}.padding(.horizontal) } } }.refreshable { viewModel.loadList() } TextField("New property name", text: $propertyName) .textFieldStyle(RoundedBorderTextFieldStyle()) .multilineTextAlignment(.center)
Button("Save") { viewModel.createPropertyButtonTapped(name: propertyName) self.propertyName = "" } .buttonStyle(TappedButtonStyle()) }.task { viewModel.loadList() } }}
struct RealEstatePropertyContainerView_Previews: PreviewProvider { static var previews: some View { RealEstatePropertyContainerView() }}