Fetch older messages when the user scroll inside a chat - swift

I have a chat but i don't want to show all the messages at once because it's laggy. When i click on the chat i want to show the last 20 messages and everytime i scroll i want to fetch 20 older messages, that's why i'm using a query limitation. When i start loading the view, the last 20 messages are showing fine but everytime i scroll, nothing happens, it shows and print the same last 20 messages instead of displaying the 20 older one. I don't know how to insert the new messages correctly inside my collectionView, here's what i've tried so far:
[UPDATED]RoomMessageViewController
var lastDocumentSnapshot: DocumentSnapshot!
var fetchingMore = false
private var messages = [RoomMessage]()
private var chatMessages = [[RoomMessage]]()
override func viewDidLoad() {
super.viewDidLoad()
loadMessages()
}
// MARK: - Helpers
fileprivate func attemptToAssembleGroupedMessages(completion: (Bool) -> ()){
chatMessages.removeAll()
let groupedMessages = Dictionary(grouping: messages) { (element) -> Date in
return element.timestampDate.reduceToMonthDayYear() }
// provide a sorting for the keys
let sortedKeys = groupedMessages.keys.sorted()
sortedKeys.forEach { (key) in
let values = groupedMessages[key]
chatMessages.append(values ?? [])
self.collectionView.reloadData()
}
completion(true)
}
// MARK: - API
func loadMessages() {
var query: Query!
guard let room = room else{return}
guard let roomID = room.recentMessage.roomID else{return}
showLoader(true)
fetchingMore = true
if messages.isEmpty {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 20)
print("First 10 msg loaded")
} else {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 20)
print("Next 10 msg loaded")
}
query.addSnapshotListener { querySnapshot, error in
guard let snapshot = querySnapshot else {
print("Error fetching snapshots: \(error!)")
return
}
guard let lastSnap = snapshot.documents.first else {return}
self.lastDocumentSnapshot = lastSnap
snapshot.documentChanges.forEach({ (change) in
if change.type == .added {
let dictionary = change.document.data()
let timestamp = dictionary["timestamp"] as? Timestamp
var message = RoomMessage(dictionary: dictionary)
self.messages.append(message)
self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
self.collectionView.reloadData()
}
self.attemptToAssembleGroupedMessages { (assembled) in
if assembled {
}
}
self.lastDocumentSnapshot = snapshot.documents.first
})
}
}
}
extension RoomMessageViewController {
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset.y
if contentOffset <= -40 {
loadMessages()
}
}

Here is my asset fetching function.
You can see I am using limit logic, at the line limit(to: AlbumRepository.assetPageSize).
Then you will need an "index" to fetch documents right before/after your "index".
Ex: your message will have a prop named created_date.
We consider that asset is your message, albumDocId is your chatRoomId, asset_created is your message's timestamp.
So we will:
Query all documents that have created_date older than "index" (index here is a Date in second or TimeStamp). Line: whereField("asset_created", isLessThanOrEqualTo: lastCreatedForQuery)
Sort the result from newest to oldest by using created_date. Line: order(by: "asset_created", descending: true) to sort by asset_created ASC. (ASC when you display newest at the bottom, DESC when you display newest at the top)
Now we limit the number of returned documents to x items. Line: limit(to: AlbumRepository.assetPageSize), my value is 20 items.
After you get the first batch with index is now, just save the created_date of the last document in the returned list (from query's response). Then put in whereField("asset_created", isLessThanOrEqualTo: {index}). Last item on the list will be the oldest message.
// MARK: - Asset repo functions
static let assetPageSize = 20
func fetchAsset(albumDocId id: String, lastCreated: TimeInterval?, done: #escaping ([FSAssetModel]) -> Void) {
let lastCreatedForQuery = lastCreated ?? Date().timeIntervalSince1970
FirebaseFirestoreManager.db
.collection(tableName)
.document(id)
.collection("asset")
.whereField("asset_created", isLessThan: lastCreatedForQuery)
.order(by: "asset_created", descending: false)
.limit(to: AlbumRepository.assetPageSize)
.getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
done([])
} else {
var finalResult: [FSAssetModel] = []
for document in querySnapshot!.documents {
if let asset = FSAssetModel(JSON: document.data()) {
asset.doc_id = document.documentID
finalResult.append(asset)
}
}
done(finalResult)
}
}
}
Next, in your message screen, you have an array messages, to use it as dataSource.
And, private var _lastCreatedOfMessage: Date?
// When you open your message screen, you will call this function to fetch last x message,
// and the first time you call this function, you have no _lastCreatedOfMessage yet, it will be null.
// That means we will last x message from NOW
// You can check this line in the fetch method:
// let lastCreatedForQuery = lastCreated ?? Date().timeIntervalSince1970
// albumDocId: "ABCD1234" -> it will be your room id
// You will call this method anytime the user scrolls to the top/bottom to load history messages, after the `firstLoadMessages()` when you open screen.
fetchAsset(albumDocId: "ABCD1234", lastCreated: self._lastCreatedOfMessage) { [weak self] messages
guard let _self = self else, !messages.isEmpty { return }
// 1) Save your lastCreated for next fetch/query
// We order by created_date ASC so the first item will be oldest
// It will be used for next list [history message] fetching
_self.lastCreatedOfMessage = items.first!.created_date
// 2) Now we insert this message list to begin of your message list, that is dataSource for displaying
_self.yourMessageList.insert(contentsOf: messages, at: 0)
// 3) Reload your list, then scroll to the top or first indexPath
// Because you are dragging the listView down to see the older message
// That is why you use order by created_date [ASC]
_self.yourListView.reloadData() // ex: UICollectionView
_self.yourListView.scrollToItem(at: IndexPath.init(item: 0, section: 0), at: .top, animated: true)
// or more simple trick
// _self.yourListView.setContentOffset(.zero, animated: true)
}
Finally, to update your observer logic, you just need to observe the last new/changed message. Whenever you have a new incoming message, append it to your message list.
THE MOST REASONABLE LOGIC IS:
Open screen, fetch the list of last x messages,
When you get the list of last x messages, initialize your observer now, we will observe for any new message that has timestamp that is newer than the newest message from the above list.
static func observeNewMessage(roomId: String, lastTimeStamp: TimeInterval, completion: #escaping(RoomMessage?, Error?) -> Void) -> Query {
let now = Date().timeIntervalSince1970
let query = COLLECTION_ROOMS.document(roomId)
.collection("messages")
.whereField("timestamp", isGreaterThan: lastTimeStamp) // only take message that newer than lastTimeStamp
.order(by: "timestamp", descending: false)
.limit(toLast: 1)
// You will need to retain this query instance to keep observing for new message until you close message screen.
query.addSnapshotListener { querySnapshot, error in
guard let snapshot = querySnapshot else {
print("Error fetching snapshots: \(error!)")
return
}
snapshot.documentChanges.forEach { change in
let dictionary = change.document.data()
var message = RoomMessage(dictionary: dictionary)
if (change.type == .added) {
completion(message, nil)
print("Added msg: \(message.text)")
}
if (change.type == .modified) {
print("Modified msg: \(message.text)")
}
if (change.type == .removed) {
print("Removed msg: \(message.text)")
}
}
}
return query
}
How to use:
private var newMessageQueryListener: Query? // this variable is used to retain query [observeNewMessage]
// You only call this function once when open screen
func firstLoadMessages() {
showLoader(true)
guard let room = room else, let roomId = room.recentMessage.roomID { return }
fetchAsset(albumDocId: "ABCD1234", lastCreated: self._lastCreatedOfMessage) { [weak self] messages
guard let _self = self else, !messages.isEmpty { return }
// 1) Save your lastCreated for next fetch/query
// We order by created_date ASC so the first item will be oldest
// It will be used for next list [history message] fetching
_self.lastCreatedOfMessage = items.first!.timestamp
// 2) Now we insert this message list to begin of your message list, that is dataSource for displaying
_self.yourMessageList.insert(contentsOf: messages, at: 0)
// 3) Reload your list, then scroll to the top or LAST indexPath
// Because user just open screen not loading older messages
// ALWAYS call reload data before you do some animation,...
_self.yourListView.reloadData() // ex: UICollectionView
// Scroll to last index with position: bottom for newest message
let lastIndex = _self.messages.count - 1
_self.yourListView.scrollToItem(at: IndexPath.init(item: lastIndex, section: 0), at: .botom, animated: true)
// 4) Setup your observer for new message here
let lastTimeStamp = items.last!.timestamp
_self.makeNewMessageObserver(roomId: roomId, lastTimeStamp: lastTimeStamp)
}
}
private func makeNewMessageObserver(roomId: String, lastTimeStamp: TimeInterval) {
self.newMessageQueryListener = RoomService.observeNewMessage(roomId: roomId, lastTimeStamp: lastTimeStamp) { [weak self] newMess, error in
// DO NOT CALL self or REFER self in the block/closure IF you dont know its life-cycle.
// [weak self] will make an optional refering to self to prevent memory-leak.
guard let _self = self else { return } // here we check if self(MessageScreen) is null or not, if not null we continue
_self.messages.append(newMess) // ASC so the last item in list will be newest message
// Reload then scroll to the bottom for newest message
DispatchQueue.main.async {
_self.collectionView.reloadData()
let lastIndex = _self.messages.count - 1
_self.collectionView.scrollToItem(at: IndexPath(item: lastIndex, section: 0), at: .bottom, animated: true)
}
}
}
The rule is: Fetch a list, but observe one.

func loadMessages() {
var query: Query!
guard let room = room else{return}
guard let roomID = room.recentMessage.roomID else{return}
showLoader(true)
fetchingMore = true
if messages.isEmpty {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 20)
print("First 10 msg loaded")
} else {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 20)
print("Next 10 msg loaded")
}
query.addSnapshotListener { querySnapshot, error in
guard let snapshot = querySnapshot else {
print("Error fetching snapshots: \(error!)")
return
}
guard let lastSnap = snapshot.documents.first else {return}
self.lastDocumentSnapshot = lastSnap
snapshot.documentChanges.forEach({ (change) in
if change.type == .added {
let dictionary = change.document.data()
let timestamp = dictionary["timestamp"] as? Timestamp
var message = RoomMessage(dictionary: dictionary)
self.messages.append(message)
self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
self.collectionView.reloadData()
}
self.attemptToAssembleGroupedMessages { (assembled) in
if assembled {
}
}
self.lastDocumentSnapshot = snapshot.documents.first
})
}
}
}

Related

Restart Firebase observe

I created a method where I fetch all the published Books in Firebase. Each Book object stores a userId string value. I would like to fetch all books excluding currentUser books. I fetch 5 books every time I call the method starting from lastBookId, however if a user publishes more than 5 books, and are the first five, they can not be shown and as a result can not continue fetching them. I was thinking about increasing the limit value and calling the query observation again.
My code:
public func fetchBooksStarting(with lastBookId: String? = nil, completion: #escaping (Result<[Book], Error>) -> Void) {
var limit: UInt = 5
var books = [Book]()
let group = DispatchGroup()
var query = database.child("books").queryOrdered(byChild: "type")
if lastBookId != nil {
query = query.queryStarting(afterValue: BookType.Fiction.rawValue, childKey: lastBookId)
} else {
query = query.queryEqual(toValue: BookType.Fiction.rawValue)
}
query.queryLimited(toFirst: limit).observeSingleEvent(of: .value, with: { snap in
guard let snapshot = snap.children.allObjects as? [DataSnapshot] else {
completion(.failure(DatabaseErrors.failedToFetch))
return
}
books.removeAll()
for data in snapshot {
group.enter()
if let dict = data.value as? [String: AnyObject] {
let book = Book(dict: dict, bookId: data.key)
if book.userId == currentUserUid {
limit += 1
// recall query observe
} else {
books.append(book)
}
}
group.leave()
}
group.notify(queue: .main) {
completion(.success(books))
}
}, withCancel: nil)
}

How can I combine the result of these 2 async methods?

I have 2 methods I call. I need to produce a model that contains the result of both and call another method.
I wanted to avoid placing 1 method inside another as this could expand out to 3 or 4 additional calls.
Essentially once I have the results for setUserFollowedState and loadFollowersForTopic I want to send both values to another function.
Coming from a JS land I would use async/await but this does not exist in Swift.
func setUserFollowedState() {
following.load(for: userID, then: { [weak self, topic] result in
guard self != nil else { return }
let isFollowed = (try? result.get().contains(topic)) ?? false
// do something with isFollowed?
})
}
func loadFollowersForTopic() {
followers.load(topic, then: { [weak self, topic] result in
guard self != nil else { return }
let count = (try? result.get().first(where: { $0.tag == topic })?.followers) ?? 0
// do something with count?
})
}
You can store both async call results as optional properties. When your callbacks happen, set these properties then check that both properties have been set. If they've been set, you know both async calls have returned.
private var isFollowed: Bool?
private var count: Int?
func setUserFollowedState() {
following.load(for: userID, then: { [weak self, topic] result in
guard let self = self else { return }
let isFollowed = (try? result.get().contains(topic)) ?? false
self.isFollowed = isFollowed
performPostAsyncCallFunctionality()
})
}
func loadFollowersForTopic() {
followers.load(topic, then: { [weak self, topic] result in
guard let self = self else { return }
let count = (try? result.get().first(where: { $0.tag == topic })?.followers) ?? 0
self.count = count
performPostAsyncCallFunctionality()
})
}
private func performPostAsyncCallFunctionality() {
// Check that both values have been set.
guard let isFollowed = isFollowed, let count = count else { return }
// Both calls have returned, do what you need to do.
}
The good thing about this approach is that you can easily add more async calls using the pattern. However, if you need to make that many async network calls at once, I would recommend you think about rewriting your server-side logic so you only need one network call for this functionality.
Another approach (which I believe is a bit cleaner) would be to use a DispatchGroup to combine the result of the mentioned methods.
You would modify your original methods to take a completion handler and then combine the two results where you actually need the data.
See example below.
func setUserFollowedState(completion: #escaping ((Bool) -> Void)) {
following.load(for: userID, then: { [weak self, topic] result in
guard self != nil else { return }
let isFollowed = (try? result.get().contains(topic)) ?? false
// Call completion with isFollowed flag
completion(isFollowed)
})
}
func loadFollowersForTopic(completion: #escaping ((Int) -> Void)) {
followers.load(topic, then: { [weak self, topic] result in
guard self != nil else { return }
let count = (try? result.get().first(where: { $0.tag == topic })?.followers) ?? 0
// Call completion with follower count
completion(count)
})
}
func loadFollowedAndCount() {
let group = DispatchGroup()
var isFollowed: Bool?
// Enter group before triggering data fetch
group.enter()
setUserFollowedState { followed in
// Store the fetched followed flag
isFollowed = followed
// Leave group only after storing the data
group.leave()
}
var followCount: Int?
// Enter group before triggering data fetch
group.enter()
loadFollowersForTopic { count in
// Store the fetched follow count
followCount = count
// Leave group only after storing the data
group.leave()
}
// Wait for both methods to finish - enter/leave state comes back to 0
group.notify(queue: .main) {
// This is just a matter of preference - using optionals so we can avoid using default values
if let isFollowed = isFollowed, let followCount = followCount {
// Combined results of both methods
print("Is followed: \(isFollowed) by: \(followCount).")
}
}
}
Edit: always make sure that a group.enter() is followed by a group.leave().

Firestore Listener Document Changes

I am trying to create a Listener for changes to a Document. When I change the data in Firestore (server) it doesn't update in the TableView (App). The TableView only updates when I reopen the App or ViewController.
I have been able to set this up for a Query Snapshot but not for a Document Snapshot.
Can anyone look at the code below to see why this is not updating in realtime?
override func viewDidAppear(_ animated: Bool) {
var newDocIDString = newDocID ?? ""
detaliPartNumberListerner = firestore.collection(PARTINFO_REF).document(newDocIDString).addSnapshotListener { documentSnapshot, error in
guard let document = documentSnapshot else {
print("Error fetching document: \(error!)")
return
}
guard let data = document.data() else {
print("Document data was empty.")
return
}
print("Current data: \(data)")
self.partInfos.removeAll()
self.partInfos = PartInfo.parseData2(snapshot: documentSnapshot)
self.issueTableView.reloadData()
}
In my PartInfo file
class func parseData2(snapshot: DocumentSnapshot?) -> [PartInfo] {
var partNumbers = [PartInfo]()
guard let snap = snapshot else { return partNumbers }
//for document in snap.documents {
// let data = document.data()
let area = snapshot?[AREA] as? String ?? "Not Known"
let count = snapshot?[COUNT] as? Int ?? 0
//let documentId = document.documentID
let documentId = snapshot?.documentID ?? ""
let newPartInfo = PartInfo(area: area, count: count, documentId: documentId)
partNumbers.append(newPartInfo)
return partNumbers
}
UI work must always be done on the main thread. So instead of your last line in your first code snippet, do this:
DispatchQueue.main.async {
self.issueTableView.reloadData()
}
I think this might be the solution to your problem. (A little late, I know ...)

Reloading individual TableView rows upon changes in document

Long time listener, first time app developer..
I'm using Firestore data to populate a TableView in Swift 4.2 using a snapshot listener. This works great if I don't mind the entire TableView reloading with every document change, however I've now added animations to the cell that trigger upon a status value change in the document and my present implementation of tableView.reloadData() triggers all cells to play their animations with any change to any document in the collection.
I'm seeking help understanding how to implement reloadRows(at:[IndexPath]) using .documentChanges with diff.type == .modified to reload only the rows that have changed and have spent more time than I'd like to admit trying to figure it out. =/
I have attempted to implement tableView.reloadRows, but cannot seem to understand how to specify the indexPath properly for only the row needing updated. Perhaps I need to add conditional logic for the animations to only execute with changes in the document? Losing hair.. Any help is greatly appreciated.
Snapshot implementation:
self.listener = query?.addSnapshotListener(includeMetadataChanges: true) { documents, error in
guard let snapshot = documents else {
print("Error fetching snapshots: \(error!)")
return
}
snapshot.documentChanges.forEach { diff in
if (diff.type == .added) {
let source = snapshot.metadata.isFromCache ? "local cache" : "server"
print("Metadata: Data fetched from \(source)")
let results = snapshot.documents.map { (document) -> Task in
if let task = Task(eventDictionary: document.data(), id: document.documentID) {
return task
} // if
else {
fatalError("Unable to initialize type \(Task.self) with dictionary \(document.data())")
} // else
} //let results
self.tasks = results
self.documents = snapshot.documents
self.tableView.reloadData()
} // if added
if (diff.type == .modified) {
print("Modified document: \(diff.document.data())")
let results = snapshot.documents.map { (document) -> Task in
if let task = Task(eventDictionary: document.data(), id: document.documentID) {
return task
} // if
else {
fatalError("Unable to initialize type \(Task.self) with dictionary \(document.data())")
} // else closure
} //let closure
self.tasks = results
self.documents = snapshot.documents
self.tableView.reloadData() // <--- reloads the entire tableView with changes = no good
self.tableView.reloadRows(at: <#T##[IndexPath]#>, with: <#T##UITableView.RowAnimation#>) // <-- is what I need help with
}
if (diff.type == .removed) {
print("Document removed: \(diff.document.data())")
} // if removed
} // forEach
} // listener
cellForRowAt
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "eventListCell", for: indexPath) as! EventTableViewCell
let item = tasks[indexPath.row]
let url = URL.init(string: (item.eventImageURL))
datas.eventImageURL = url
cell.eventImageView.kf.setImage(with: url)
cell.eventEntranceLabel!.text = item.eventLocation
cell.eventTimeLabel!.text = item.eventTime
if item.eventStatus == "inProgress" {
cell.eventReponderStatus.isHidden = false
cell.eventReponderStatus.text = "\(item.eventResponder)" + " is responding"
UIView.animate(withDuration: 2, delay: 0.0, options: [.allowUserInteraction], animations: {cell.backgroundColor = UIColor.yellow; cell.backgroundColor = UIColor.white}, completion: nil)
}
else if item.eventStatus == "verifiedOK" {
cell.eventReponderStatus.isHidden = false
cell.eventReponderStatus.text = "\(item.eventResponder)" + " verified OK"
UIView.animate(withDuration: 2, delay: 0.0, options: [.allowUserInteraction], animations: {cell.backgroundColor = UIColor.green; cell.backgroundColor = UIColor.white}, completion: nil)
}
else if item.eventStatus == "sendBackup" {
cell.eventReponderStatus.isHidden = false
cell.eventReponderStatus.text = "\(item.eventResponder)" + " needs assistance"
UIView.animate(withDuration: 1, delay: 0.0, options: [.repeat, .autoreverse, .allowUserInteraction], animations: {cell.backgroundColor = UIColor.red; cell.backgroundColor = UIColor.white}, completion: nil)
}
else if item.eventStatus == "newEvent" {
UIView.animate(withDuration: 2, delay: 0.0, options: [.allowUserInteraction], animations: {cell.backgroundColor = UIColor.red; cell.backgroundColor = UIColor.white}, completion: nil)
}
else {
cell.isHidden = true
cell.eventReponderStatus.isHidden = true
}
switch item.eventStatus {
case "unhandled": cell.eventStatusIndicator.backgroundColor = UIColor.red
case "inProgress": cell.eventStatusIndicator.backgroundColor = UIColor.yellow
case "verifiedOK": cell.eventStatusIndicator.backgroundColor = UIColor.green
case "sendBackup": cell.eventStatusIndicator.backgroundColor = UIColor.red
default: cell.eventStatusIndicator.backgroundColor = UIColor.red
}
return cell
}
Variables and setup
// Create documents dictionary
private var documents: [DocumentSnapshot] = []
// Create tasks var
public var tasks: [Task] = []
// Create listener registration var
private var listener : ListenerRegistration!
// Create baseQuery function
fileprivate func baseQuery() -> Query {
switch switchIndex {
case 0:
return Firestore.firestore().collection("metalDetectorData").document("alarmEvents").collection("eventList").limit(to: 50).whereField("eventStatus", isEqualTo: "unhandled")
case 1:
return Firestore.firestore().collection("metalDetectorData").document("alarmEvents").collection("eventList").limit(to: 50).whereField("eventStatus", isEqualTo: "verifiedOK")
case 3:
return Firestore.firestore().collection("metalDetectorData").document("alarmEvents").collection("eventList").limit(to: 50)
default:
return Firestore.firestore().collection("metalDetectorData").document("alarmEvents").collection("eventList").limit(to: 50)//.whereField("eventStatus", isEqualTo: false)
}
} // baseQuery closure
// Create query variable
fileprivate var query: Query? {
didSet {
if let listener = listener {
listener.remove()
}
}
} // query closure
Tasks
struct Task{
var eventLocation: String
var eventStatus: String
var eventTime: String
var eventImageURL: String
var eventResponder: String
var eventUID: String
var eventDictionary: [String: Any] {
return [
"eventLocation": eventLocation,
"eventStatus": eventStatus,
"eventTime": eventTime,
"eventImageURL": eventImageURL,
"eventResponder": eventResponder,
"eventUID": eventUID
]
} // eventDictionary
} // Task
extension Task{
init?(eventDictionary: [String : Any], id: String) {
guard let eventLocation = eventDictionary["eventLocation"] as? String,
let eventStatus = eventDictionary["eventStatus"] as? String,
let eventTime = eventDictionary["eventTime"] as? String,
let eventImageURL = eventDictionary["eventImageURL"] as? String,
let eventResponder = eventDictionary["eventResponder"] as? String,
let eventUID = id as? String
else { return nil }
self.init(eventLocation: eventLocation, eventStatus: eventStatus, eventTime: eventTime, eventImageURL: eventImageURL, eventResponder: eventResponder, eventUID: eventUID)
}
}
So I did this without really knowing Firebase or having a compiler to check for errors. There may be some typos and you may have to do some unwrapping and casting but the idea should be there. I added lots of comments to help you understand what is happening in the code…
self.listener = query?.addSnapshotListener(includeMetadataChanges: true) { documents, error in
guard let snapshot = documents else {
print("Error fetching snapshots: \(error!)")
return
}
// You only need to do this bit once, not for every update
let source = snapshot.metadata.isFromCache ? "local cache" : "server"
print("Metadata: Data fetched from \(source)")
let results = snapshot.documents.map { (document) -> Task in
if let task = Task(eventDictionary: document.data(), id: document.documentID) {
return task
} // if
else {
fatalError("Unable to initialize type \(Task.self) with dictionary \(document.data())")
} // else
} //let results
// Tell the table view you are about to give it a bunch of updates that should all get batched together
self.tableView.beginUpdates()
snapshot.documentChanges.forEach { diff in
let section = 0 // This should be whatever section the update is in. If you only have one section then 0 is right.
if (diff.type == .added) {
// If a document has been added we need to insert a row for it…
// First we filter the results from above to find the task connected to the document ID.
// We use results here because the document doesn't exist in tasks yet.
let filteredResults = results.filter { $0.eventUID == diff.document.documentID }
// Do some saftey checks on the filtered results
if filteredResults.isEmpty {
// Deal with the fact that there is a document that doesn't have a companion task in results. This shouldn't happen, but who knows…
}
if filteredResults.count > 1 {
// Deal with the fact that either the document IDs aren't terribly unique or that a task was added more than once for the saem document
}
let row = results.index(of: filteredResults[0])
let indexPath = IndexPath(row: row, section: section)
// Tell the table view to insert the row
self.tableView.insertRows(at: [indexPath], with: .fade)
} // if added
if (diff.type == .modified) {
// For modifications we need to get the index out of tasks so the index path matches the current path not the one it will have after the updates.
let filteredTasks = self.tasks.filter { $0.eventUID == diff.document.documentID }
// Do some saftey checks on the filtered results
if filteredTasks.isEmpty {
// Deal with the fact that there is a document that doesn't have a companion task in results. This shouldn't happen, but who knows…
}
if filteredTasks.count > 1 {
// Deal with the fact that either the document IDs aren't terribly unique or that a task was added more than once for the saem document
}
let row = self.tasks.index(of: filteredTasks[0])
let indexPath = IndexPath(row: row, section: section)
// Tell the table view to update the row
self.tableView.reloadRows(at: [indexPath], with: .fade)
}
if (diff.type == .removed) {
print("Document removed: \(diff.document.data())")
// For deleted documents we need to use tasks since it doesn't appear in results
let filteredTasks = self.tasks.filter { $0.eventUID == diff.document.documentID }
// Do some saftey checks on the filtered results
if filteredTasks.isEmpty {
// Deal with the fact that there is a document that doesn't have a companion task in results. This shouldn't happen, but who knows…
}
if filteredTasks.count > 1 {
// Deal with the fact that either the document IDs aren't terribly unique or that a task was added more than once for the saem document
}
let row = self.tasks.index(of: filteredTasks[0])
let indexPath = IndexPath(row: row, section: section)
// ** Notice that the above few lines are very similiar in all three cases. The only thing that varies is our use results or self.tasks. You can refactor this out into its own method that takes the array to be filtered and the documentID you are looking for. It could then return either the the row number by itself or the whole index path (returning just the row would be more flexible).
// Tell the table view to remove the row
self.tableView.deleteRows(at: [indexPath], with: .fade)
} // if removed
} // forEach
// Sync tasks and documents with the new info
self.tasks = results
self.documents = snapshot.documents
// Tell the table view you are done with the updates so It can make all the changes
self.tableView.endUpdates()
} // listener
Inside of your change listener all you really need to do is save the indexes of the corresponding changes, save your model objects, and then trigger your table view updates.
let insertions = snapshot.documentChanges.compactMap {
return $0.type == .added ? IndexPath(row: Int($0.newIndex), section: 0) : nil
}
let modifications = snapshot.documentChanges.compactMap {
return $0.type == .modified ? IndexPath(row: Int($0.newIndex), section: 0) : nil
}
let deletions = snapshot.documentChanges.compactMap {
return $0.type == .removed ? IndexPath(row: Int($0.oldIndex), section: 0) : nil
}
self.userDocuments = snapshot.documents
self.tableView.beginUpdates()
self.tableView.insertRows(at: insertions, with: .automatic)
self.tableView.reloadRows(at: modifications, with: .automatic)
self.tableView.deleteRows(at: deletions, with: .automatic)
self.tableView.endUpdates()
There are more efficient ways of mapping the changes to IndexPaths but this was the clearest way to write it.

Remove firestore snapshot listener not working (Swift)

I have the following code to start listening for changes in a specific directory of firestore:
func updateCart(){
database.collection("Users").document(currentUserUUID!).collection("Transactions").addSnapshotListener { querySnapshot, error in
guard let snapshot = querySnapshot else {
print("Error fetching snapshots: \(error!)")
return
}
snapshot.documentChanges.forEach { event in
let Active = event.document.data()["Active"] as? Bool
let Type = event.document.data()["Instore"] as? Bool
let Store = event.document.data()["Store"] as? String
let TDate = event.document.data()["Date"] as? String
if (Active == true) {
print("New Transaction at \(Store!) - (Instore location:\(Type!)) on \(TDate!)")
self.searchCart(Document: event.document.documentID)
self.DocumentID = event.document.documentID
self.startTransaction()
}
if (event.type == .modified && Active == false){
print("Transaction at \(Store!) - (Instore location:\(Type!)) on \(TDate!) is no longer active or has been finalized")
let updatedData = ["Total": "\(self.Total)","Saved": "\(self.Savings)"]
database.collection("Users").document(currentUserUUID!).collection("Transactions").document(event.document.documentID).setData(updatedData, merge: true)
self.completeTransaction()
self.DocumentID = " "
}
}
}
}
In viewDidLoad I run the function: updateCart(), but when the field Active in the database changes to false, I am trying to stop the listener in the app. I tried the following code but it does not stop the listener:
func detachListener() {
let listener = database.collection("Users").document(currentUserUUID!).collection("Transactions").addSnapshotListener { querySnapshot, error in
if error != nil{
print(error as Any)
}
}
listener.remove()
}