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.
Related
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
})
}
}
}
I want grabAllFollowingPosts() to run only after loadFollowing() has finished running. These are both network calls so I want to run them in the background. Any ideas on why my code isn’t working?
DispatchQueue.global(qos: .userInteractive).sync {
self.loadFollowing()
self.grabAllFollowingPosts()
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
What these 3 functions do is:
grab every user that the current user is following
for each of those users, grab their posts
Hence, loadUsers() must run before grabAllFollowingPosts()
var followingUsers = [String]()
//Function 1: load the poeple you are following into the followingUsers array
func loadFollowing () {
guard let userID = Auth.auth().currentUser?.uid else { return }
let firestoreRef = Firestore.firestore().collection("Following").document(userID).collection("UserFollowing")
firestoreRef.addSnapshotListener { (snapshot, error) in
if error != nil {
//error retrieving documents
print (error!.localizedDescription)
} else {
// document retrival successful
guard let snapshot = snapshot else { return }
for document in snapshot.documents {
let data = document.data()
let userid = data["UserID"] as? String ?? "anonymous"
self.followingUsers.append(userid)
}
}
}
}
//Function 2: for all of the users in the followingUsers array - grab their documents from Firestore
func grabAllFollowingPosts () {
for users in followingUsers {
loadPosts(theUsers: users)
}
}
//Function 3: loads the posts
func loadPosts (theUsers: String) {
let firestoreRef = Firestore.firestore().collection("Posts").whereField("UserID", isEqualTo: theUsers).whereField("Date", isGreaterThanOrEqualTo: Date()).limit(to: 8)
//TODO: add infinate scroll
firestoreRef.addSnapshotListener { (snapshot, error) in
if error != nil {
//error retrieving documents
print (error!.localizedDescription)
} else {
// document retrival successful
guard let snapshot = snapshot else { return }
for document in snapshot.documents {
let data = document.data()
let ageRestriction = data["AgeRestriction"] as? String ?? "Age E"
let category = data["Category"] as? String ?? "Error - No Category"
let date = data["Date"] as? Date ?? Date()
let documentId = data["DocumentID"] as? String ?? "Error - No Document-ID"
let description = data["Description"] as? String ?? "Error - No Description"
let location = data["Location"] as? String ?? "Error - No Location"
let title = data["Title"] as? String ?? "Error - No Title"
let userId = data["UserID"] as? String ?? "Error - No User-ID"
let username = data["Username"] as? String ?? "Anonymous"
let color = data["Color"] as? String ?? "Sale"
let newPost = Post(documentIDText: documentId, usernameText: username, titleText: title, locationText: location, dateText: date, descriptionText: description, ageText: ageRestriction, category: category, uid: userId, color: color)
self.posts.append(newPost)
}
if self.posts.isEmpty {self.goFollowPeopleImage.isHidden = false}
}
}
}
There are two basic patterns:
When dealing with RESTful network requests, we give all of our network routines a completion handler closure, which we call when the network request is done. That way, the caller can invoke each subsequent step in the completion handler of the prior step.
There are many variations on this theme (asynchronous Operation subclasses, futures/promises, etc), but the idea is the same, namely chaining a series of asynchronous tasks together in such a way that the caller can know when the requests are all done and can trigger the UI update.
On the other hand, when dealing with Firestore, we can add observers/listeners to update our UI as updates come in. The addSnapshotListener closure is repeatedly called as the underlying database is updated. In this scenario there isn’t a “ok, we’re done, update the UI” point in time (so we wouldn’t generally use the completion handler approach), but rather we just continually update the UI as the documents come in.
But while your example is using addSnapshotListener, it also is using the limit(to:), which adds a wrinkle. It’s a bit like the first scenario (e.g., if you’re limited to 8, and you retrieved 8, the listener won’t get called again). But it’s also a bit like the second scenario (e.g., if limiting to 8 and you currently have only 7 posts, it will retrieve the first seven and call that closure; but if another record comes in, it will call the closure again, this time with the 8th document as well).
Trying to handle both limited/paginated responses and listening for realtime updates can get complicated. I might suggest that if you want to make Firestore act like a RESTful service, I might suggest using getDocuments instead of addSnapshotListener, eliminating this complexity. Then you can use the completion handler approach recommended by others. It makes it behave a bit like the RESTful approach (but, then again, you lose the realtime update feature).
In case you’re wondering what the realtime, second scenario might look like, here is a simplified example (my post only has “text” and “date” properties, but hopefully it’s illustrative of the process):
func addPostsListener() {
db.collection("posts").addSnapshotListener { [weak self] snapshot, error in
guard let self = self else { return }
guard let snapshot = snapshot, error == nil else {
print(error ?? "Unknown error")
return
}
for diff in snapshot.documentChanges {
let document = diff.document
switch diff.type {
case .added: self.add(document)
case .modified: self.modify(document)
case .removed: self.remove(document)
}
}
}
}
func add(_ document: QueryDocumentSnapshot) {
guard let post = post(for: document) else { return }
let indexPath = IndexPath(item: self.posts.count, section: 0)
posts.append(post)
tableView.insertRows(at: [indexPath], with: .automatic)
}
func modify(_ document: QueryDocumentSnapshot) {
guard let row = row(for: document) else { return }
guard let post = post(for: document) else { return }
posts[row] = post
tableView.reloadRows(at: [IndexPath(row: row, section: 0)], with: .automatic)
}
func remove(_ document: QueryDocumentSnapshot) {
guard let row = row(for: document) else { return }
posts.remove(at: row)
tableView.deleteRows(at: [IndexPath(row: row, section: 0)], with: .automatic)
}
func row(for document: QueryDocumentSnapshot) -> Int? {
posts.firstIndex {
$0.id == document.documentID
}
}
func post(for document: QueryDocumentSnapshot) -> Post? {
let data = document.data()
guard
let text = data["text"] as? String,
let timestamp = data["date"] as? Timestamp
else {
return nil
}
return Post(id: document.documentID, text: text, date: timestamp.dateValue())
}
But this approach works because I’m not limiting the responses. If you do use limit(to:) or limit(toLast:), then you’ll stop getting realtime updates when you hit that limit.
I am trying to append new data to Tableview and only reload the Tableview for the new data added.
My app has been crashing giving me this error: "attempt to delete row 19 from section 0 which only contains 10 rows before the update'"
I would like to reload those rows once the asynchronous function contentQueryContinuous() is completed.
Here is my code:
//Once User Scrolls all the way to bottom, beginContentBatchFetch() is called
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentSize.height
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.height {
beginContentBatchFetch()
}
}
//Content Batch Fetch looks up to 10 new elements, tries to append to current array's, and then reload's tableview only for those new elements
func beginContentBatchFetch() {
contentFetchMore = true
ProgressHUD.show()
let oldcount = contentObjectIdArray.count
var IndexPathsToReload = [IndexPath]()
var startIndex = Int()
var endIndex = Int()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
//Calls self.contentQueryContinous which adds new elements
self.contentQueryContinous()
let newElements = self.contentObjectIdArray.count - oldcount
startIndex = oldcount
endIndex = self.contentObjectIdArray.count
for index in startIndex..<endIndex {
let indexPath = IndexPath(row: index, section: 0)
IndexPathsToReload.append(indexPath)
}
if newElements > 0 {
self.MyTableView.reloadRows(at: IndexPathsToReload, with: .fade)
}
ProgressHUD.dismiss()
}
}
Here is contentQueryContinous()
func contentQueryContinous() {
if contentObjectIdArray.count != 0 {
contentSkip = contentObjectIdArray.count
let query = PFQuery(className: "ContentPost")
query.whereKey("Spot", equalTo: SpotText)
query.limit = 10
query.skip = contentSkip
query.addDescendingOrder("createdAt")
query.findObjectsInBackground(block: { (objects: [PFObject]?,error: Error?) in
if let objects = objects {
for object in objects {
let ProfileImageFile = object["ProfileImage"] as? PFFileObject
let urlString = ProfileImageFile?.url as! String
if let url = URL(string: urlString) {
let data = try? Data(contentsOf: url)
if let imageData = data {
self.contentPostProPicUrlArray.append(urlString as NSString)
self.contentPostProPicImageCache.setObject(UIImage(data: imageData)!, forKey: urlString as NSString)
}
}
if object["Post"] != nil && object["UserLikes"] != nil && object["Username"] != nil && object["UserTime"] != nil {
self.contentPostArray.append(object["Post"] as! String)
self.contentLikeArray.append(object["UserLikes"] as! Int)
self.contentUsernameArray.append(object["Username"] as! String)
self.contentTimeArray.append(object["UserTime"] as! String)
self.contentObjectIdArray.append(object.objectId!)
}
}
print(self.contentPostArray)
}
})
}
}
You are trying to reload a cell that is not on the table. Try inserting it. Also, you are risking a race condition.
From the docs
Reloading a row causes the table view to ask its data source for a new cell for that row. The table animates that new cell in as it animates the old row out. Call this method if you want to alert the user that the value of a cell is changing.
I have a request that update array (append there a new instance if it exist) of new incoming message
#objc
private func loadNewThread() {
func refreshThreads(newThreads: [ThreadModel]) {
var oldThreads: [ThreadModel] = self.threads
var updatedThreads: [ThreadModel] = []
for new in newThreads {
updatedThreads.append(new)
if let index = oldThreads.firstIndex(where: { $0.threadId == new.threadId }) {
oldThreads.remove(at: index)
}
}
updatedThreads.append(contentsOf: oldThreads)
self.threads = updatedThreads
DispatchQueue.main.async {
self.updateEmptyState()
self.tableView.reloadData()
}
}
messageService.getThreads(searchText: searchBar.text?.lowercased(), page: 0) { (result) in
switch result {
case .value(let newThreads):
refreshThreads(newThreads: newThreads)
case .error(let errors):
self.handleErrors(errors: errors)
}
}
}
I update it in viewDidLoad by Timer every 5 sec
override func viewDidLoad() {
super.viewDidLoad()
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true, block: { [weak self] (timer) in
guard let self = self else {
timer.invalidate()
return
}
self.loadNewThread()
})
}
I am removing element from array and row respectively by element index
if let index = self.threads.firstIndex(where: { $0.threadId == thread.threadId }) {
let cellIndex = self.threads.firstIndex(where: { $0.threadId == thread.threadId })
self.threads.remove(at: index)
if let cellIndex = cellIndex {
self.tableView.beginUpdates()
self.tableView.deleteRows(at: [IndexPath.init(row: cellIndex, section: 0)], with: .automatic)
self.tableView.endUpdates()
self.animateLayout()
}
But after deleting, the deleted cell appears at the end of the tableView list.
I suppose that the request to delete element fulfills after the request to append to the array
How should I make my cell disappear?
We are facing issues while integrating coredata with our chat application. Please help us to resolve the issue. We tried to figure out each issue individually but sometimes it gets fixed and then shows up randomly. We are tring to fix it from last 1 week.
Our setup stack
We are using sockets library to for real time chatting. To persist the data we are using core-data. Our application is supporting iOS 8 and above so we can't use PersistenceContainer so to workoround this we are using BNRCoreDataStack [url: https://github.com/bignerdranch/CoreDataStack] which is similiar to what PersistenceContainer does.
Also to display chat we are using IGListKit and we have created viewModels to avoid sending mutable coredata objects to IGLIstkit as IGListkit works fine with immutable model. Also we have used this setup to create our own viewModels [url: https://github.com/Instagram/IGListKit/blob/master/Guides/Working%20with%20Core%20Data.md]
issues we are facing
1] Constraint validation failure
2] FetchResult controller crash issue
crash-log:
2018-07-19 21:41:36.515153+0530 Toppr Doubts[62803:2359707] *** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3698.54.4/UITableView.m:2012
2018-07-19 21:41:36.517093+0530 Toppr Doubts[62803:2359707] [error] error: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (60) must be equal to the number of rows contained in that section before the update (50), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)
CoreData: error: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (60) must be equal to the number of rows contained in that section before the update (50), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)
3] Illegal attempt to establish a relationship 'lastMessage' between objects in different contexts
Below is our CoreDataModel, we are not using any abstract entity for Message as we came to know this could cause lot of performance issues.
Session Entity
We are creating object in fromJSON method and setup is same for rest of the entities. Also I am sharing LastMessage Entity to show the way we are integrating relationships, again same for others
public class Session: NSManagedObject {
class func fromJSON(_ json: JSON, moc: NSManagedObjectContext) -> Session? {
if let entityDescription = NSEntityDescription.entity(forEntityName: "Session", in: moc) {
// Object creation
let object = Session(entity: entityDescription, insertInto: moc)
object.internalIdentifier = json[kSessionIdKey].int64Value
if let date = json[kSessionStartedOnKey].dateTime as NSDate? {
object.startedOn = date
}
if let date = json[kSessionEndedOnKey].dateTime as NSDate? {
object.endedOn = date
}
object.statusType = SessionStatus(rawValue: json[kSessionStatusKey].stringValue).map { $0.rawValue } ?? SessionStatus.none.rawValue
object.stateType = SessionState(rawValue: json[kSessionStateKey].stringValue).map { $0.rawValue } ?? SessionState.none.rawValue
if let ratingDict = json[kSessionRatingKey].dictionaryObject {
if let rating = ratingDict["student"] as? Int {
object.rating = rating
}
}
object.subjectID = json[kSessionSubjectKey]["id"].intValue
// Subjects are already stored need to fetch and assign
let subjectFetchRequest: NSFetchRequest<Subject> = Subject.fetchRequest()
subjectFetchRequest.predicate = NSPredicate(format: "internalIdentifier == %d", Int64(json[kSessionSubjectKey]["id"].intValue))
do {
if let subject = try moc.fetch(subjectFetchRequest).first {
object.subject = subject
}
} catch let erroe as NSError {
Logger.log.error(error)
}
// Student Object initialisation
if json[kSessionStudentKey] != JSON.null {
if let student = Student.fromJSON(json[kSessionStudentKey], moc: moc) {
object.student = student
}
}
// Tutor Object initialisation
if json[kSessionTutorKey] != JSON.null {
if let tutor = Tutor.fromJSON(json[kSessionTutorKey], moc: moc) {
object.tutor = tutor
}
}
// LastMessage Object initialisation
if json[kSessionLastMessageKey] != JSON.null {
if let lastMessage = LastMessage.fromJSON(json[kSessionLastMessageKey], moc: moc) {
object.lastMessage = lastMessage
} else {
return nil
}
}
return object
}
return nil
}
}
LastMessage
public class LastMessage: NSManagedObject, Message {
class func fromJSON(_ json: JSON, moc: NSManagedObjectContext) -> LastMessage? {
if let entityDescription = NSEntityDescription.entity(forEntityName: "LastMessage", in: moc) {
let object = LastMessage(entity: entityDescription, insertInto: moc)
object.id = json[kMessageIdKey].intValue
object.body = json[kBodyKey].stringValue
object.type = MessageType(rawValue: json[kTypeKey].stringValue) ?? MessageType.none
object.doubtTag = json[kDoubtTagKey].stringValue
if json[kAttachmentKey] != JSON.null {
if let attachment = Attachment.fromJSON(json[kAttachmentKey], moc: moc) {
object.attachment = attachment
}
}
if let date = json[kSentOnKey].dateTime as NSDate? {
object.sentOn = date
}
if json[kSentByKey] != JSON.null {
if let sentBy = SentBy.fromJSON(json[kSentByKey], moc: moc) {
object.sentBy = sentBy
}
}
object.deliveryState = DeliveryState(rawValue: json[kDeliveryStateKey].stringValue) ?? DeliveryState.none
object.sessionId = json[kSessionIdKey].intValue
return object
}
return nil
}
}
Get User State
Helps us fetch data for subject and live chat.
static func getUserState(completion:#escaping (_ success: Bool) -> Void) {
SocketManager.sharedInstance.send(eventName: .userState) { (response) in
guard !response.isError() else { return completion(false) }
// Coredatastack
guard let coreDataStack = (UIApplication.shared.delegate as! AppDelegate).coreDataStack else { return }
let wmoc = coreDataStack.newChildContext()
// Save Subject and Live Sessions
let subjects = response.result["subjects"].arrayValue.flatMap({ Subject.fromJSON($0, moc: wmoc) })
let sessions = response.result["live_sessions"].arrayValue.flatMap({ Session.fromJSON($0, moc: wmoc) })
if sessions.isNotEmpty || subjects.isNotEmpty {
CoreDataStack.batchUpdate(moc: wmoc, completion: {
NotificationCenter.default.post(name: NSNotification.Name("didUpdateUserState"), object: nil)
completion(true)
})
}
completion(false)
}
}
Get Previous Session
Helps us fetch data for inactive chats. We are getting problem in this while storing sessions
static func getPreviousSessions(isLoadingMore: Bool, completion: #escaping (_ success: Bool,_ isMore: Bool)->Void) {
guard let coreDataStack = (UIApplication.shared.delegate as! AppDelegate).coreDataStack else { return }
let wmoc = coreDataStack.newChildContext()
var sessionID = 0
// TODO: - Need to implement last sessionID from CoreData
if isLoadingMore {
// Get Sessions with Status == closed order by sessionID asc
let archiveSession = Session.filterArchivedSessions(moc: wmoc)
let sortedArchiveSession = archiveSession?.sorted(by: { $0.0.id < $0.1.id })
// Get last sessionID
if let lastSessionID = sortedArchiveSession?.first?.id {
sessionID = lastSessionID
}
}
let request: [String: Any] = [ "last_session_id": sessionID ]
SocketManager.sharedInstance.send(request, eventName: .getPreviousSessions) { (response) in
if response.result.isEmpty {
completion(false, false)
} else {
let sessions = response.result["sessions"].arrayValue.flatMap({ Session.fromJSON($0, moc: wmoc) })
if sessions.isNotEmpty {
CoreDataStack.batchUpdate(moc: wmoc)
} else {
for session in sessions { wmoc.delete(session) }
}
if let isMore = response.result["is_more_server"].bool {
completion(true, isMore)
}
}
}
}
In the above image every session is suppose to have one last message and one subject. But as you can see, there are no last messages and some session have subjects as null
CoreDataStack+Extension
To Save data directly to Store
extension CoreDataStack {
// This method will add or update a CoreData's object.
static func batchUpdate(moc: NSManagedObjectContext? = nil, completion: (()-> Void)? = nil) {
guard let moc = moc, moc.hasChanges else { return }
if #available(iOS 10.0, *) {
moc.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
}
do {
try moc.performAndWaitOrThrow {
try moc.saveContextToStoreAndWait()
DispatchQueue.main.async {
completion?()
}
}
} catch {
print("Error creating initial data: \(error)")
}
}
}
NSFetchedResultsController Setup
lazy var archievedSessionFRC: NSFetchedResultsController<Session> = {
guard let coreDataStack = (UIApplication.shared.delegate as! AppDelegate).coreDataStack else { return NSFetchedResultsController() }
// Create Fetch Request
let fetchRequest: NSFetchRequest<Session> = Session.fetchRequest()
// Configure Fetch Request
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "internalIdentifier", ascending: false)]
fetchRequest.predicate = NSPredicate(format: "statusType = %#", "closed")
let archievedSessionFRC = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: coreDataStack.mainQueueContext,
sectionNameKeyPath: nil,
cacheName: nil)
archievedSessionFRC.delegate = self
return archievedSessionFRC
}()
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
do {
try archievedSessionFRC.performFetch()
if let sessions = archievedSessionFRC.fetchedObjects {
self.previousSessions = sessions
}
} catch {
let fetchError = error as NSError
print("\(fetchError), \(fetchError.localizedDescription)")
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension HomeVC: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.beginUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .insert:
if let indexPath = newIndexPath {
self.tableView.insertRows(at: [indexPath], with: .automatic)
}
case .delete:
if let indexPath = indexPath {
self.tableView.deleteRows(at: [indexPath], with: .automatic)
}
case .move:
if let indexPath = indexPath , let newIndexPath = newIndexPath {
self.tableView.moveRow(at: indexPath, to: newIndexPath)
}
case .update:
if let indexPath = indexPath {
self.tableView.reloadRows(at: [indexPath], with: .automatic)
}
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.endUpdates()
}
}
Thanks in Advance