Populating UITableView from Firestore with query cursors (paging) - swift

I have a UITableView subclass that I am populating from a Firestore collection. I only grab 20 documents at a time, and use the UITableViewDataSourcePrefetching delegate to get the next "page" of 20 documents from Firestore when the user nears the end of the loaded array.
The following is my code that almost perfectly achieves this, omitting/obfuscating where appropriate.
class MyCustomViewController: UIViewController {
#IBOutlet weak var myCustomTableView: MyCustomTableView!
override func viewDidLoad() {
super.viewDidLoad()
myCustomTableView.query = Firestore.firestore().collection("MyCollection").whereField("foo", isEqualTo: "bar").order(by: "timestamp", descending: true)
myCustomTableView.fetchNextPage()
}
}
extension MyCustomViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
guard let tableView = tableView as? MyCustomTableView else {
return
}
tableView.prefetch(indexPaths: indexPaths)
}
}
class MyCustomTableView: UITableView {
var documents = [DocumentSnapshot]()
var query: Query?
let querySize = 20
private var fetchSemaphore = DispatchSemaphore(value: 1)
public func prefetch(indexPaths: [IndexPath]) {
for indexPath in indexPaths {
if indexPath.section >= documents.count - 1 {
fetchNextPage()
return
}
}
}
public func fetchNextPage() {
guard let query = query else {
return
}
guard documents.count % querySize == 0 else {
print("No further pages to fetch.")
return
}
guard fetchSemaphore.wait(timeout: .now()) == .success else { return }
if self.documents.isEmpty {
query.limit(to: querySize).addSnapshotListener { (snapshot, error) in
guard let snapshot = snapshot else {
return
}
self.documents.append(contentsOf: snapshot.documents)
self.reloadData()
self.fetchSemaphore.signal()
}
}
else {
// I think the problem is on this next line
query.limit(to: querySize).start(afterDocument: documents.last!).addSnapshotListener { (snapshot, error) in
guard let snapshot = snapshot else {
return
}
for document in snapshot.documents {
if let index = self.documents.firstIndex(where: { $0.documentID == document.documentID }) {
self.documents[index] = document
}
else {
self.documents.append(document)
}
}
self.reloadData()
self.fetchSemaphore.signal()
}
}
}
}
I think I know what/where the problem is (see comment in fetchNextPage()), but I may be wrong. Even if I am correct, I have not been able to come up with a way to fix it.
My query is sorted, in descending order, by the documents' timestamp value which, as the name suggests, represents the time at which the document was created. This means that, in the table view, the newest documents will appear at the top, and the oldest at the bottom.
Everything works great, except for...
The problem: When a new document is created, every item in the table gets shunted down a row because the new document has the newest timestamp and gets placed at the top. Great, except that when this happens, all of the query snapshot listeners (except for the first one) are no longer using the correct document to start after. The document snapshot originally retrieved with documents.last! is no longer the correct document that the query should start after. I do not know how to change the startAfter parameter of an existing snapshot listener. I could not find any method belonging to the query that I could use to alter this.
Again, this might not actually be the reason I'm seeing things out of order when new documents are added, but I think it is. If anyone has advice for how to resolve this I'd greatly appreciate it.
References I have used to get here:
https://firebase.google.com/docs/firestore/query-data/query-cursors
https://www.raywenderlich.com/5786-uitableview-infinite-scrolling-tutorial
Additional notes:
Even though it'd be much easier and solve this problem, I do not want to use getDocuments on the query. Having the near-real-time updates is important in this case.
I cannot use FirebaseUI because my use-case requires functionality not yet available. For example, you may have noticed that I used indexPath.section instead of indexPath.row, which is because the table is made up of many single-rowed sections so that vertical padding can be put between cells.

Related

Display paginated results while observing changes in Realm Swift

I have a typical messaging app where messages are stored as Realm objects. I want to display messages of a conversation in a collection/table view in a safe way observing the results
let results = realm.objects(Message.self).filter(predicate)
// Observe Results Notifications
notificationToken = results.observe { [weak self] (changes: RealmCollectionChange) in
guard let tableView = self?.tableView else { return }
switch changes {
case .initial:
// Results are now populated and can be accessed without blocking the UI
tableView.reloadData()
case .update(_, let deletions, let insertions, let modifications):
// Query results have changed, so apply them to the UITableView
tableView.beginUpdates()
tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
with: .automatic)
tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.endUpdates()
case .error(let error):
// An error occurred while opening the Realm file on the background worker thread
fatalError("\(error)")
}
}
Now, this would work assuming that I display all messages. Since they can be a lot I need to load paginated.
How can I track changes then?
I'm searching for a method to get a sort of id of the changed message, but I couldn't find anything.
Realm objects are lazily loaded so they don't 'take up space' until they are accessed. In our case we have have 1000 objects in results but only display 10 at a time. Those are the 10 that 'take up space'. So it may not be an issue to have a large results dataset.
When you populate a results object from Realm, each object has an index. Think of a results as an array. The first object is index 0, the second object in index 1 etc.
When an object is modified in realm, that information is passed to your observer to which you can then update your UI.
Say we have a PersonClass Realm object that has a persons name and email
PersonClass: Object {
#objc dynamic var name = ""
#objc dynamic var email = ""
}
and we want to display a list of people, and if an email address changes we want to update the UI with that change.
When the app starts, we load all of the people into a Results class var.
override func viewDidLoad() {
super.viewDidLoad()
self.personResults = realm.objects(PersonClass.self)
Then we add an observer to those results so the app is notified of changes. .initial will run when the results have been loaded so it's a good place to populate your dataSource and refresh your tableView.
func doObserve() {
self.notificationToken = self.personResults!.observe { (changes: RealmCollectionChange) in
switch changes {
case .initial: // initial object load complete
if let results = self.personResults {
for p in results {
print(p.name, p.email) //populate datasource, refresh
}
}
break
case .update(_, let deletions, let insertions, let modifications):
for index in deletions {
print(" deleted object at index: \(index)")
}
for index in insertions {
print(" inserted object at index: \(index)")
}
for index in modifications {
print(" modified object at index: \(index)")
let person = self.personResults![index]
print("modified item: \(person.name) \(person.email)")
}
break
case .error(let error):
fatalError("\(error)")
break
}
}
}
In this example, when an person stored at index #2 changes his email, the observer responds to that and prints the name and the new email to console.
But...
Realm is live updating and if you refresh your tableView or even just that row so the cell re-loads from the object, the UI will be updated. I don't know what 'How can I track changes then?' means in your use case, but you could actually remove all of the for loops and just have a tableView.reloadData in the .update section and the UI will be updated as data changes. Or, you could use the index and just update that row.
Keep in mind Realm objects in Results are live and always stay fresh as data changes.
Another note is that many Realm objects will have a unique identifier for the object, defined like this
class PersonClass: Object {
#objc dynamic var person_id = UUID().uuidString
override static func primaryKey() -> String? {
return "person_id"
}
}
which can be used to access that specific object within Realm, but not directly related to your question.

SearchBar problem while trying to search Firestore and reload the tableview

I have a tableView and I use infinite scroll to populate firestore data with batches. Also I have a searched bar and I am trying to query firestore with the text from the text bar and then populate it in the tableview. I have 3 main problems.
When I click search thee first time I get an empty array and an empty tableview, but when I click search the second time everything seems fine.
When I finally populate the searched content I want to stop fetching new content while I am scrolling.
If I text a wrong word and press search then I get the previous search and then the "No Ingredients found" printed twice.
This is my code for searchBar:
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let text = searchBar.text else {return}
searchIngredients(text: text)
self.searchBarIngredient.endEditing(true)
print("\(searchIngredients(text: text))")
}
The code for function when I click search
func searchIngredients(text: String) -> Array<Any>{
let db = Firestore.firestore()
db.collection("Ingredients").whereField("compName", arrayContains: text).getDocuments{ (querySnapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
print("Test Error")
} else {
if (querySnapshot!.isEmpty == false){
self.searchedIngredientsArray = querySnapshot!.documents.compactMap({Ingredients(dictionary: $0.data())})
}else{
print("No Ingredients found")
}
}
}
self.tableView.reloadData()
ingredientsArray = searchedIngredientsArray
return ingredientsArray
}
Finally the code for scrolling
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let off = scrollView.contentOffset.y
let off1 = scrollView.contentSize.height
if off > off1 - scrollView.frame.height * leadingScreensForBatching{
if !fetchMoreIngredients && !reachEnd{
beginBatchFetch()
}
}
}
I don't write the beginBatchFetch() cause its working fine and I don't think is relevant.
Thanks in advance.
The issue in your question is that Firestore is asynchronous.
It takes time for Firestore to return documents you've requested and that data will only be valid within the closure calling the function. The code outside the closure will execute way before the data is available within the closure.
So here's what's going on.
func searchIngredients(text: String) -> Array<Any>{
let db = Firestore.firestore()
db.collection("Ingredients").whereField("compName", arrayContains: text).getDocuments{ (querySnapshot, err) in
//the data has returned from firebase and is valid
}
//the code below here will execute *before* the code in the above closure
self.tableView.reloadData()
ingredientsArray = searchedIngredientsArray
return ingredientsArray
}
what's happening is the tableView is being refreshed before there's any data in the array.
You're also returning the ingredientsArray before it's populated. More importantly, attempting to return a value from an asynchronous function can (and should) generally be avoided.
The fix is to handle the data within the closure
class ViewController: NSViewController {
var ingredientArray = [String]()
func searchIngredients(text: String) {
let db = Firestore.firestore()
db.collection("Ingredients").whereField("compName", arrayContains: text).getDocuments{ (querySnapshot, err) in
//the data has returned from firebase and is valid
//populate the class var array with data from firebase
// self.ingredientArray.append(some string)
//refresh the tableview
}
}
Note that the searchIngredients function should not return a value - nor does it need to

FireStore Swift: Accessing counts and document details for use in a table

The code from FireStore Swift 4 : How to get total count of all the documents inside a collection, and get details of each document? is correctly printing to the console when called in the viewDidLoad part of my TableViewController. However, I am trying to use "count" for the number of rows, and pick out some of the document details to display in the table.
The problem I am having now is that these details seem locked in this QuerySnapshot call, and I can't access them in numberOfRowsInSection and CellForRowAt. I'm using version 10 of xcode. Sorry if this is a noob question, but I've been stumped for a while.
var count = 0
override func viewDidLoad() {
super.viewDidLoad()
let reviewTopic = Firestore.firestore().collection("usersIds").document(userEmail!).collection("reviews")
reviewTopic.getDocuments() {
(QuerySnapshot,err) in
if let err = err {
print("Error getting documents: \(err)");
} else {
for document in QuerySnapshot!.documents {
self.count += 1
print("\(document.documentID) => \(document.data())");
}
print("Count = \(self.count)");
}
print("Count from viewDidLoad: ", self.count) // prints 2
}
}
Again, the above returns the correct counts (2) and document details, but I am trying to use the value in numberOfRowsInSection, which runs before viewDidLoad and returns 0.
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
print("Count from numrows: ", self.count)
return self.count //returns value of 0
}
You forget to reload your UITableView once you got the data from the server so you need to reload your UITableView with:
self.tableView.reloadData()
After the for loop. Because it's an asynchronous call where you need to wait until you got the data from the server.
And your code will look like:
override func viewDidLoad() {
super.viewDidLoad()
let reviewTopic = Firestore.firestore().collection("usersIds").document(userEmail!).collection("reviews")
reviewTopic.getDocuments() {
(QuerySnapshot,err) in
if let err = err {
print("Error getting documents: \(err)");
} else {
for document in QuerySnapshot!.documents {
self.count += 1
print("\(document.documentID) => \(document.data())");
}
print("Count = \(self.count)");
self.tableView.reloadData()
}
print("Count from viewDidLoad: ", self.count) // prints 2
}
}
One more thing you should create a class object and store all the data into a class object and then access that data into your cellforrow and numberofrows method. Or you can use Codable protocol to parse your JSON from server and store the server data.
And with that you can easily manage your UITableView datasource and you can also find out which cell is pressed easily.

Filter a tableview with UISearchResultsUpdating

I have a very large array of data that I use a searchbar to filter. However I would like to filter only exactly that which the user enters in the searchbar. I.e. if you type "b" you only get items starting with b. The list is more than 200,000 items so now even with many letters entered the results showing are often irrelevant. I have tried searching a lot but every guide I have found is a variation of what I have :
extension MapTableController : UISearchResultsUpdating {
func filterContentForSearchText(_ searchText: String) {
if(searchText == "") {
matchingItems = []
}
else {
matchingItems = arrayOfFixes.filter { fix in
return fix.lowercased().contains(searchText.lowercased())
}
tableView.reloadData()
}
}
func updateSearchResults(for searchController: UISearchController) {
let searchText = searchController.searchBar.text
filterContentForSearchText(searchText!)
self.tableView.reloadData()
}
}
I understand that using "contains" will have the above mentioned effect. But I can't figure out how to filter it in the desired way. For example I would like it so that if I enter "Ben" it will show results like "Bendi", "Benhu", "Benji" etc. Not so that it shows results like "Juben", "Ibeno" etc, you get the picture.
Thanks for any help!
You can use 'hasPrefix' instead of 'contains' for your filter:
return fix.lowercased().hasPrefix(searchText.lowercased())

How to display CloudKit RecordType instances in a tableview controller

To my knowledge, the following code (or very close to it) would retrieve one cloudkit instance from the recordtype array...
let pred = NSPredicate(value: true)
let query = CKQuery(recordType: "Stores", predicate: pred)
publicDatabase.performQuery(query, inZoneWithID: nil) { (result, error) in
if error != nil
{
print("Error" + (error?.localizedDescription)!)
}
else
{
if result?.count > 0
{
let record = result![0]
dispatch_async(dispatch_get_main_queue(), { () -> Void in
self.txtDesc.text = record.objectForKey("storeDesc") as? String
self.position = record.objectForKey("storeLocation") as! CLLocation
let img = record.objectForKey("storeImage") as! CKAsset
self.storeImage.image = UIImage(contentsOfFile: img.fileURL.path!)
....(& so on)
However, how and when (physical location in code?) would I query so that I could set each cell to the information of each instance in my DiningType record?
for instance, would I query inside the didreceivememory warning function? or in the cellforRowatIndexPath? or other!
If I am misunderstanding in my above code, please jot it down in the notes, all help at this point is valuable and extremely appreciated.
Without a little more information, I will make a few assumptions about the rest of the code not shown. I will assume:
You are using a UITableView to display your data
Your UITableView (tableView) is properly wired to your viewController, including a proper Outlet, and assigning the tableViewDataSource and tableViewDelegate to your view, and implementing the required methods for those protocols.
Your data (for each cell) is stored in some type of collection, like an Array (although there are many options).
When you call the code to retrieve records from the database (in this case CloudKit) the data should eventually be stored in your Array. When your Array changes (new or updated data), you would call tableView.reloadData() to tell the tableView that something has changed and to reload the cells.
The cells are wired up (manually) in tableView(:cellForRowAtIndexPath:). It calls this method for each item (provided you implemented the tableView(:numberOfRowsInSection:) and numberOfSectionsInTableView(_:)
If you are unfamiliar with using UITableView's, they can seem difficult at first. If you'd like to see a simple example of wiring up a UITableView just let me know!
First, I had to take care of the typical cloudkit requirements: setting up the container, publicdatabase, predicate, and query inputs. Then, I had the public database perform the query, in this case, recordtype of "DiningType". Through the first if statement of the program, if an error is discovered, the console will print "Error" and ending further action. However, if no run-time problem is discovered, each result found to be relatable to the query is appended to the categories array created above the viewdidload function.
var categories: Array<CKRecord> = []
override func viewDidLoad() {
super.viewDidLoad()
func fetchdiningtypes()
{
let container = CKContainer.defaultContainer()
let publicDatabase = container.publicCloudDatabase
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "DiningType", predicate: predicate)
publicDatabase.performQuery(query, inZoneWithID: nil) { (results, error) -> Void in
if error != nil
{
print("Error")
}
else
{
for result in results!
{
self.categories.append(result)
}
NSOperationQueue.mainQueue().addOperationWithBlock( { () -> Void in
self.tableView.reloadData()
})
}
}
}
fetchdiningtypes()