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.
Related
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?
I have a ViewController with a UICollectionView and its elements are bound and cells created via:
self.viewModel.profileItems.bind(to: self.collectionView.rx.items){ (cv, row, item) ...
I also react to the user taps via:
self.collectionView.rx.modelSelected(ProfileItem.self).subscribe(onNext: { (item) in
if(/*special item*/) {
let xVC = self.storyboard?.instantiateViewController(identifier: "x") as! XViewController
xVC.item = item
self.navigationController?.pushViewController(xVC, animated: true)
} else {
// other generic view controller
}
}).disposed(by: bag)
The property in the xViewController for item is of Type ProfileItem?. How can changes to item in the XViewController be bound to the collectionView cell?
Thanks in advance
Your XViewController needs an Observable that emits a new item at the appropriate times... Then realize that this observable can affect what profileItems, or at least your view model, emits.
let xResult = self.collectionView.rx.modelSelected(ProfileItem.self)
.filter { /*special item*/ }
.flatMapFirst { [unowned self] (specialItem) -> Observable<Item> in
let xVC = self.storyboard?.instantiateViewController(identifier: "x") as! XViewController
xVC.item = item
self.navigationController?.pushViewController(xVC, animated: true)
return xVC.observableX // this needs to complete at the appropriate time.
}
self.collectionView.rx.modelSelected(ProfileItem.self)
.filter { /*not special item*/ }
.bind(onNext: { [unowned self] item in
// other generic view controller
}
.disposed(by: bag)
Now you need to feed xResult into your view model.
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()
}
}
}
}
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()
}
When I have instantiated the third cell, I will add more to my items to my model array and then I will update the collection view data with:
DispatchQueue.main.async(execute: {
self.collectionView.reloadData()
})
Everything works as expected. However, when I reload the data for my collectionView, it will instantiate cells that are currently visible or hold in memory (2,3). Unfortunately, I have some expensive server requests which consume a lot of time.
Instaniated 0
Instaniated 2
Instaniated 3
******polluting more data: size of cells 10
Instaniated 2
Instaniated 3
Instaniated 4
How can I reload the data without reCreating visible cells or those who are in memory?
Thanks a lot.
Instead of reloading the cells, try inserting or reloading the once that have actually changed. You can use UIColletionViews performBatchUpdates(_:) for this: Link
An example:
collectionView.performBatchUpdates {
self.collectionView.insertItems(at: [IndexPath(row: 1, section: 1)])
}
This ensures that only the new cells are loaded. You can also move around cells and sections in this method and even delete cells. The linked page contains documentation for all of this.
Why can't you go with below approach
1) I hope you have declared dataSource and collectionView objects as global to the class
let collectionView = UICollectionView()
var dataSource = [Any]()
2) Have one function to get the initial results from the API response
func getInitialPosts(){
// call api
// store your initial response in the local array object and reload collectionview
let results:[Any] = {response from the server call}
self.dataSource.append(contentsOf: results)
DispatchQueue.main.async(execute: {
self.collectionView.reloadData()
})
}
3) For the next call, you can have another function
func getPostsForPage(page:Int){
// call api with page number
let newResults = {response from the server call}
self.dataSource.append(contentsOf: newResults)
var indexPathsToReload = [IndexPath]()
let section = 0
var row = self.dataSource.count - 1
//add new data from server response
for _ in newResults {
let indexPath = IndexPath(row: row, section: section)
row+=1
indexPathsToReload.append(indexPath)
}
// perform reload action
DispatchQueue.main.async(execute: {
self.collectionView.insertItems(at: indexPathsToReload)
})
}
Suppose from your network adapter you are calling delegate function fetchData with new data. Here you have to check if your data is empty or not to check if you need to add new data, or reload entire CollectionView.
Then you create all indexPaths that you need to fetch more, in order to let already fetched cell stay as they are. And finally use insertItems(at: IndexPaths).
I use page in order to paginate new data with page number in the future. Strictly for my use case. Good luck!
func fetchData(with videos: [VideoModel], page: Int) {
guard self.data.count == 0 else{
self.addNewData(with: videos, page: page)
return
}
self.data = videos
DispatchQueue.main.async {
self.isPaging = false
self.collectionView?.reloadData()
self.page = page
}
}
func addNewData(with videos: [VideoModel], page: Int){
var indexPathsToReload = [IndexPath]()
let section = 0
var row = self.data.count - 1
self.data += videos
for _ in videos {
print(row)
let indexPath = IndexPath(row: row, section: section)
row+=1
indexPathsToReload.append(indexPath)
}
DispatchQueue.main.async{
self.isPaging = false
self.collectionView!.insertItems(at: indexPathsToReload)
self.page = page
}
}