Insert cell in collection view, Swift - ios

I have a collection view made with UICollectionViewCompositionalLayout, with different sections and cells.
I fetch some data through my API and then I insert this data in my dataSource like this:
if let index = self.dataSource.firstIndex(where: { $0.id == "Cell with loader" }) {
self.dataSource.remove(at: index)
if !data.isEmpty {
self.dataSource.insert(data, at: index)
self.reloadCollectionViewSection?(.recapCards, .insertAndRemove)
} else {
self.reloadCollectionViewSection?(.recapCards, .remove)
}
}
This is my method to reload data in sections:
viewModel.reloadCollectionViewSection = { [weak self] section, action in
guard let self = self else { return }
switch action {
case .remove:
self.collectionView.performBatchUpdates({
let indexSet = IndexSet(integer: section.rawValue)
self.collectionView.deleteSections(indexSet)
}, completion: nil)
case .insert:
self.collectionView.performBatchUpdates({
let indexSet = IndexSet(integer: section.rawValue)
self.collectionView.insertSections(indexSet)
}, completion: nil)
case .insertAndRemove:
self.collectionView.performBatchUpdates({
let indexSet = IndexSet(integer: section.rawValue)
self.collectionView.deleteSections(indexSet)
self.collectionView.insertSections(indexSet)
}, completion: nil)
}
}
My question is: what if an API finish before another and I insert a section at an index that doesn't exist? I need that those section are ordered (that's the main reason of my insert), but is there a safer approach? I don't know if section 3 is added or not (it depends from my api), so how can I add ordered sections maximising my performance?
I can sort the section when everything is done, but I don't like this approach. How can I insert, remove and update section in the safest way?

Related

UICollectionViewCompositionalLayout optional sections

I have looked at this question, which is similar: How to deal with empty items section in UICollectionView CompositionalLayout, but the answer there seems to be either leave sections out in the snapshot (which I do, but that leaves another problem, which I describe later) or to render a very small section. That solution does not seem like a good solution.
I have a collection view using a compositional layout with a diffable data source. The collection view has four sections, but each of those sections is optional meaning that if the corresponding data for that section is empty, then the section should not be displayed.
Code
Layout Definition
I have a section provider that uses the sectionIndex to configure what each section should look like. I think this is bad because then if I do not have data for section three in the snapshot, for instance, then everything that should normally be in section four will now have an indexPath that will cause it to be laid out like section three.
And each section has different item sizes and some are orthogonal scrolling sections. So if section four data is rendered using the section three layout, then it will look wrong.
NSCollectionLayoutSection * _Nullable (^sectionProvider)(NSInteger, id<NSCollectionLayoutEnvironment> _Nonnull) = ^NSCollectionLayoutSection * _Nullable (NSInteger sectionIndex, id<NSCollectionLayoutEnvironment> _Nonnull layoutEnvironment) {
if (sectionIndex == 0) {
//configure and return a layout for the first section
} else if (sectionIndex == 1) {
//configure and return a layout for the second section
} else if (sectionIndex == 2) {
//configure and return a layout for the third section
} else if (sectionIndex == 3) {
//configure and return a layout for the fourth section
}
return nil;
};
UICollectionViewCompositionalLayoutConfiguration *configuration = [[UICollectionViewCompositionalLayoutConfiguration alloc] init];
configuration.interSectionSpacing = 10;
configuration.scrollDirection = UICollectionViewScrollDirectionVertical;
self->_collectionViewLayout = [[UICollectionViewCompositionalLayout alloc] initWithSectionProvider:sectionProvider configuration:configuration];
Data Source Definition
This is where the data source is defined. Each section uses a different data model class, so I decide which type of cell to use based on the type of the data model class, not on the index path.
self->_dataSource = [[UICollectionViewDiffableDataSource alloc] initWithCollectionView:self.collectionView cellProvider:^UICollectionViewCell * _Nullable(UICollectionView * _Nonnull collectionView, NSIndexPath * _Nonnull indexPath, id _Nonnull item) {
if ([item isKindOfClass:[MyFirstSectionModel class]]) {
return [collectionView dequeueConfiguredReusableCellWithRegistration:firstSectionCellRegistration forIndexPath:indexPath item:item];
} else if ([item isKindOfClass:[MySecondSectionModel class]]) {
return [collectionView dequeueConfiguredReusableCellWithRegistration:secondSectionCellRegistration forIndexPath:indexPath item:item];
} else if ([item isKindOfClass:[MyThirdSectionModel class]]) {
return [collectionView dequeueConfiguredReusableCellWithRegistration:thirdSectionCellRegistration forIndexPath:indexPath item:item];
} else if ([item isKindOfClass:[MyFourthSectionModel class]]) {
return [collectionView dequeueConfiguredReusableCellWithRegistration:fourthSectionCellRegistration forIndexPath:indexPath item:item];
}
return nil;
}];
Snapshot Construction
Here is where each section is either included (if it has data) or excluded (if the section is empty). But leaving a section out (like for example, if section three does not have any data, then it will be left out, but then that will make section four's data to have an index path with an index of 2, which will not work with the section provider.
If I insert an empty section into the snapshot, that still will not work because some of these sections have headers, so if it is a section that has a header then the header will still be displayed. But even if none of the sections had headers, I think it would still render some extra amount of empty space for the section (but this may be incorrect).
- (void)reloadDataSourceAnimated:(BOOL)animated {
NSDiffableDataSourceSnapshot<CICustomerReviewsSectionIdentifierType, CICustomerReviewsItemIdentifierType> *snapshot = [[NSDiffableDataSourceSnapshot alloc] init];
if (self.firstSectionItems.count) {
[snapshot appendSectionsWithIdentifiers:#[MyFirstSectionIdentifier]];
[snapshot appendItemsWithIdentifiers:#[self.firstSectionItems] intoSectionWithIdentifier:MyFirstSectionIdentifier];
}
if (self.secondSectionItems.count) {
[snapshot appendSectionsWithIdentifiers:#[MySecondSectionIdentifier]];
[snapshot appendItemsWithIdentifiers:#[self.secondSectionItems] intoSectionWithIdentifier:MySecondSectionIdentifier];
}
if (self.thirdSectionItems.count) {
[snapshot appendSectionsWithIdentifiers:#[MyThirdSectionIdentifier]];
[snapshot appendItemsWithIdentifiers:#[self.thirdSectionItems] intoSectionWithIdentifier:MyThirdSectionIdentifier];
}
if (self.fourthSectionItems.count) {
[snapshot appendSectionsWithIdentifiers:#[MyFourthSectionIdentifier]];
[snapshot appendItemsWithIdentifiers:self.fourthSectionItems intoSectionWithIdentifier:MyFourthSectionIdentifier];
}
[self.dataSource applySnapshot:snapshot animatingDifferences:animated];
}
Summary
So the problem is that if one or more of my sections does not have data, then when they get left out of the snapshot, that will cause the data for subsequent sections to be rendered in the wrong section (because the section provider configures sections based on the index and the indexPaths of each of the sections after the empty section(s) are no longer the original indexPath).
Question
Is there a way to have the sections be optional and for any regular views and supplementary views to not be rendered for an "empty" section?
I solved this problem by assigning my collection view data to local variable before applying datasource snaphot. This variable can be accessed by UICollectionViewCompositionalLayoutSectionProvider closure for determine which layout needs to be returned for a given index.
Example
Lets take this data model:
struct ViewControllerData {
let texts: [String]
let colors: [UIColor]
let numbers: [Int]
}
Collection view datasource definition:
enum Section: Hashable {
case first
case second
case third
}
enum SectionData: Hashable {
case text(String)
case color(UIColor)
case number(Int)
}
lazy var datasource: UICollectionViewDiffableDataSource<Section, SectionData> = {
let dataSource = UICollectionViewDiffableDataSource<Section, SectionData>(collectionView: self.collectionView) { [weak self] (collectionView, indexPath, data) -> UICollectionViewCell? in
switch data {
case .text(let text):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TextCollectionViewCell.reuseIdentifier, for: indexPath) as? TextCollectionViewCell
cell?.textLabel.text = text
return cell
case .color(let color):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ColorCollectionViewCell.reuseIdentifier, for: indexPath) as? ColorCollectionViewCell
cell?.colorView.backgroundColor = color
return cell
case .number(let number):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NumberCollectionViewCell.reuseIdentifier, for: indexPath) as? NumberCollectionViewCell
cell?.numberLabel.text = "\(number)"
return cell
}
}
dataSource.supplementaryViewProvider = ...
return dataSource
}()
Configure diffable snapshot excluding sections which has no data and assign model data to a local variable:
private var currentData: ViewControllerData?
public func showData(_ data: ViewControllerData) {
self.currentData = data
var snapshot = NSDiffableDataSourceSnapshot<Section, SectionData>()
if !data.texts.isEmpty {
snapshot.appendSections([.first])
snapshot.appendItems(data.texts.map { SectionData.text($0 )}, toSection: .first)
}
if !data.colors.isEmpty {
snapshot.appendSections([.second])
snapshot.appendItems(data.colors.map { SectionData.color($0) }, toSection: .second)
}
if !data.numbers.isEmpty {
snapshot.appendSections([.third])
snapshot.appendItems(data.numbers.map { SectionData.number($0) }, toSection: .third)
}
datasource.apply(snapshot, animatingDifferences: true)
}
Use this variable to provide correct section layout:
lazy var collectionViewLayout: UICollectionViewLayout = {
let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let section = self?.currentData?.visibleSection(at: sectionIndex) else { return nil }
switch section {
case .first:
let section = ...
return section
case .second:
let header = ...
let section = ...
section.boundarySupplementaryItems = [header]
return section
case .third:
let section = ...
return section
}
}
return layout
}()
visibleSection(at index:) is extension of ViewControllerData for convenience:
extension ViewControllerData {
var visibleSections: [ViewController.Section] {
var sections: [ViewController.Section] = []
if !texts.isEmpty { sections.append(.first) }
if !colors.isEmpty { sections.append(.second) }
if !numbers.isEmpty { sections.append(.third) }
return sections
}
func visibleSection(at index: Int) -> ViewController.Section? {
guard visibleSections.indices.contains(index) else { return nil }
return visibleSections[index]
}
}
This variable can also be used in collection view data source for provide supplementary views:
dataSource.supplementaryViewProvider = { [weak self] (collectionView, kind, indexPath) in
guard let section = self?.currentData?.visibleSection(at: indexPath.section) else { return nil }
switch section {
case .second:
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderView.reuseIdentifier, for: indexPath) as? HeaderView
header?.textLabel.text = "Colors section header"
return header
default: return nil
}
}
Result:

Incorrect tableView row is deleted when deleting tableView rows in ascending order (bottom to top)

When deleting tableView rows in ascending order (bottom to top), the incorrect row above the selected row is being deleted. For example, with the following four rows:
A
B
C
D
when swiping left to delete row D, row C is being deleted.
Inversely, when deleting in a descending order (top to bottom) there is no problem whatsoever, the rows are being deleted in their proper order. The basic deletion code follows with the tableView having two sections (the problem occurs when deleting in an ascending order in both sections). Any suggestions, much appreciated!
let deleteAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment:"Delete")) { _, _, complete in
let section = indexPath.section
let row = indexPath.row
let i = IndexPath(item: row, section: section)
if section == 0 {
self.cyclesteps.remove(at: row)
self.tableView.deleteRows(at: [i], with: .automatic)
let thesample = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.distanceCycling)
let thepredicate = HKQuery.predicateForObjects(from: HKSource.default())
healthStore.deleteSamplesOfType(thesample!, predicate: thepredicate, withCompletion: { (success, count, error) -> Void in
DispatchQueue.main.async(execute: { () -> Void in
if error == nil
{
// saved successfully
//self.tableView.reloadRows(at: [i], with: .automatic)
}
else
{
print("Error occured while saving to Health Kit: \(error!.localizedDescription)")
}
})
})
}
It's highly recommended to first delete the item in the database, then in the data source (on success) and then update the view
let deleteAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment:"Delete")) { _, _, complete in
guard indexPath.section == 0 else { complete(false) }
let thesample = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.distanceCycling)
let thepredicate = HKQuery.predicateForObjects(from: HKSource.default())
healthStore.deleteSamplesOfType(thesample!, predicate: thepredicate, withCompletion: { (success, count, error) -> Void in
DispatchQueue.main.async {
if let error = error {
print("Error occured while saving to Health Kit: \(error.localizedDescription)")
complete(false)
} else {
// saved successfully
self.cyclesteps.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
complete(true)
}
}
})
}
And don't forget to call the complete handler of the action.

Index out of range when dismissing view controller with collection view

When I dismiss my customVC while inserting items inside collection view, the app crashes with index out of range error. It doesnt matter how much I remove all collectionView data sources, it still crashes. Heres what I do to insert items:
DispatchQueue.global(qos: .background).async { // [weak self] doesn't do much
for customs in Global.titles.prefix(8) {
autoreleasepool {
let data = self.getData(name: customs)
Global.customs.append(data)
DispatchQueue.main.async { [weak self] in
self?.insertItems()
}
}
}
}
func insertItems() { // this function helps me insert items after getting data and it works fine
let currentNumbers = collectionView.numberOfItems(inSection: 0)
let updatedNumber = Global.customs.count
let insertedNumber = updatedNumber - currentNumbers
if insertedNumber > 0 {
let array = Array(0...insertedNumber-1)
var indexPaths = [IndexPath]()
for item in array {
let indexPath = IndexPath(item: currentNumbers + item, section: 0)
indexPaths.append(indexPath)
}
collectionView.insertItems(at: indexPaths)
}
}
I tried to remove all items in customs array and reload collection view before dismissing but still getting error:
Global.customs.removeAll()
collectionView.reloadData()
dismiss(animated: true, completion: nil)
I suspect that since I load data using a background thread, the collection view inserts the items even when the view is unloaded (nil) but using DispatchQueue.main.async { [weak self] in self?.insertItems() } doesn't help either.

Updating selected collectionview indexes after responding to PHPhotoLibraryChangeObserver event

I am making a multiple selection feature for my collection view which shows photos from the user's library. I keep track of the selected indexPaths in an array and I want to update them in case a photo library change observer event happens in the middle of selecting cells. for example, if a user has selected indexes 3 and 4 and a change observer event removes indexes 1 and 2 from the collection view, selected indexes should change to 1 and 2.
I am trying to do it manually using these functions:
fileprivate func removeIndicesFromSelections(indicesToRemove:IndexSet){
var itemToRemove: Int?
for (_, removeableIndex) in indicesToRemove.map({$0}).enumerated() {
itemToRemove = nil
for (itemIndex,indexPath) in selectedIndices.enumerated() {
//deduct 1 from indices after the deletion index
if (indexPath.item > removeableIndex) && (indexPath.item > 0) {
selectedIndices[itemIndex] = IndexPath(item: indexPath.item - 1, section: 0)
} else if indexPath.item == removeableIndex {
itemToRemove = itemIndex
}
}
if let remove = itemToRemove {
selectedIndices.remove(at: remove)
disableDeleteButtonIfNeeded()
}
}
}
fileprivate func moveSelectedIndicesAfterInsertion (insertedIndices:IndexSet){
for (_, insertedIndex) in insertedIndices.map({$0}).enumerated() {
for (itemIndex,indexPath) in selectedIndices.enumerated() {
//add 1 to indices after the insertion index
if (indexPath.item >= insertedIndex) {
selectedIndices[itemIndex] = IndexPath(item: indexPath.item + 1, section: 0)
}
}
}
}
However, these are getting more complicated than I expected and I keep finding bugs in them. Is there any better way to handle this situation (such as any built in collection view capabilities) or I just have to come up with my own functions like above?
You're on the right path, but you should store a reference to what object the user actually selected, not where they selected it (since that can change).
In this case, you should keep a reference to the selected photos' identifiers (see docs) and then you can determine what cell/index-path should be selected. You can compare your selection array against your image datasource to determine what the most up-to-date index path is.
There is a solution provided by Apple. You can find more information in official documentation page:
Bacically you want to adopt PHPhotoLibraryChangeObserver and implement the following function:
func photoLibraryDidChange(_ changeInstance: PHChange) {
guard let collectionView = self.collectionView else { return }
// Change notifications may be made on a background queue.
// Re-dispatch to the main queue to update the UI.
DispatchQueue.main.sync {
// Check for changes to the displayed album itself
// (its existence and metadata, not its member assets).
if let albumChanges = changeInstance.changeDetails(for: assetCollection) {
// Fetch the new album and update the UI accordingly.
assetCollection = albumChanges.objectAfterChanges! as! PHAssetCollection
navigationController?.navigationItem.title = assetCollection.localizedTitle
}
// Check for changes to the list of assets (insertions, deletions, moves, or updates).
if let changes = changeInstance.changeDetails(for: fetchResult) {
// Keep the new fetch result for future use.
fetchResult = changes.fetchResultAfterChanges
if changes.hasIncrementalChanges {
// If there are incremental diffs, animate them in the collection view.
collectionView.performBatchUpdates({
// For indexes to make sense, updates must be in this order:
// delete, insert, reload, move
if let removed = changes.removedIndexes where removed.count > 0 {
collectionView.deleteItems(at: removed.map { IndexPath(item: $0, section:0) })
}
if let inserted = changes.insertedIndexes where inserted.count > 0 {
collectionView.insertItems(at: inserted.map { IndexPath(item: $0, section:0) })
}
if let changed = changes.changedIndexes where changed.count > 0 {
collectionView.reloadItems(at: changed.map { IndexPath(item: $0, section:0) })
}
changes.enumerateMoves { fromIndex, toIndex in
collectionView.moveItem(at: IndexPath(item: fromIndex, section: 0),
to: IndexPath(item: toIndex, section: 0))
}
})
} else {
// Reload the collection view if incremental diffs are not available.
collectionView.reloadData()
}
}
}
}

UITableView Floating Cell After Async Fetch

This is in the same vein as a previous question I here
Basically, UITableView cells would occasionally overlap the data underneath them - I tracked that down to reloadRows acting wonky with estimatedHeight, and my solve was to cache the height when calling willDisplay cell: and then return that height, or an arbitrary constant if the row hasn't been seen yet, when calling heightForRow
But now the problem is back! Well, a similar one: after propagating a UITableView with some data, some of it fetched asynchronously, I want to be able to search and repopulate the UITableView.
This data I'm fetching may or may not already be present on the TableView, and in any case I don't consider that - I hit the backend, grab some stuff, and display it.
Except, it gets wonky:
As you can see from the screenshot, there's a cell overlaid on top of another cell with the same content. The TableView only reports there being 2 rows, via numberOfRows, but the View Hierarchy says there are 3 cells present when I click through to the TableView.
Only thing I can figure is there's some weird race condition or interaction that happens when I reloadRow after fetching the openGraph data.
What gives?
Some code:
Search
fileprivate func search(searchText: String, page: Int) {
postsService.searchPosts(searchText: searchText, page: page) { [weak self] posts, error in
if let weakSelf = self {
if let posts = posts, error == nil {
if !posts.isEmpty {
weakSelf.postListController.configureWith(posts: posts, deletionDelegate: nil, forParentView: "Trending")
weakSelf.page = weakSelf.page + 1
weakSelf.scrollTableViewUp()
} else {
// manually add a "No results found" string to tableFooterView
}
} else {
weakSelf.postListController.configureWith(posts: weakSelf.unfilteredPosts, deletionDelegate: nil, forParentView: "Trending")
weakSelf.scrollTableViewUp()
}
}
}
}
**configureWith*
func configureWith(posts: [Post], deletionDelegate: DeletionDelegate?, forParentView: String) {
self.posts = posts
for post in posts {
//some data pre-processing
if some_logic
if rawURLString.contains("twitter") {
let array = rawURLString.components(separatedBy: "/")
let client = TWTRAPIClient()
let tweetID = array[array.count - 1]
client.loadTweet(withID: tweetID, completion: { [weak self] (t, error) in
if let weakSelf = self {
if let tweet = t {
weakSelf.twitterCache.addTweetToCache(tweet: tweet, forID: Int(tweetID)!)
}
}
})
}
openGraphService.fetchOGData(url: rawURL, completion: { [weak self] (og, error) in
weakSelf.openGraphService.fetchOGImageData(url: ogImageURL, completion: { (data, response, error) in
if let imageData = data {
weakSelf.imageURLStringToData[ogImageString] = imageData
weakSelf.queueDispatcher.dispatchToMainQueue {
for cell in weakSelf.tableView.visibleCells {
if (cell as! PostCell).cellPost == post {
let cellIndexPath = IndexPath(row: weakSelf.posts.index(of: post)!, section: 0)
weakSelf.tableView.reloadRows(at: [cellIndexPath], with: UITableViewRowAnimation.automatic)
}
}
}
}
})
})
}
self.deletionDelegate = deletionDelegate
self.parentView = forParentView
queueDispatcher.dispatchToMainQueue { [weak self] in
if let weakSelf = self {
weakSelf.tableView.reloadData()
}
}
scrollToPost()
}

Resources