Deleted cell appears at the end of tableView list - swift

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?

Related

cellProvider doesn't update after section removal when using UITableViewDiffableDataSource

I have some code which loads in some empty sections and items. Then if there is a network connection some calls are made for the visible cells. I have a button in each section which navigates to another view based on the section type, which uses the indexPath. My problem is that when I remove the fist section the cellProvider doesn't update the indexPath and button navigates to the wrong view. If I scroll however the problem goes away. How do I make sure the indexPath updates? EDIT: It appears that my headerViews are retaining their old indexPath, but the cellProvider correctly updates. The header views are set to the correct indexPath once you scroll away and come back.
private enum RowItemType: Hashable {
case itemTypeOne(collection: MyCollection)
case itemTypeTwo(collection: MyCollection)
case itemTypeThree(collection: OtherCollection)
}
private enum SectionType: Hashable {
case sectionOne
case sectionTwo
case sectionThree
}
class MyCollection: Codable, Hashable, Equatable {
var identifier = UUID()
var foos: [Foo]
init(foos: [Foo] = [Foo]()) {
self.foos = foos
}
// MARK: Equatable
var hash: Int {
var hasher = Hasher()
hasher.combine(identifier)
return hasher.finalize()
}
static func == (lhs: MyCollection, rhs: MyCollection) -> Bool {
lhs.identifier == rhs.identifier
}
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}
class OtherCollection: Codable, Hashable, Equatable {
var identifier = UUID()
var bars: [Bar]
init(bars: [Bar] = [Bar]()) {
self.bars = bars
}
// MARK: Equatable
var hash: Int {
var hasher = Hasher()
hasher.combine(identifier)
return hasher.finalize()
}
static func == (lhs: OtherCollection, rhs: OtherCollection) -> Bool {
lhs.identifier == rhs.identifier
}
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}
final class SomeTableViewController: UITableVIewController {
...code....
RELEVENT CODE
private func makeDataSource() -> UITableViewDiffableDataSource<SectionType, RowItemType> {
UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, item in
guard let self = self else {
return UITableViewCell()
}
guard let marginTableView = tableView as? AutoMarginTableView else {
return UITableViewCell()
}
switch item {
case .itemTypeOne(collection: let collection):
let carousel = self.itemOneCarouCell(from: marginTableView, withSection: .sectionOne, atIndexPath: indexPath)
if !collection.foos.isEmpty {
carousel.configureCell(with: .sectionOne, fooCollection: collection)
self.showViewAllButtonForHeader(collection.foos.count > 3, atIndexPath: indexPath)
} else {
carousel.configureCellForLoading()
self.getFoosTypeOne(collection: collection)
}
return carousel
case .itemTypeTwo(let collection):
let carousel = self.itemTwoCarouCell(from: marginTableView, withSection: .sectionTwo, atIndexPath: indexPath)
if !collection.foos.isEmpty {
carousel.configureCell(with: .sectionTwo, fooCollection: collection)
self.showViewAllButtonForHeader(collection.foos.count > 3, atIndexPath: indexPath)
} else {
carousel.configureCellForLoading()
self.getFoosTypeTwo(collection: collection)
}
return carousel
case .ttemTypeThree(let collection):
let carousel = self.itemThreeCarouCell(from: marginTableView, withSection: .sectionThree, atIndexPath: indexPath)
if !collection.bars.isEmpty {
carousel.configureCell(with: collection)
self.showViewAllButtonForHeader(true, atIndexPath: indexPath)
} else {
carousel.configureCellForLoading()
self.getBars(collection: collection)
}
return carousel
})
}
private func showViewAllButtonForHeader(_ show: Bool, atIndexPath indexPath: IndexPath) {
if let headerView = self.tableView.headerView(forSection: indexPath.section) as? MyTableHeaderView {
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseInOut, animations: {
headerView.showViewAll(show)
}, completion: nil)
}
}
private func getFoosTypeOne(collection: MyCollection) {
var snapshot = dataSource.snapshot()
if FeatureFlags.isFeatureEnabled && snapshot.sectionIdentifiers.contains(.sectionOne) {
someRepositry.getfoos { result in
if case .success(let foos) = result {
if !foos.isEmpty {
collection.foos = foos
snapshot.reloadItems([.itemTypeOne(collection: collection)])
} else {
if snapshot.sectionIdentifiers.contains(.sectionOne) {
snapshot.deleteSections([.sectionOne])
}
}
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
}
private func getFoosTypeTwo(collection: MyCollection) {
var snapshot = dataSource.snapshot()
if FeatureFlags.isFeatureEnabled && snapshot.sectionIdentifiers.contains(.sectionTwo) {
someRepositry.getDifferentFoos { result in
if case .success(let foos) = result {
if !foos.isEmpty {
collection.foos = foos
snapshot.reloadItems([.itemTypeTwo(collection: collection)])
} else {
if snapshot.sectionIdentifiers.contains(.sectionTwo) {
snapshot.deleteSections([.sectionTwo])
}
}
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
}
private func getBars(collection: OtherCollection) {
var snapshot = dataSource.snapshot()
someService.getSomeCollection { someColl in
DispatchQueue.main.async {
if let someColl = someColl {
if !someColl.bars.isEmpty {
collection.bars = someColl.bars
snapshot.reloadItems([.itemTypeThree(collection: collection)])
} else {
if snapshot.sectionIdentifiers.contains(.sectionThree) {
snapshot.deleteSections([.sectionThree])
}
}
}
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
}
extension SomeTabelViewController: SomeTableHeaderViewDelegate {
func someTableHeaderViewTappedViewAll(_ headerView: SomeTableHeaderView) {
guard headerView.section < dataSource.numberOfSections(in: tableView) else {
return
}
let snapshot = dataSource.snapshot()
let section = snapshot.sectionIdentifiers[headerView.section]
switch section {
case .sectionOne:
if case .itemTypeOne(let collection) = snapshot.itemIdentifiers(inSection: .sectionOne).first, !collection.foos.isEmpty {
let vc = SomeListViewController()
navigationController?.pushViewController(vc, animated: true)
}
case .sectionTwo:
if case .itemTypeTwo(let collection) = snapshot.itemIdentifiers(inSection: .sectionTwo).first, !collection.foos.isEmpty {
let vc = SomeListViewController()
navigationController?.pushViewController(vc, animated: true)
}
case .sectionThree:
if case .itemTypeThree(let collection) = snapshot.itemIdentifiers(inSection: .sectionThree).first, !collection.bars.isEmpty {
let vc = OtherViewController(collecton: collection)
navigationController?.pushViewController(vc, animated: true)
}
}
}

How do I hide the activity indicator once there is no more data to load?

I am paginating data from Firestore and I am able to get that to work.
Here is the paginating query:
if restaurantArray.isEmpty {
query = db.collection("Restaurant_Data").limit(to: 4)
} else {
query = db.collection("Restaurant_Data").start(afterDocument: lastDocument!).limit(to: 4)
}
query.getDocuments { (querySnapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
} else if querySnapshot!.isEmpty {
self.fetchMore = false
return
} else {
if (querySnapshot!.isEmpty == false) {
let allQueriedRestaurants = querySnapshot!.documents.compactMap { (document) -> Restaurant in (Restaurant(dictionary: document.data(), id: document.documentID)!)}
guard let location = self.currentLocation else { return }
self.restaurantArray.append(contentsOf: self.applicableRestaurants(allQueriedRestaurants: allQueriedRestaurants, location: location))
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
self.tableView.reloadData()
self.fetchMore = false
})
self.lastDocument = querySnapshot!.documents.last
}
}
}
The pagination is triggered when the user drags the table view up:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let off = scrollView.contentOffset.y
let off1 = scrollView.contentSize.height
if off > off1 - scrollView.frame.height * leadingScreensForBatching{
if !fetchMore { // excluded reachedEnd Bool
if let location = self.currentLocation {
queryGenerator(searched: searchController.isActive, queryString: searchController.searchBar.text!.lowercased(), location: location)
}
}
}
}
I also added an activity indicator to the bottom and that works as expected. Here is the code for that:
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let lastSectionIndex = tableView.numberOfSections - 1
let lastRowIndex = tableView.numberOfRows(inSection: lastSectionIndex) - 1
if indexPath.section == lastSectionIndex && indexPath.row == lastRowIndex {
// print("this is the last cell")
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
spinner.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.bounds.width, height: CGFloat(44))
tableView.tableFooterView = spinner
tableView.tableFooterView?.isHidden = false
}
}
However, once the bottom of the table view is reached and there is no more data available, the indicator is still showing and I dont know how to get it to not show.
I tried using a variable to check if the Firestory query is done but my implementation is probably wrong so I cannot get it to work.
If you know the moment when there is no more data available, you set the tableView footer view to nil or hidden = true, see below
tableView.tableFooterView?.isHidden = true
or
tableView.tableFooterView = nil
before you call self.tableView.reloadData()
If you do not know it, see code below
query.getDocuments { (querySnapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
} else if querySnapshot!.isEmpty {
// Here you know when there is no more data
self.fetchMore = false
self.tableView.tableFooterView = nil // or self.tableView.tableFooterView?.isHidden = true
self.tableView.reloadData()
return
} else {
if (querySnapshot!.isEmpty == false) {
let allQueriedRestaurants = querySnapshot!.documents.compactMap { (document) -> Restaurant in (Restaurant(dictionary: document.data(), id: document.documentID)!)}
guard let location = self.currentLocation else { return }
self.restaurantArray.append(contentsOf: self.applicableRestaurants(allQueriedRestaurants: allQueriedRestaurants, location: location))
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
self.tableView.reloadData()
self.fetchMore = false
})
self.lastDocument = querySnapshot!.documents.last
}
}
}

RxDatasource in RxSwift reload animation don't update data source

RxDatasource in RxSwift [RxTableViewSectionedAnimatedDataSource] Reload Animation don't update data source. What mistake I am doing? I am even unable to bind my action with button properly.
TableDataSource and Table editing commands
struct TableDataSource {
var header: String
var items: [Item]
var SectionViewModel: SectionViewModel
}
extension TableDataSource: AnimatableSectionModelType {
var identity: String {
return header
}
type alias Item = Data
init(original: TableDataSource, items: [Item]) {
self = original
self.items = items
self.sectionViewModel = original.sectionViewModel
}
}
enum TableViewEditingCommand {
case deleteSingle(IndexPath)
case clearAll(IndexPath)
case readAll(IndexPath)
}
struct SectionedTableViewState {
var sections: [TableDataSource]
init(sections: [TableDataSource]) {
self.sections = sections
}
func execute(command: TableViewEditingCommand) -> SectionedTableViewState {
var sections = self.sections
switch command {
// Delete single item from datasource
case .deleteSingle(let indexPath):
var items = sections[indexPath.section].items
items.remove(at: indexPath.row)
if items.count <= 0 {
sections.remove(at: indexPath.section)
} else {
sections[indexPath.section] = TableDataSource(
original: sections[indexPath.section],
items: items) }
// Clear all item from datasource with isUnread = false
case .clearAll(let indexPath):
sections.remove(at: indexPath.section)
// Mark all item as read in datasource with isUnread = true
case .readAll(let indexPath):
var items = sections[indexPath.section].items
items = items.map { var unread = $0
if $0.isUnRead == true { unreadData.isUnRead = false }
return unreadData
}
sections.remove(at: indexPath.section)
if sections.count > 0 {
let allReadItems = sections[indexPath.section].items + items
sections[indexPath.section] = TableDataSource(
original: sections[indexPath.section],
items: allReadItems)
}
}
return SectionedTableViewState(sections: sections)
}
}
This is my controller and its extensions
class ViewController: UIViewController, Storyboardable {
#IBOutlet weak var tableView: UITableView!
#IBOutlet weak var closeButton: UIButton!
#IBOutlet weak var titleText: UILabel!
var viewModel: ViewModel!
let disposeBag = DisposeBag()
let sectionHeight: CGFloat = 70
let dataSource = ViewController.dataSource()
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
bindInitials()
bindDataSource()
bindDelete()
}
private func bindInitials() {
tableView.delegate = nil
tableView.rx.setDelegate(self)
.disposed(by: disposeBag)
registerNibs()
}
private func registerNibs() {
let headerNib = UINib.init(
nibName: TableViewSection.identifier,
bundle: nil)
tableView.register(
headerNib,
forHeaderFooterViewReuseIdentifier: TableViewSection.identifier)
}
}
extension ViewController: Bindable {
func bindViewModel() {
bindActions()
}
private func bindDataSource() {
bindDelete()
// tableView.dataSource = nil
// Observable.just(sections)
// .bind(to: tableView.rx.items(dataSource: dataSource))
// .disposed(by: disposeBag)
}
private func bindDelete() {
/// TODO: to check and update delete code to work properly to sink with clear all and mark all as read
guard let sections = self.viewModel?.getSections() else {
return
}
let deleteState = SectionedTableViewState(sections: sections)
let deleteCommand = tableView.rx.itemDeleted.asObservable()
.map(TableViewEditingCommand.deleteSingle)
tableView.dataSource = nil
Observable.of(deleteCommand)
.merge()
.scan(deleteState) {
(state: SectionedTableViewState,
command: TableViewEditingCommand) -> SectionedTableViewState in
return state.execute(command: command) }
.startWith(deleteState) .map { $0.sections }
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
}
private func bindActions() {
guard let openDetailsObserver = self.viewModel?.input.openDetailsObserver,
let closeObserver = self.viewModel?.input.closeObserver else {
return
}
viewModel.output.titleTextDriver
.drive(titleText.rx.text)
.disposed(by: disposeBag)
// viewModel.input.dataSourceObserver
// .mapObserver({ (result) -> [Data] in
// return result
// })
// .disposed(by: disposeBag)
/// Close button binding with closeObserver
closeButton.rx.tap
.bind(to: (closeObserver))
.disposed(by: disposeBag)
/// Tableview item selected binding with openDetailsObserver
tableView.rx.itemSelected
.map { indexPath in
return (self.dataSource[indexPath.section].items[indexPath.row])
}.subscribe(openDetailsObserver).disposed(by: disposeBag)
}
}
extension ViewController: UITableViewDelegate {
static func dataSource() -> RxTableViewSectionedAnimatedDataSource<TableDataSource> {
return RxTableViewSectionedAnimatedDataSource(
animationConfiguration: AnimationConfiguration(insertAnimation: .fade,
reloadAnimation: .fade,
deleteAnimation: .fade),
configureCell: { (dataSource, table, idxPath, item) in
var cell = table.dequeueReusableCell(withIdentifier: TableViewCell.identifier) as? TableViewCell
let cellViewModel = TableCellViewModel(withItem: item)
cell?.setViewModel(to: cellViewModel)
return cell ?? UITableViewCell()
}, canEditRowAtIndexPath: { _, _ in return true })
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard var headerView = tableView.dequeueReusableHeaderFooterView(
withIdentifier: TableViewSection.identifier)
as? TableViewSection
else { return UITableViewHeaderFooterView() }
let viewModel = self.dataSource[section].sectionViewModel
headerView.setViewModel(to: viewModel)
headerView.dividerLine.isHidden = section == 0 ? true : false
headerView.section = section
let data = TableViewEditingCommand.clearAll(IndexPath(row: 0, section: section ?? 0))
// /// Section button binding with closeObserver
// headerView.sectionButton.rx.tap
// .map(verseNum -> TableViewEditingCommand in (TableViewEditingCommand.deleteSingle))
// .disposed(by: disposeBag)
headerView.sectionButtonTappedClosure = { [weak self] (section, buttonType) in
if buttonType == ButtonType.clearAll {
self?.showClearAllConfirmationAlert(section: section, buttonType: buttonType)
} else {
self?.editAction(section: section, buttonType: buttonType)
}
}
return headerView
}
func editAction(section: Int, buttonType: ButtonType) {
var sections = self.dataSource.sectionModels
let updateSection = (sections.count == 1 ? 0 : section)
switch buttonType {
/// Clear all
case .clearAll:
sections.remove(at: updateSection)
let data = SectionedTableViewState(sections: sections)
self.tableView.dataSource = nil
Observable.of(data)
.startWith(data) .map { $0.sections }
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
/// Mark all as read
case .markAllAsRead:
if updateSection == 0 { sections = self.viewModel.getSections() }
var items = sections[updateSection].items
items = items.map { var unread = $0
if $0.isUnRead == true { unread.isUnRead = false }
return unread
}
sections.remove(at: updateSection)
let allReadItems = sections[updateSection].items + items
sections[updateSection] = TableDataSource(
original: sections[updateSection],
items: allReadItems)
let data = SectionedTableViewState(sections: sections)
self.tableView.dataSource = nil
Observable.of(data)
.startWith(data) .map { $0.sections }
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
}
}
func showClearAllConfirmationAlert(section: Int, buttonType: ButtonType) {
let alert = UIAlertController(title: "Clear All",
message: "Are you sure, you want to clear all Items?",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { action in
switch action.style{
case .default:
self.editAction(section: section, buttonType: buttonType)
case .cancel: break
case .destructive: break
default:break
}}))
let cancel = UIAlertAction(title: "Cancel", style: .default, handler: { action in
})
alert.addAction(cancel)
self.present(alert, animated: true, completion: nil)
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return sectionHeight
}
}
View model for controller
class ViewModel {
private enum Constants {
static let titleText = "Test".localized
static let testHistoryHeaderText = "test".localized
static let unreadHeaderText = "Unread".localized
}
struct Input {
let dataSourceObserver: AnyObserver<[Data]>
let openDetailsObserver: AnyObserver<Data>
let closeObserver: AnyObserver<Void>
let sectionButtonTappedObserver: AnyObserver<IndexPath>
}
struct Output {
let titleTextDriver: Driver<String>
let dataSourceDriver: Driver<[Data]>
let viewComplete: Observable<DataCoordinator.Event>
}
let input: Input
let output: Output
private let dataSourceSubject = PublishSubject<[Data]>()
private let closeSubject = PublishSubject<Void>()
private let openDetailsSubject = BehaviorSubject<Data>(value:Data())
private let sectionButtonTappedSubject = PublishSubject<IndexPath>()
private let disposeBag = DisposeBag()
init(withRepository repository: Repository) {
input = Input(
dataSourceObserver: dataSourceSubject.asObserver(),
openDetailsObserver: openDetailsSubject.asObserver(),
closeObserver: closeSubject.asObserver(), sectionButtonTappedObserver: sectionButtonTappedSubject.asObserver()
)
let closeEventObservable = closeSubject.asObservable().map { _ in
return Coordinator.Event.goBack
}
let openDetailsEventObservable = openDetailsSubject.asObservable().map { _ in
return Coordinator.Event.goToDetails
}
let viewCompleteObservable = Observable.merge(closeEventObservable, openDetailsEventObservable)
let list = ViewModel.getData(repository: repository)
output = Output(
titleTextDriver: Observable.just(Constants.titleText).asDriver(onErrorJustReturn: Constants.titleText),
dataSourceDriver: Observable.just(list).asDriver(onErrorJustReturn: list),
viewComplete: viewCompleteObservable)
}
///TODO: To be updated as per response after API integration
static func getData(repository: Repository) -> [Data] {
return repository.getData()
}
func getSections() -> [TableDataSource] {
let List = ViewModel.getData(repository: Repository())
let unread = list.filter { $0.isUnRead == true }
let read = list.filter { $0.isUnRead == false }
let headerText = String(format:Constants.unreadHeaderText, unread.count)
let sections = [TableDataSource(
header: headerText,
items: unread,
sectionViewModel: SectionViewModel(
withHeader: headerText,
buttonType: ButtonType.markAllAsRead.rawValue,
items: unread)),
TableDataSource(
header: Constants.historyHeaderText,
items: read,
SectionViewModel: SectionViewModel(
withHeader: Constants.historyHeaderText,
buttonType: ButtonType.clearAll.rawValue,
items: read))]
return sections
}
}
i think your problem is with :
var identity: String {
return header
}
make a uuid and pass it to identity:
let id = UUID().uuidString
var identity: String {
return id
}

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.

Pagination with Firebase firestore - swift 4

I'm trying to paginate data (infinitely scroll my tableview) using firestore. I've integrated the code google gives for pagination as best I can, but I'm still having problems getting the data to load in correctly.
The initial set of data loads into the tableview as wanted. Then, when the user hits the bottom of the screen, the next "x" amount of items are load in. But when the user hits the bottom of the screen the second time, the same "x" items are simply appended to the table view. The same items keep getting added indefinitely.
So its the initial 3 "ride" objects, followed by the next 4 "ride" objects repeating forever.
123 4567 4567 4567 4567...
How do I get the data to load in correctly?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.height {
// Bottom of the screen is reached
if !fetchingMore {
beginBatchFetch()
}
}
}
func beginBatchFetch() {
// Array containing "Ride" objcets is "rides"
fetchingMore = true
// Database reference to "rides" collection
let ridesRef = db.collection("rides")
let first = ridesRef.limit(to: 3)
first.addSnapshotListener { (snapshot, err) in
if let snapshot = snapshot {
// Snapshot isn't nil
if self.rides.isEmpty {
// rides array is empty (initial data needs to be loaded in).
let initialRides = snapshot.documents.compactMap({Ride(dictionary: $0.data())})
self.rides.append(contentsOf: initialRides)
self.fetchingMore = false
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: {
self.tableView.reloadData()
})
print("first rides loaded in")
}
} else {
// Error
print("Error retreiving rides: \(err.debugDescription)")
return
}
// reference to lastSnapshot
guard let lastSnapshot = snapshot!.documents.last else{
// The collection is empty
return
}
let next = ridesRef.limit(to: 4).start(afterDocument: lastSnapshot)
next.addSnapshotListener({ (snapshot, err) in
if let snapshot = snapshot {
if !self.rides.isEmpty {
let newRides = snapshot.documents.compactMap({Ride(dictionary: $0.data())})
self.rides.append(contentsOf: newRides)
self.fetchingMore = false
DispatchQueue.main.asyncAfter(deadline: .now() + 7, execute: {
self.tableView.reloadData()
})
print("new items")
return
}
} else {
print("Error retreiving rides: \(err.debugDescription)")
return
}
})
}
}
So here's the solution I've come up with! It is very likely that this solution makes multiple calls to firestore, creating a large bill for any real project, but it works as a proof of concept I guess you could say.
If you have any recommendations or edits, please feel free to share!
var rides = [Ride]()
var lastDocumentSnapshot: DocumentSnapshot!
var fetchingMore = false
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
//print("offsetY: \(offsetY) | contHeight-scrollViewHeight: \(contentHeight-scrollView.frame.height)")
if offsetY > contentHeight - scrollView.frame.height - 50 {
// Bottom of the screen is reached
if !fetchingMore {
paginateData()
}
}
}
// Paginates data
func paginateData() {
fetchingMore = true
var query: Query!
if rides.isEmpty {
query = db.collection("rides").order(by: "price").limit(to: 6)
print("First 6 rides loaded")
} else {
query = db.collection("rides").order(by: "price").start(afterDocument: lastDocumentSnapshot).limit(to: 4)
print("Next 4 rides loaded")
}
query.getDocuments { (snapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
} else if snapshot!.isEmpty {
self.fetchingMore = false
return
} else {
let newRides = snapshot!.documents.compactMap({Ride(dictionary: $0.data())})
self.rides.append(contentsOf: newRides)
//
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
self.tableView.reloadData()
self.fetchingMore = false
})
self.lastDocumentSnapshot = snapshot!.documents.last
}
}
}
A little late in the game, but I would like to share how I do it, using the query.start(afterDocument:) method.
class PostsController: UITableViewController {
let db = Firestore.firestore()
var query: Query!
var documents = [QueryDocumentSnapshot]()
var postArray = [Post]()
override func viewDidLoad() {
super.viewDidLoad()
query = db.collection("myCollection")
.order(by: "post", descending: false)
.limit(to: 15)
getData()
}
func getData() {
query.getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
querySnapshot!.documents.forEach({ (document) in
let data = document.data() as [String: AnyObject]
//Setup your data model
let postItem = Post(post: post, id: id)
self.postArray += [postItem]
self.documents += [document]
})
self.tableView.reloadData()
}
}
}
func paginate() {
//This line is the main pagination code.
//Firestore allows you to fetch document from the last queryDocument
query = query.start(afterDocument: documents.last!)
getData()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return postArray.count
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// Trigger pagination when scrolled to last cell
// Feel free to adjust when you want pagination to be triggered
if (indexPath.row == postArray.count - 1) {
paginate()
}
}
}
Result like so:
Here is a reference.
My solution was similar to #yambo, however, I tried to avoid making extra calls to the database. After the first call to the database, I get back 10 objects and when it is time to load the new page I kept a reference of how many objects and I checked if the count + 9 is in the range of my new count.
#objc func LoadMore() {
let oldCount = self.uploads.count
guard shouldLoadMore else { return }
self.db.getNextPage { (result) in
switch result {
case .failure(let err):
print(err)
case .success(let newPosts):
self.uploads.insert(contentsOf: newPosts, at: self.uploads.count)
if oldCount...oldCount+9 ~= self.uploads.count {
self.shouldLoadMore = false
}
DispatchQueue.main.async {
self.uploadsView.collectionView.reloadData()
}
}
}
}
Simple, Fast and easy way is...
class FeedViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, FeedCellDelegate {
private var quotes = [Quote]() {
didSet{ tbl_Feed.reloadData() }
}
var quote: Quote?
var fetchCount = 10
#IBOutlet weak var tbl_Feed: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
fetchPost()
}
// MARK: - API
func fetchPost() {
reference(.Quotes).limit(to: getResultCount).getDocuments { (snapshot, error) in
guard let documents = snapshot?.documents else { return }
documents.forEach { (doc) in
let quotes = documents.map {(Quote(dictionary: $0.data()))}
self.quotes = quotes
}
}
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let currentOffset = scrollView.contentOffset.y
let maxxOffset = scrollView.contentSize.height - scrollView.frame.size.height
if maxxOffset - currentOffset <= 300 { // Your cell size 300 is example
fetchCount += 5
fetchPost()
print("DEBUG: Fetching new Data")
}
}
}