Explorar el Código

Add animations to @FirestoreQuery (#11437)

* Feature: Add animations to @FirestoreQuery
- Adds a SwiftUI `Animation` parameter to the FirebaseQuery initializer.
- If animation is not `nil` then setting the data to the published value will be executed inside of a `withAnimation` block (with the given `Animation`)
* feature: Add `FavouriteFruitsAnimationView`
This view demostrates how to use the ``FirestoreQuery`` property wrapper, using the `animation` parameter to make sure list items are animated when being added or removed.
* Explore alternative strategy for animating changes
This is an alternatice to the approach for animating changes in PR #10813
* Update sample project to include a view with and without animations
---------

Signed-off-by: Peter Friese <peter@peterfriese.de>
Co-authored-by: Brianna Zamora <briannadoubt@icloud.com>
Peter Friese hace 2 años
padre
commit
ba999200a8
Se han modificado 23 ficheros con 506 adiciones y 132 borrados
  1. 1 0
      .gitignore
  2. 5 1
      Example/FirestoreSample/.firebaserc
  3. 21 6
      Example/FirestoreSample/FirestoreSample.xcodeproj/project.pbxproj
  4. 16 5
      Example/FirestoreSample/FirestoreSample/App/FirestoreSampleApp.swift
  5. 99 57
      Example/FirestoreSample/FirestoreSample/Assets.xcassets/AppIcon.appiconset/Contents.json
  6. 127 0
      Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsAnimationView.swift
  7. 5 2
      Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView.swift
  8. 5 2
      Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView2.swift
  9. 125 0
      Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsNoAnimationsView.swift
  10. 2 0
      Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsView.swift
  11. 8 0
      Example/FirestoreSample/FirestoreSample/Views/MenuView.swift
  12. 1 0
      Example/FirestoreSample/data/auth_export/accounts.json
  13. 1 0
      Example/FirestoreSample/data/auth_export/config.json
  14. 6 2
      Example/FirestoreSample/data/firebase-export-metadata.json
  15. BIN
      Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata
  16. BIN
      Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0
  17. BIN
      Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata
  18. 4 0
      Example/FirestoreSample/firebase.json
  19. 1 23
      Example/FirestoreSample/firestore.indexes.json
  20. 4 11
      Example/FirestoreSample/firestore.rules
  21. 3 0
      Firestore/Swift/CHANGELOG.md
  22. 14 5
      Firestore/Swift/Source/PropertyWrapper/FirestoreQuery.swift
  23. 58 18
      Firestore/Swift/Source/PropertyWrapper/FirestoreQueryObservable.swift

+ 1 - 0
.gitignore

@@ -155,3 +155,4 @@ FirebaseAppCheck/Apps/AppCheckCustomProvideApp/AppCheckCustomProvideApp/GoogleSe
 /Example/FirestoreSample/FirestoreSample/GoogleService-Info.plist
 /Example/FirestoreSample/ui-debug.log
 /Example/FirestoreSample/firestore-debug.log
+/Example/FirestoreSample/firebase-debug.log

+ 5 - 1
Example/FirestoreSample/.firebaserc

@@ -1 +1,5 @@
-{}
+{
+  "projects": {
+    "default": "fir-firestore-sample-ios"
+  }
+}

+ 21 - 6
Example/FirestoreSample/FirestoreSample.xcodeproj/project.pbxproj

@@ -7,12 +7,15 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
-		8817084426B9593E009E9281 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8817084326B9593E009E9281 /* GoogleService-Info.plist */; };
 		8817084726B95A63009E9281 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 8817084626B95A63009E9281 /* FirebaseFirestore */; };
 		8817084926B95A63009E9281 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 8817084826B95A63009E9281 /* FirebaseFirestoreSwift */; };
 		88327B8826D62908002AA6D9 /* FavouriteFruitsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88327B8726D62908002AA6D9 /* FavouriteFruitsView.swift */; };
 		8844BA6126E0DD3F000786F0 /* FavouriteFruitsMappingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8844BA6026E0DD3F000786F0 /* FavouriteFruitsMappingErrorView.swift */; };
 		88D5E37826EBD2F200808AFF /* FavouriteFruitsMappingErrorView2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88D5E37726EBD2F200808AFF /* FavouriteFruitsMappingErrorView2.swift */; };
+		88D9354A2A39CB3E00FD8AFF /* FavouriteFruitsAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88D935492A39CB3E00FD8AFF /* FavouriteFruitsAnimationView.swift */; };
+		88D9354C2A39D72300FD8AFF /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 88D9354B2A39D72300FD8AFF /* FirebaseAuth */; };
+		88E5A79A2A39DDE400462B64 /* FavouriteFruitsNoAnimationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E5A7992A39DDE400462B64 /* FavouriteFruitsNoAnimationsView.swift */; };
+		88E5A79C2A39E11A00462B64 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 88E5A79B2A39E11A00462B64 /* GoogleService-Info.plist */; };
 		88FBD98826B9485F00982BF2 /* FirestoreSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FBD98726B9485F00982BF2 /* FirestoreSampleApp.swift */; };
 		88FBD98C26B9486100982BF2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88FBD98B26B9486100982BF2 /* Assets.xcassets */; };
 		88FBD98F26B9486100982BF2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88FBD98E26B9486100982BF2 /* Preview Assets.xcassets */; };
@@ -21,10 +24,12 @@
 
 /* Begin PBXFileReference section */
 		8809D0AE26B9520B00DE7864 /* firebase-ios-sdk */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "firebase-ios-sdk"; path = ../..; sourceTree = "<group>"; };
-		8817084326B9593E009E9281 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
 		88327B8726D62908002AA6D9 /* FavouriteFruitsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavouriteFruitsView.swift; sourceTree = "<group>"; };
 		8844BA6026E0DD3F000786F0 /* FavouriteFruitsMappingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavouriteFruitsMappingErrorView.swift; sourceTree = "<group>"; };
 		88D5E37726EBD2F200808AFF /* FavouriteFruitsMappingErrorView2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavouriteFruitsMappingErrorView2.swift; sourceTree = "<group>"; };
+		88D935492A39CB3E00FD8AFF /* FavouriteFruitsAnimationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavouriteFruitsAnimationView.swift; sourceTree = "<group>"; };
+		88E5A7992A39DDE400462B64 /* FavouriteFruitsNoAnimationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavouriteFruitsNoAnimationsView.swift; sourceTree = "<group>"; };
+		88E5A79B2A39E11A00462B64 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
 		88FBD98426B9485F00982BF2 /* FirestoreSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FirestoreSample.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		88FBD98726B9485F00982BF2 /* FirestoreSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreSampleApp.swift; sourceTree = "<group>"; };
 		88FBD98B26B9486100982BF2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -38,6 +43,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				8817084726B95A63009E9281 /* FirebaseFirestore in Frameworks */,
+				88D9354C2A39D72300FD8AFF /* FirebaseAuth in Frameworks */,
 				8817084926B95A63009E9281 /* FirebaseFirestoreSwift in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -84,7 +90,7 @@
 				88FBD99526B948A100982BF2 /* App */,
 				88FBD99626B948A900982BF2 /* Views */,
 				88FBD98B26B9486100982BF2 /* Assets.xcassets */,
-				8817084326B9593E009E9281 /* GoogleService-Info.plist */,
+				88E5A79B2A39E11A00462B64 /* GoogleService-Info.plist */,
 				88FBD98D26B9486100982BF2 /* Preview Content */,
 			);
 			path = FirestoreSample;
@@ -109,10 +115,12 @@
 		88FBD99626B948A900982BF2 /* Views */ = {
 			isa = PBXGroup;
 			children = (
-				88FBD99726B948E200982BF2 /* MenuView.swift */,
 				88327B8726D62908002AA6D9 /* FavouriteFruitsView.swift */,
 				8844BA6026E0DD3F000786F0 /* FavouriteFruitsMappingErrorView.swift */,
 				88D5E37726EBD2F200808AFF /* FavouriteFruitsMappingErrorView2.swift */,
+				88E5A7992A39DDE400462B64 /* FavouriteFruitsNoAnimationsView.swift */,
+				88D935492A39CB3E00FD8AFF /* FavouriteFruitsAnimationView.swift */,
+				88FBD99726B948E200982BF2 /* MenuView.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -136,6 +144,7 @@
 			packageProductDependencies = (
 				8817084626B95A63009E9281 /* FirebaseFirestore */,
 				8817084826B95A63009E9281 /* FirebaseFirestoreSwift */,
+				88D9354B2A39D72300FD8AFF /* FirebaseAuth */,
 			);
 			productName = FirestoreSample;
 			productReference = 88FBD98426B9485F00982BF2 /* FirestoreSample.app */;
@@ -179,8 +188,8 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				88E5A79C2A39E11A00462B64 /* GoogleService-Info.plist in Resources */,
 				88FBD98F26B9486100982BF2 /* Preview Assets.xcassets in Resources */,
-				8817084426B9593E009E9281 /* GoogleService-Info.plist in Resources */,
 				88FBD98C26B9486100982BF2 /* Assets.xcassets in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -193,7 +202,9 @@
 			buildActionMask = 2147483647;
 			files = (
 				88FBD99826B948E200982BF2 /* MenuView.swift in Sources */,
+				88E5A79A2A39DDE400462B64 /* FavouriteFruitsNoAnimationsView.swift in Sources */,
 				88327B8826D62908002AA6D9 /* FavouriteFruitsView.swift in Sources */,
+				88D9354A2A39CB3E00FD8AFF /* FavouriteFruitsAnimationView.swift in Sources */,
 				88FBD98826B9485F00982BF2 /* FirestoreSampleApp.swift in Sources */,
 				88D5E37826EBD2F200808AFF /* FavouriteFruitsMappingErrorView2.swift in Sources */,
 				8844BA6126E0DD3F000786F0 /* FavouriteFruitsMappingErrorView.swift in Sources */,
@@ -409,7 +420,11 @@
 		};
 		8817084826B95A63009E9281 /* FirebaseFirestoreSwift */ = {
 			isa = XCSwiftPackageProductDependency;
-			productName = "FirebaseFirestoreSwift";
+			productName = FirebaseFirestoreSwift;
+		};
+		88D9354B2A39D72300FD8AFF /* FirebaseAuth */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FirebaseAuth;
 		};
 /* End XCSwiftPackageProductDependency section */
 	};

+ 16 - 5
Example/FirestoreSample/FirestoreSample/App/FirestoreSampleApp.swift

@@ -13,20 +13,31 @@
 // limitations under the License.
 
 import SwiftUI
-import Firebase
+import FirebaseCore
+import FirebaseFirestore
+import FirebaseAuth
 
-@main
-struct FirestoreSampleApp: App {
-  init() {
+class AppDelegate: NSObject, UIApplicationDelegate {
+  func application(_ application: UIApplication,
+                   didFinishLaunchingWithOptions launchOptions: [UIApplication
+                     .LaunchOptionsKey: Any]? = nil) -> Bool {
     FirebaseApp.configure()
 
+    Firestore.firestore()
+      .useEmulator(withHost: "localhost", port: 8080)
+
     let settings = Firestore.firestore().settings
-    settings.host = "localhost:8080"
     settings.isPersistenceEnabled = false
     settings.isSSLEnabled = false
     Firestore.firestore().settings = settings
+
+    return true
   }
+}
 
+@main
+struct FirestoreSampleApp: App {
+  @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
   var body: some Scene {
     WindowGroup {
       NavigationView {

+ 99 - 57
Example/FirestoreSample/FirestoreSample/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -150,6 +150,66 @@
       "scale" : "1x",
       "size" : "1024x1024"
     },
+    {
+      "filename" : "16.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "16x16"
+    },
+    {
+      "filename" : "32.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "16x16"
+    },
+    {
+      "filename" : "32.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "32x32"
+    },
+    {
+      "filename" : "64.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "32x32"
+    },
+    {
+      "filename" : "128.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "128x128"
+    },
+    {
+      "filename" : "256.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "128x128"
+    },
+    {
+      "filename" : "256.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "256x256"
+    },
+    {
+      "filename" : "512.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "256x256"
+    },
+    {
+      "filename" : "512.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "512x512"
+    },
+    {
+      "filename" : "1024.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "512x512"
+    },
     {
       "filename" : "48.png",
       "idiom" : "watch",
@@ -180,6 +240,13 @@
       "scale" : "3x",
       "size" : "29x29"
     },
+    {
+      "idiom" : "watch",
+      "role" : "notificationCenter",
+      "scale" : "2x",
+      "size" : "33x33",
+      "subtype" : "45mm"
+    },
     {
       "filename" : "80.png",
       "idiom" : "watch",
@@ -196,6 +263,13 @@
       "size" : "44x44",
       "subtype" : "40mm"
     },
+    {
+      "idiom" : "watch",
+      "role" : "appLauncher",
+      "scale" : "2x",
+      "size" : "46x46",
+      "subtype" : "41mm"
+    },
     {
       "filename" : "100.png",
       "idiom" : "watch",
@@ -204,6 +278,20 @@
       "size" : "50x50",
       "subtype" : "44mm"
     },
+    {
+      "idiom" : "watch",
+      "role" : "appLauncher",
+      "scale" : "2x",
+      "size" : "51x51",
+      "subtype" : "45mm"
+    },
+    {
+      "idiom" : "watch",
+      "role" : "appLauncher",
+      "scale" : "2x",
+      "size" : "54x54",
+      "subtype" : "49mm"
+    },
     {
       "filename" : "172.png",
       "idiom" : "watch",
@@ -229,70 +317,24 @@
       "subtype" : "44mm"
     },
     {
-      "filename" : "1024.png",
-      "idiom" : "watch-marketing",
-      "scale" : "1x",
-      "size" : "1024x1024"
-    },
-    {
-      "filename" : "16.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "16x16"
-    },
-    {
-      "filename" : "32.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "16x16"
-    },
-    {
-      "filename" : "32.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "32x32"
-    },
-    {
-      "filename" : "64.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "32x32"
-    },
-    {
-      "filename" : "128.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "128x128"
-    },
-    {
-      "filename" : "256.png",
-      "idiom" : "mac",
+      "idiom" : "watch",
+      "role" : "quickLook",
       "scale" : "2x",
-      "size" : "128x128"
-    },
-    {
-      "filename" : "256.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "256x256"
+      "size" : "117x117",
+      "subtype" : "45mm"
     },
     {
-      "filename" : "512.png",
-      "idiom" : "mac",
+      "idiom" : "watch",
+      "role" : "quickLook",
       "scale" : "2x",
-      "size" : "256x256"
-    },
-    {
-      "filename" : "512.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "512x512"
+      "size" : "129x129",
+      "subtype" : "49mm"
     },
     {
       "filename" : "1024.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "512x512"
+      "idiom" : "watch-marketing",
+      "scale" : "1x",
+      "size" : "1024x1024"
     }
   ],
   "info" : {

+ 127 - 0
Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsAnimationView.swift

@@ -0,0 +1,127 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import SwiftUI
+import FirebaseFirestore
+import FirebaseFirestoreSwift
+
+private struct Fruit: Codable, Identifiable, Equatable {
+  @DocumentID var id: String?
+  var name: String
+  var isFavourite: Bool
+}
+
+extension Fruit {
+  static let fruits = [
+    Fruit(name: "Apple", isFavourite: true),
+    Fruit(name: "Banana", isFavourite: true),
+    Fruit(name: "Orange", isFavourite: true),
+    Fruit(name: "Pineapple", isFavourite: true),
+    Fruit(name: "Dragonfruit", isFavourite: true),
+    Fruit(name: "Mangosteen", isFavourite: true),
+    Fruit(name: "Lychee", isFavourite: true),
+    Fruit(name: "Passionfruit", isFavourite: true),
+    Fruit(name: "Starfruit", isFavourite: true),
+  ]
+
+  static func randomFruit() -> Fruit {
+    return Fruit.fruits.randomElement()!
+  }
+}
+
+/// This view demonstrates how to use the `FirestoreQuery` property wrapper,
+/// using the `animation` parameter to make sure list items are animated when
+/// being added or removed.
+struct FavouriteFruitsAnimationView: View {
+  @FirestoreQuery(
+    collectionPath: "fruits",
+    predicates: [
+      .where("isFavourite", isEqualTo: true),
+    ],
+    animation: .default
+  ) fileprivate var fruitResults: Result<[Fruit], Error>
+
+  @State var showOnlyFavourites = true
+
+  private func delete(fruit: Fruit) {
+    if let id = fruit.id {
+      Firestore.firestore().collection("fruits").document(id).delete()
+    }
+  }
+
+  private func addRandomFruit() {
+    let fruit = Fruit.randomFruit()
+    add(fruit: fruit)
+  }
+
+  private func add(fruit: Fruit) {
+    do {
+      try Firestore.firestore().collection("fruits").addDocument(from: fruit)
+    } catch {
+      print(error)
+    }
+  }
+
+  var body: some View {
+    if case let .success(fruits) = fruitResults {
+      List(fruits) { fruit in
+        Text(fruit.name)
+          .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+            Button {
+              delete(fruit: fruit)
+            } label: {
+              Label("Delete", systemImage: "trash")
+            }
+          }
+      }
+      .navigationTitle("Fruits")
+      .toolbar {
+        ToolbarItem(placement: .bottomBar) {
+          Button {
+            addRandomFruit()
+          } label: {
+            Label("Add", systemImage: "plus")
+          }
+        }
+        ToolbarItem(placement: .navigationBarTrailing) {
+          Button(action: toggleFilter) {
+            Image(systemName: showOnlyFavourites
+              ? "line.3.horizontal.decrease.circle.fill"
+              : "line.3.horizontal.decrease.circle")
+          }
+        }
+      }
+    } else if case let .failure(error) = fruitResults {
+      // Handle error
+      Text("Couldn't map data: \(error.localizedDescription)")
+    }
+  }
+
+  func toggleFilter() {
+    showOnlyFavourites.toggle()
+    if showOnlyFavourites {
+      $fruitResults.predicates = [
+        .whereField("isFavourite", isEqualTo: true),
+      ]
+    } else {
+      $fruitResults.predicates = []
+    }
+  }
+}
+
+struct FavouriteFruitsAnimationView_Previews: PreviewProvider {
+  static var previews: some View {
+    FavouriteFruitsAnimationView()
+  }
+}

+ 5 - 2
Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView.swift

@@ -20,10 +20,14 @@ private struct Fruit: Codable, Identifiable, Equatable {
   var name: String
 }
 
+/// This view demonstrates how to use the `FirestoreQuery` property wrapper's
+/// error handling. When any of the documents can't be mapped, this view will
+/// just show an error message.
 struct FavouriteFruitsMappingErrorView: View {
   @FirestoreQuery(
     collectionPath: "mappingFailure",
-    decodingFailureStrategy: .raise
+    decodingFailureStrategy: .raise,
+    animation: .default
   ) private var fruitResults: Result<[Fruit], Error>
 
   var body: some View {
@@ -32,7 +36,6 @@ struct FavouriteFruitsMappingErrorView: View {
       List(fruits) { fruit in
         Text(fruit.name)
       }
-      .animation(.default, value: fruits)
       .navigationTitle("Mapping failure")
 
     case let .failure(error):

+ 5 - 2
Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView2.swift

@@ -20,10 +20,14 @@ private struct Fruit: Codable, Identifiable, Equatable {
   var name: String
 }
 
+/// This view demonstrates how to use the `FirestoreQuery` property wrapper's
+/// error handling. When any of the documents can't be mapped, this view will
+/// show all items that could be mapped, and a friendly error message.
 struct FavouriteFruitsMappingErrorView2: View {
   @FirestoreQuery(
     collectionPath: "mappingFailure",
-    decodingFailureStrategy: .ignore
+    decodingFailureStrategy: .ignore,
+    animation: .default
   ) private var fruits: [Fruit]
 
   var body: some View {
@@ -41,7 +45,6 @@ struct FavouriteFruitsMappingErrorView2: View {
         .background(Color.red)
       }
     }
-    .animation(.default, value: fruits)
     .navigationTitle("Mapping failure")
     .ignoresSafeArea(edges: .bottom)
   }

+ 125 - 0
Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsNoAnimationsView.swift

@@ -0,0 +1,125 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import SwiftUI
+import FirebaseFirestore
+import FirebaseFirestoreSwift
+
+private struct Fruit: Codable, Identifiable, Equatable {
+  @DocumentID var id: String?
+  var name: String
+  var isFavourite: Bool
+}
+
+extension Fruit {
+  static let fruits = [
+    Fruit(name: "Apple", isFavourite: true),
+    Fruit(name: "Banana", isFavourite: true),
+    Fruit(name: "Orange", isFavourite: true),
+    Fruit(name: "Pineapple", isFavourite: true),
+    Fruit(name: "Dragonfruit", isFavourite: true),
+    Fruit(name: "Mangosteen", isFavourite: true),
+    Fruit(name: "Lychee", isFavourite: true),
+    Fruit(name: "Passionfruit", isFavourite: true),
+    Fruit(name: "Starfruit", isFavourite: true),
+  ]
+
+  static func randomFruit() -> Fruit {
+    return Fruit.fruits.randomElement()!
+  }
+}
+
+/// This view demonstrates how to use the `FirestoreQuery` property wrapper.
+/// It uses **no animations**, so adding and removing elements is a bit jarring.
+struct FavouriteFruitsNoAnimationsView: View {
+  @FirestoreQuery(
+    collectionPath: "fruits",
+    predicates: [
+      .where("isFavourite", isEqualTo: true),
+    ]
+  ) fileprivate var fruitResults: Result<[Fruit], Error>
+
+  @State var showOnlyFavourites = true
+
+  private func delete(fruit: Fruit) {
+    if let id = fruit.id {
+      Firestore.firestore().collection("fruits").document(id).delete()
+    }
+  }
+
+  private func addRandomFruit() {
+    let fruit = Fruit.randomFruit()
+    add(fruit: fruit)
+  }
+
+  private func add(fruit: Fruit) {
+    do {
+      try Firestore.firestore().collection("fruits").addDocument(from: fruit)
+    } catch {
+      print(error)
+    }
+  }
+
+  var body: some View {
+    if case let .success(fruits) = fruitResults {
+      List(fruits) { fruit in
+        Text(fruit.name)
+          .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+            Button {
+              delete(fruit: fruit)
+            } label: {
+              Label("Delete", systemImage: "trash")
+            }
+          }
+      }
+      .navigationTitle("Fruits")
+      .toolbar {
+        ToolbarItem(placement: .bottomBar) {
+          Button {
+            addRandomFruit()
+          } label: {
+            Label("Add", systemImage: "plus")
+          }
+        }
+        ToolbarItem(placement: .navigationBarTrailing) {
+          Button(action: toggleFilter) {
+            Image(systemName: showOnlyFavourites
+              ? "line.3.horizontal.decrease.circle.fill"
+              : "line.3.horizontal.decrease.circle")
+          }
+        }
+      }
+    } else if case let .failure(error) = fruitResults {
+      // Handle error
+      Text("Couldn't map data: \(error.localizedDescription)")
+    }
+  }
+
+  func toggleFilter() {
+    showOnlyFavourites.toggle()
+    if showOnlyFavourites {
+      $fruitResults.predicates = [
+        .whereField("isFavourite", isEqualTo: true),
+      ]
+    } else {
+      $fruitResults.predicates = []
+    }
+  }
+}
+
+struct FavouriteFruitsNoAnimationsView_Previews: PreviewProvider {
+  static var previews: some View {
+    FavouriteFruitsNoAnimationsView()
+  }
+}

+ 2 - 0
Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsView.swift

@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import SwiftUI
+import FirebaseFirestore
 import FirebaseFirestoreSwift
 
 private struct Fruit: Codable, Identifiable, Equatable {
@@ -21,6 +22,7 @@ private struct Fruit: Codable, Identifiable, Equatable {
   var isFavourite: Bool
 }
 
+/// This view demonstrates how to use the `FirestoreQuery` property wrapper.
 struct FavouriteFruitsView: View {
   @FirestoreQuery(
     collectionPath: "fruits",

+ 8 - 0
Example/FirestoreSample/FirestoreSample/Views/MenuView.swift

@@ -28,6 +28,14 @@ struct MenuView: View {
           Label("Mapping failure 2", systemImage: "shippingbox")
         }
       }
+      Section(header: Text("Animations")) {
+        NavigationLink(destination: FavouriteFruitsNoAnimationsView()) {
+          Label("Without Animations", systemImage: "shippingbox")
+        }
+        NavigationLink(destination: FavouriteFruitsAnimationView()) {
+          Label("With Animations", systemImage: "shippingbox")
+        }
+      }
     }
     .listStyle(InsetGroupedListStyle())
     .navigationTitle("Firestore")

+ 1 - 0
Example/FirestoreSample/data/auth_export/accounts.json

@@ -0,0 +1 @@
+{"kind":"identitytoolkit#DownloadAccountResponse","users":[]}

+ 1 - 0
Example/FirestoreSample/data/auth_export/config.json

@@ -0,0 +1 @@
+{"signIn":{"allowDuplicateEmails":false}}

+ 6 - 2
Example/FirestoreSample/data/firebase-export-metadata.json

@@ -1,8 +1,12 @@
 {
-  "version": "9.18.0",
+  "version": "12.0.1",
   "firestore": {
-    "version": "1.13.1",
+    "version": "1.17.4",
     "path": "firestore_export",
     "metadata_file": "firestore_export/firestore_export.overall_export_metadata"
+  },
+  "auth": {
+    "version": "12.0.1",
+    "path": "auth_export"
   }
 }

BIN
Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata


BIN
Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0


BIN
Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata


+ 4 - 0
Example/FirestoreSample/firebase.json

@@ -9,6 +9,10 @@
     },
     "ui": {
       "enabled": true
+    },
+    "singleProjectMode": true,
+    "auth": {
+      "port": 9099
     }
   }
 }

+ 1 - 23
Example/FirestoreSample/firestore.indexes.json

@@ -1,26 +1,4 @@
 {
-  // Example:
-  //
-  // "indexes": [
-  //   {
-  //     "collectionGroup": "widgets",
-  //     "queryScope": "COLLECTION",
-  //     "fields": [
-  //       { "fieldPath": "foo", "arrayConfig": "CONTAINS" },
-  //       { "fieldPath": "bar", "mode": "DESCENDING" }
-  //     ]
-  //   },
-  //
-  //  "fieldOverrides": [
-  //    {
-  //      "collectionGroup": "widgets",
-  //      "fieldPath": "baz",
-  //      "indexes": [
-  //        { "order": "ASCENDING", "queryScope": "COLLECTION" }
-  //      ]
-  //    },
-  //   ]
-  // ]
   "indexes": [],
   "fieldOverrides": []
-}
+}

+ 4 - 11
Example/FirestoreSample/firestore.rules

@@ -1,16 +1,9 @@
+rules_version = '2';
+
 service cloud.firestore {
   match /databases/{database}/documents {
     match /{document=**} {
-      // This rule allows anyone with your database reference to view, edit,
-      // and delete all data in your database. It is useful for getting
-      // started, but it is configured to expire after 30 days because it
-      // leaves your app open to attackers. At that time, all client
-      // requests to your database will be denied.
-      //
-      // Make sure to write security rules for your app before that time, or
-      // else all client requests to your database will be denied until you
-      // update your rules.
-      allow read, write: if request.time < timestamp.date(2123, 9, 2);
+      allow read, write: if true;
     }
   }
-}
+}

+ 3 - 0
Firestore/Swift/CHANGELOG.md

@@ -1,3 +1,6 @@
+# unreleased
+- [added] Added support animations on the `@FirestoreQuery` property wrapper.
+
 # 10.9.0
 - [changed] The async `CollectionReference.addDocument(data:)` API now returns
   a discardable result. (#10640)

+ 14 - 5
Firestore/Swift/Source/PropertyWrapper/FirestoreQuery.swift

@@ -124,11 +124,14 @@ public struct FirestoreQuery<T>: DynamicProperty {
     /// The query's predicates.
     public var predicates: [QueryPredicate]
 
-    // The strategy to use in case there was a problem during the decoding phase.
+    /// The strategy to use in case there was a problem during the decoding phase.
     public var decodingFailureStrategy: DecodingFailureStrategy = .raise
 
     /// If any errors occurred, they will be exposed here as well.
     public var error: Error?
+
+    /// The type of animation to apply when updating the view. If this is ommitted then no animations are fired.
+    public var animation: Animation?
   }
 
   /// The results of the query.
@@ -156,13 +159,16 @@ public struct FirestoreQuery<T>: DynamicProperty {
   ///     filter for the fetched results.
   ///   - decodingFailureStrategy: The strategy to use when there is a failure
   ///     during the decoding phase. Defaults to `DecodingFailureStrategy.raise`.
+  ///   - animation: The optional animation to apply to the transaction.
   public init<U: Decodable>(collectionPath: String, predicates: [QueryPredicate] = [],
-                            decodingFailureStrategy: DecodingFailureStrategy = .raise)
+                            decodingFailureStrategy: DecodingFailureStrategy = .raise,
+                            animation: Animation? = nil)
     where T == [U] {
     let configuration = Configuration(
       path: collectionPath,
       predicates: predicates,
-      decodingFailureStrategy: decodingFailureStrategy
+      decodingFailureStrategy: decodingFailureStrategy,
+      animation: animation
     )
 
     _firestoreQueryObservable =
@@ -176,13 +182,16 @@ public struct FirestoreQuery<T>: DynamicProperty {
   ///     filter for the fetched results.
   ///   - decodingFailureStrategy: The strategy to use when there is a failure
   ///     during the decoding phase. Defaults to `DecodingFailureStrategy.raise`.
+  ///   - animation: The optional animation to apply to the transaction.
   public init<U: Decodable>(collectionPath: String, predicates: [QueryPredicate] = [],
-                            decodingFailureStrategy: DecodingFailureStrategy = .raise)
+                            decodingFailureStrategy: DecodingFailureStrategy = .raise,
+                            animation: Animation? = nil)
     where T == Result<[U], Error> {
     let configuration = Configuration(
       path: collectionPath,
       predicates: predicates,
-      decodingFailureStrategy: decodingFailureStrategy
+      decodingFailureStrategy: decodingFailureStrategy,
+      animation: animation
     )
 
     _firestoreQueryObservable =

+ 58 - 18
Firestore/Swift/Source/PropertyWrapper/FirestoreQueryObservable.swift

@@ -41,16 +41,23 @@ internal class FirestoreQueryObservable<T>: ObservableObject {
     items = []
     self.configuration = configuration
     setupListener = createListener { [weak self] querySnapshot, error in
+      guard let self else { return }
       if let error = error {
-        self?.items = []
-        self?.projectError(error)
+        self.animated {
+          self.items = []
+          self.projectError(error)
+        }
         return
       } else {
-        self?.projectError(nil)
+        self.animated {
+          self.projectError(nil)
+        }
       }
 
       guard let documents = querySnapshot?.documents else {
-        self?.items = []
+        self.animated {
+          self.items = []
+        }
         return
       }
 
@@ -60,19 +67,27 @@ internal class FirestoreQueryObservable<T>: ObservableObject {
         case let .success(decodedDocument):
           return decodedDocument
         case let .failure(error):
-          self?.projectError(error)
+          self.animated {
+            self.projectError(error)
+          }
           return nil
         }
       }
 
-      if self?.configuration.error != nil {
+      if configuration.error != nil {
         if configuration.decodingFailureStrategy == .raise {
-          self?.items = []
+          self.animated {
+            self.items = []
+          }
         } else {
-          self?.items = decodedDocuments
+          self.animated {
+            self.items = decodedDocuments
+          }
         }
       } else {
-        self?.items = decodedDocuments
+        self.animated {
+          self.items = decodedDocuments
+        }
       }
     }
 
@@ -83,16 +98,23 @@ internal class FirestoreQueryObservable<T>: ObservableObject {
     items = .success([])
     self.configuration = configuration
     setupListener = createListener { [weak self] querySnapshot, error in
+      guard let self else { return }
       if let error = error {
-        self?.items = .failure(error)
-        self?.projectError(error)
+        self.animated {
+          self.items = .failure(error)
+          self.projectError(error)
+        }
         return
       } else {
-        self?.projectError(nil)
+        self.animated {
+          self.projectError(nil)
+        }
       }
 
       guard let documents = querySnapshot?.documents else {
-        self?.items = .success([])
+        self.animated {
+          self.items = .success([])
+        }
         return
       }
 
@@ -102,19 +124,27 @@ internal class FirestoreQueryObservable<T>: ObservableObject {
         case let .success(decodedDocument):
           return decodedDocument
         case let .failure(error):
-          self?.projectError(error)
+          self.animated {
+            self.projectError(error)
+          }
           return nil
         }
       }
 
-      if let error = self?.configuration.error {
+      if let error = self.configuration.error {
         if configuration.decodingFailureStrategy == .raise {
-          self?.items = .failure(error)
+          self.animated {
+            self.items = .failure(error)
+          }
         } else {
-          self?.items = .success(decodedDocuments)
+          self.animated {
+            self.items = .success(decodedDocuments)
+          }
         }
       } else {
-        self?.items = .success(decodedDocuments)
+        self.animated {
+          self.items = .success(decodedDocuments)
+        }
       }
     }
 
@@ -173,4 +203,14 @@ internal class FirestoreQueryObservable<T>: ObservableObject {
     listener?.remove()
     listener = nil
   }
+
+  private func animated(_ body: () -> Void) {
+    if let animation = configuration.animation {
+      withAnimation(animation) {
+        body()
+      }
+    } else {
+      body()
+    }
+  }
 }