Repository: firebase/firebase-ios-sdk (language: cpp)

Issue:
[FR]: Add animation support to @FirestoreQuery updates
### Description

1. **Use case:** Animate the data updates provided by the `@FirestoreQuery` property wrapper.
2. **Current implementation:** Unless I'm missing something, `@FirebaseQuery` doesn't animate its' published changes.
3. **Proposed solution:** Add an `Animation` parameter to the `FirestoreQueryObservable` initializer, store it in the `FirestoreQuery.Configuration`, and conditionally invoke a `withAnimation` block when setting the data.

### Forked Implementation

A forked version of the `master` branch that implements this change is here: https://github.com/briannadoubt/firebase-ios-sdk

Give me the word and I will open a PR!

### API Proposal

First, add an `Animation` object to the `FirestoreQuery.Configuration`:

```swift
/// The query's configurable properties.
  public struct Configuration {
    /// The query's collection path.
    public var path: String

    /// The query's predicates.
    public var predicates: [QueryPredicate]

    /// 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 to updating the view.
    public var animation: Animation?                                              // <~ Add animation here
  }
```

Add an optional `Animation` parameter to the `FirestoreQuery`'s initializer:

```swift
/// Creates an instance by defining a query based on the parameters.
  /// - Parameters:
  ///   - collectionPath: The path to the Firestore collection to query.
  ///   - predicates: An optional array of `QueryPredicate`s that defines a
  ///     filter for the fetched results.
  ///   - decodingFailureStrategy: The strategy to use when there is a failure
  ///     during the decoding phase. Defaults to `DecodingFailureStrategy.raise`.
  public init<U: Decodable>(collectionPath: String, predicates: [QueryPredicate] = [],
                            decodingFailureStrategy: DecodingFailureStrategy = .raise, animation: Animation? = nil) // <~ Here
    where T == [U] {
    let configuration = Configuration(
      path: collectionPath,
      predicates: predicates,
      decodingFailureStrategy: decodingFailureStrategy,
      animation: animation
    )

    _firestoreQueryObservable =
      StateObject(wrappedValue: FirestoreQueryObservable<T>(configuration: configuration))
  }

  /// Creates an instance by defining a query based on the parameters.
  /// - Parameters:
  ///   - collectionPath: The path to the Firestore collection to query.
  ///   - predicates: An optional array of `QueryPredicate`s that defines a
  ///     filter for the fetched results.
  ///   - decodingFailureStrategy: The strategy to use when there is a failure
  ///     during the decoding phase. Defaults to `DecodingFailureStrategy.raise`.
  public init<U: Decodable>(collectionPath: String, predicates: [QueryPredicate] = [],
                            decodingFailureStrategy: DecodingFailureStrategy = .raise, animation: Animation? = nil) // <~ And here
    where T == Result<[U], Error> {
    let configuration = Configuration(
      path: collectionPath,
      predicates: predicates,
      decodingFailureStrategy: decodingFailureStrategy,
      animation: animation
    )

    _firestoreQueryObservable =
      StateObject(wrappedValue: FirestoreQueryObservable<T>(configuration: configuration))
  }
```

Then add a `private` method to update the `items` instance memory, with a conditional animation block (based on the configuration):

```swift
private func set(items: T) {
      if let animation = configuration.animation {
          withAnimation(animation) {
            self.items = items
          }
          return
      }
      self.items = items
  }
```

Update all of the setter expressions (setting the `items` value) with this new `set(items:)` func.

```swift
init<U: Decodable>(configuration: FirestoreQuery<T>.Configuration) where T == [U] {
    items = [] // Don't update with animations before self is available
    self.configuration = configuration
    setupListener = createListener { [weak self] querySnapshot, error in
      if let error = error {
        self?.set(items: []) // <~ Here
        self?.projectError(error)
        return
      } else {
        self?.projectError(nil)
      }

      guard let documents = querySnapshot?.documents else {
        self?.set(items: []) // <~ Here
        return
      }

      let decodedDocuments: [U] = documents.compactMap { queryDocumentSnapshot in
        let result = Result { try queryDocumentSnapshot.data(as: U.self) }
        switch result {
        case let .success(decodedDocument):
          return decodedDocument
        case let .failure(error):
          self?.projectError(error)
          return nil
        }
      }

      if self?.configuration.error != nil {
        if configuration.decodingFailureStrategy == .raise {
          self?.set(items: []) // <~ Here
        } else {
          self?.set(items: decodedDocuments) // <~ Here
        }
      } else {
        self?.set(items: decodedDocuments) // <~ Here
      }
    }

    setupListener()
  }

init<U: Decodable>(configuration: FirestoreQuery<T>.Configuration) where T == Result<[U], Error> {
    items = .success([]) // Don't update with animations before self is available
    self.configuration = configuration
    setupListener = createListener { [weak self] querySnapshot, error in
      if let error = error {
        self?.set(items: .failure(error)) // <~ Here
        self?.projectError(error)
        return
      } else {
        self?.projectError(nil)
      }

      guard let documents = querySnapshot?.documents else {
        self?.set(items: .success([])) // <~ Here
        return
      }

      let decodedDocuments: [U] = documents.compactMap { queryDocumentSnapshot in
        let result = Result { try queryDocumentSnapshot.data(as: U.self) }
        switch result {
        case let .success(decodedDocument):
          return decodedDocument
        case let .failure(error):
          self?.projectError(error)
          return nil
        }
      }

      if let error = self?.configuration.error {
        if configuration.decodingFailureStrategy == .raise {
          self?.set(items: .failure(error)) // <~ Here
        } else {
          self?.set(items: .success(decodedDocuments)) // <~ Here
        }
      } else {
        self?.set(items: .success(decodedDocuments)) // <~ Here
      }
    }

    setupListener()
  }
```

Also add conditional animation logic to the error setter to animate errors on screen:

```swift
private func projectError(_ error: Error?) {
      if let animation = configuration.animation {
          withAnimation(animation) {
              shouldUpdateListener = false
              configuration.error = error
              shouldUpdateListener = true
          }
          return
      }
      shouldUpdateListener = false
      configuration.error = error
      shouldUpdateListener = true
  }
```

### Firebase Product(s)

Firestore
Feature: Add animations to @FirestoreQuery
### Features

- 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`)

### Description

1. **Use case:** Animate the data updates provided by the `@FirestoreQuery` property wrapper.
2. **Current implementation:** Unless I'm missing something, `@FirebaseQuery` doesn't animate its published changes.
3. **Proposed solution:** Add an `Animation` parameter to the `FirestoreQueryObservable` initializer, store it in the `FirestoreQuery.Configuration`, and conditionally invoke a `withAnimation` block when setting the data.

### API Proposal

1. First, add an `Animation` object to the `FirestoreQuery.Configuration`
2. Add an optional `Animation` parameter to the `FirestoreQuery`'s initializer
3. Add a `private` method to update the `items` instance memory, with a conditional animation block (based on the configuration)
4. Update all of the setter expressions (setting the `items` value) with this new `set(items:)` func.
5. Also add conditional animation logic to the error setter to animate errors on screen.

### Firebase Product(s)

Firestore

### Related Feature Requests

This fixes #10810


Discussion / hints:
I couldn't figure out how to label this issue, so I've labeled it for a human to triage. Hang tight.
This looks like a useful addition, thanks for sending this FR, @briannadoubt.

Feel free to send a PR. Can you add a sample to https://github.com/firebase/firebase-ios-sdk/tree/master/Example/FirestoreSample to demonstrate how to use this?

I will take a look next week and follow up with next steps (this will likely have to go through our internal API review process).


@peterfriese I am hoping to have that PR up later today!


Fix patch:
diff --git a/.gitignore b/.gitignore
index 2c8cbf5e265..06975f0f4f6 100644
--- a/.gitignore
+++ b/.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
diff --git a/Example/FirestoreSample/.firebaserc b/Example/FirestoreSample/.firebaserc
index 0967ef424bc..b3794acc46a 100644
--- a/Example/FirestoreSample/.firebaserc
+++ b/Example/FirestoreSample/.firebaserc
@@ -1,1 +1,5 @@
-{}
+{
+  "projects": {
+    "default": "fir-firestore-sample-ios"
+  }
+}
diff --git a/Example/FirestoreSample/FirestoreSample.xcodeproj/project.pbxproj b/Example/FirestoreSample/FirestoreSample.xcodeproj/project.pbxproj
index 8b858cb5ac1..47f8f18d631 100644
--- a/Example/FirestoreSample/FirestoreSample.xcodeproj/project.pbxproj
+++ b/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 */
 	};
diff --git a/Example/FirestoreSample/FirestoreSample/App/FirestoreSampleApp.swift b/Example/FirestoreSample/FirestoreSample/App/FirestoreSampleApp.swift
index e05a49b066e..b920b0d1c1d 100644
--- a/Example/FirestoreSample/FirestoreSample/App/FirestoreSampleApp.swift
+++ b/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 {
diff --git a/Example/FirestoreSample/FirestoreSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/FirestoreSample/FirestoreSample/Assets.xcassets/AppIcon.appiconset/Contents.json
index d48fcc0a68a..f78687a21d1 100644
--- a/Example/FirestoreSample/FirestoreSample/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/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" : {
diff --git a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsAnimationView.swift b/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsAnimationView.swift
new file mode 100644
index 00000000000..79b44a134ce
--- /dev/null
+++ b/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()
+  }
+}
diff --git a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView.swift b/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView.swift
index 20db78f486f..cae3f797c08 100644
--- a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView.swift
+++ b/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):
diff --git a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView2.swift b/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView2.swift
index 96e1f7ac7f2..ea6b0453739 100644
--- a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsMappingErrorView2.swift
+++ b/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)
   }
diff --git a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsNoAnimationsView.swift b/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsNoAnimationsView.swift
new file mode 100644
index 00000000000..0a0cd01b7e7
--- /dev/null
+++ b/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()
+  }
+}
diff --git a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsView.swift b/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsView.swift
index d5a4be92deb..f7f2967d9a3 100644
--- a/Example/FirestoreSample/FirestoreSample/Views/FavouriteFruitsView.swift
+++ b/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",
diff --git a/Example/FirestoreSample/FirestoreSample/Views/MenuView.swift b/Example/FirestoreSample/FirestoreSample/Views/MenuView.swift
index 052595a796b..bd54937267e 100644
--- a/Example/FirestoreSample/FirestoreSample/Views/MenuView.swift
+++ b/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")
diff --git a/Example/FirestoreSample/data/auth_export/accounts.json b/Example/FirestoreSample/data/auth_export/accounts.json
new file mode 100644
index 00000000000..135e08db674
--- /dev/null
+++ b/Example/FirestoreSample/data/auth_export/accounts.json
@@ -0,0 +1,1 @@
+{"kind":"identitytoolkit#DownloadAccountResponse","users":[]}
\ No newline at end of file
diff --git a/Example/FirestoreSample/data/auth_export/config.json b/Example/FirestoreSample/data/auth_export/config.json
new file mode 100644
index 00000000000..8f77af98099
--- /dev/null
+++ b/Example/FirestoreSample/data/auth_export/config.json
@@ -0,0 +1,1 @@
+{"signIn":{"allowDuplicateEmails":false}}
\ No newline at end of file
diff --git a/Example/FirestoreSample/data/firebase-export-metadata.json b/Example/FirestoreSample/data/firebase-export-metadata.json
index 5ffc80edeb4..18ca13e8c45 100644
--- a/Example/FirestoreSample/data/firebase-export-metadata.json
+++ b/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"
   }
 }
\ No newline at end of file
diff --git a/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata
index 2caf85da511..c9de4abed91 100644
Binary files a/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ
diff --git a/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0 b/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0
index 25de79ed477..297f8b8b65c 100644
Binary files a/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0 and b/Example/FirestoreSample/data/firestore_export/all_namespaces/all_kinds/output-0 differ
diff --git a/Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata b/Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata
index 7fb0bc88667..0faec7f73b0 100644
Binary files a/Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata and b/Example/FirestoreSample/data/firestore_export/firestore_export.overall_export_metadata differ
diff --git a/Example/FirestoreSample/firebase.json b/Example/FirestoreSample/firebase.json
index d941cfb8c4d..5801b813e6f 100644
--- a/Example/FirestoreSample/firebase.json
+++ b/Example/FirestoreSample/firebase.json
@@ -9,6 +9,10 @@
     },
     "ui": {
       "enabled": true
+    },
+    "singleProjectMode": true,
+    "auth": {
+      "port": 9099
     }
   }
 }
diff --git a/Example/FirestoreSample/firestore.indexes.json b/Example/FirestoreSample/firestore.indexes.json
index 4ff4fab5c7c..415027e5dda 100644
--- a/Example/FirestoreSample/firestore.indexes.json
+++ b/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": []
-}
\ No newline at end of file
+}
diff --git a/Example/FirestoreSample/firestore.rules b/Example/FirestoreSample/firestore.rules
index 126bebe8f2b..01cd3978ced 100644
--- a/Example/FirestoreSample/firestore.rules
+++ b/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;
     }
   }
-}
+}
\ No newline at end of file
diff --git a/Firestore/Swift/CHANGELOG.md b/Firestore/Swift/CHANGELOG.md
index bcec66cc065..60d55d41342 100644
--- a/Firestore/Swift/CHANGELOG.md
+++ b/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)
diff --git a/Firestore/Swift/Source/PropertyWrapper/FirestoreQuery.swift b/Firestore/Swift/Source/PropertyWrapper/FirestoreQuery.swift
index ba93477da5c..5cfd77f4742 100644
--- a/Firestore/Swift/Source/PropertyWrapper/FirestoreQuery.swift
+++ b/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 =
diff --git a/Firestore/Swift/Source/PropertyWrapper/FirestoreQueryObservable.swift b/Firestore/Swift/Source/PropertyWrapper/FirestoreQueryObservable.swift
index a157375123b..a5a01beb9c3 100644
--- a/Firestore/Swift/Source/PropertyWrapper/FirestoreQueryObservable.swift
+++ b/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()
+    }
+  }
 }


Test patch: