I have a UICollectionView that I feed data into using UICollectionViewDiffableDataSource. I want to display a scroll scrubber on the trailing edge of it, like I'd get if I implemented the data source methods indexTitlesForCollectionView and indexPathForIndexTitle. But the data source is the diffable data source object, and there's no property or closure on it to supply index titles as of iOS 15.
How are index titles supposed to work with UICollectionViewDiffableDataSource?
You have to create the subclass for your UICollectionViewDiffableDataSource.
final class SectionIndexTitlesCollectionViewDiffableDataSource: UICollectionViewDiffableDataSource<Section, SectionItem> {
private var indexTitles: [String] = []
func setupIndexTitle() {
indexTitles = ["A", "B", "C"]
}
override func indexTitles(for collectionView: UICollectionView) -> [String]? {
indexTitles
}
override func collectionView(_ collectionView: UICollectionView, indexPathForIndexTitle title: String, at index: Int) -> IndexPath {
// your logic how to calculate the correct IndexPath goes here.
guard let index = indexTitles.firstIndex(where: { $0 == title }) else {
return IndexPath(item: 0, section: 0)
}
return IndexPath(item: index, section: 0)
}
}
You can use now this custom diffable data source in your vc instead of regular UICollectionViewDiffableDataSource.
N.B But there is a small trick.
You must have to setup indexTitles once applying snapshot completes, otherwise it may crash.
dataSource?.apply(sectionSnapshot, to: section, animatingDifferences: true, completion: { [weak self] in
self?.dataSource?.setupIndexTitle()
self?.collectionView.reloadData()
})
Related
I'm using a UICollectionViewDiffableDataSource and a NSFetchedResultsController to populate my UICollectionView inside my UIViewController.
To add the ability of reordering cells I added a UILongPressGestureRecognizer and subclassed UICollectionViewDiffableDataSource in order to use it's canMoveItemAt: and moveItemAt: methods.
When reordering a cell the following things happen:
moveItemAt: is called and I update the objects position property and save the MOC
controllerDidChangeContent: of the NSFetchedResultsControllerDelegate is called and I create a new snapshot from the current fetchedObjects and apply it.
When I apply dataSource?.apply(snapshot, animatingDifferences: true) the cells switch positions back immediately. If I set animatingDifferences: false it works, but all cells are reloaded visibly.
Is there any best practice here, how to implement cell reordering on a UICollectionViewDiffableDataSource and a NSFetchedResultsController?
Here are my mentioned methods:
// ViewController
func createSnapshot(animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Int, Favorite>()
snapshot.appendSections([0])
snapshot.appendItems(provider.fetchedResultsController.fetchedObjects ?? [])
dataSource?.apply(snapshot, animatingDifferences: animated)
}
// NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
createSnapshot(animated: false)
}
// Subclassed UICollectionViewDiffableDataSource
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
provider.moveFavorite(from: sourceIndexPath.row, to: destinationIndexPath.row)
}
// Actual cell moving in a provider class
public func moveFavorite(from source: Int, to destination: Int) {
guard let favorites = fetchedResultsController.fetchedObjects else { return }
if source < destination {
let partialObjects = favorites.filter({ $0.position <= destination && $0.position >= source })
for object in partialObjects {
object.position -= 1
}
let movedFavorite = partialObjects.first
movedFavorite?.position = Int64(destination)
}
else {
let partialObjects = favorites.filter({ $0.position >= destination && $0.position <= source })
for object in partialObjects {
object.position += 1
}
let movedFavorite = partialObjects.last
movedFavorite?.position = Int64(destination)
}
do {
try coreDataHandler.mainContext.save()
} catch let error as NSError {
print(error.localizedDescription)
}
}
My solution to the same issue is to subclass the UICollectionViewDiffableDataSource and implement the canMoveItemAt method in the subclass to answer true.
The animation seems to work fine for me if the longPressAction case of .ended does three things:
update the model
call dateSource.collectionView(..moveItemAt:..)
run your dataSource.apply
The other usual methods for drag behavior have to be also implemented which it looks like you have done. FYI for others- These methods are well documented in the section for 'Reordering Items Interactively' of UICollectionView. https://developer.apple.com/documentation/uikit/uicollectionview
class PGLDiffableDataSource: UICollectionViewDiffableDataSource<Int, Int> {
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
}
No need to subclass. Starting in iOS 14.0, UICollectionViewDiffableDataSource supports reordering handlers you can implement.
let data_source = UICollectionViewDiffableDataSource<MySection, MyModelObject>( collectionView: collection_view, cellProvider:
{
[weak self] (collection_view, index_path, video) -> UICollectionViewCell? in
let cell = collection_view.dequeueReusableCell( withReuseIdentifier: "cell", for: index_path ) as! MyCollectionViewCell
if let self = self
{
//setModel() is my own method to update the view in MyCollectionViewCell
cell.setModel( self.my_model_objects[index_path.item] )
}
return cell
})
// Allow every item to be reordered as long as there's 2 or more
diffable_data_source.reorderingHandlers.canReorderItem =
{
item in
my_model_objects.count >= 2 return true
}
//Update your model objects before the reorder occurs.
//You can also use didReorder, but it might be useful to have your
//model objects in the correct order before dequeueReusableCell() is
//called so you can update the cell's view with the correct model object.
diffable_data_source.reorderingHandlers.willReorder =
{
[weak self] transaction in
guard let self = self else { return }
self.my_model_objects = transaction.finalSnapshot.itemIdentifiers
}
I implement pull to refresh in collection View and the problem I'm facing is my app will crash with out of index message. Below is my cellForItem method
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! CauNguyenCell
cell.postArray = postData[indexPath.item]
return cell
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return postData.count
}
I know the problem is because I use removeAll method to clear all data from my postData but I have to that so my data array will have completely new data.
Below is my refresh function:
func handleRefresh(_ refreshControl: UIRefreshControl) {
refreshControl.beginRefreshing()
postData.removeAll()
fetchDataAgain()
self.collectionView.reloadData()
refreshControl.endRefreshing()
}
error message: Thread 1: Fatal error: Index out of range
I just want to ask if anyone has any suggestions to solve the problem. Thanks!
I have implemented same thing in my project. First I have created refreshControl instance globally, then setup in initSetup() method call from viewDidLoad() in my view controller.
var refreshControl : UIRefreshControl?
var arrWeddingInvitations = [MyModelClass]()
func initialSetup() {
self.refreshControl = UIRefreshControl()
self.refreshControl?.tintColor = .yellow
self.refreshControl?.addTarget(self, action: #selector(getWeddingInvitations), for: .valueChanged)
self.cvWeddingInvitation.addSubview(refreshControl!)
}
This is call getWeddingInvitations() method which is fetching data from server.
// This code will hide refresh controller
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
}
// This code in my API request completion block and will check responded array data, If it is not nil then assigned to my global array in view controller which are used to display data and reload collection view.
if arrInvitations != nil, arrInvitations.count > 0 {
self.lblEmptyData.isHidden = true
self.cvWeddingInvitation.isHidden = false
self.arrWeddingInvitations = arrInvitations
self.cvWeddingInvitation.reloadData()
} else {
self.lblEmptyData.isHidden = false
self.cvWeddingInvitation.isHidden = true
}
This is working code in my current project. I hope this will help you.
See following video:
Pull to refresh test video
Please check your UICollectionViewDataSource implementation for these methods:
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
func numberOfSections(in: UICollectionView)
In the first one you should return the current number of items postData.count, the second one in your case should return 1.
Edit you code like this:
func fetchDataAgain(completion: ((Bool) -> ())) {
// your code
if postData != nil, postData.count != 0 {
completion(true)
}
}
func handleRefresh(_ refreshControl: UIRefreshControl) {
refreshControl.beginRefreshing()
postData.removeAll()
fetchDataAgain { (complete) in
if complete {
self.collectionView.reloadData()
refreshControl.endRefreshing()
}
}
}
Method fetchDataAgain will check array, and if it != nil and count != 0 in handler complete reloadData.
Code do all step by step, and when you are reload data in collection, your array can be empty, or nil. As a rule better to use handlers
So, I am using Realm as a data store, which I'm pretty sure I need to first add content to before inserting an item at index path in a collection view. But I keep getting this all too familiar error:
'NSInternalInconsistencyException', reason: 'attempt to insert item 1 into section -1, but there are only 1 items in section 1 after the update'
Here is my model:
final class Listing: Object {
dynamic var id = ""
dynamic var name = ""
dynamic var item = ""
}
Here is my view controller that conforms to UICollectionView data sources and delegates:
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - Get Listings!
queryListings()
// MARK: - Delegates
self.collectionView.delegate = self
self.collectionView.dataSource = self
}
// MARK: - Query Listings
func queryListings() {
let realm = try! Realm()
let everyListing = realm.objects(Listing.self)
let listingDates = everyArticle.sorted(byKeyPath: "created", ascending: false)
for listing in listingDates {
listing.append(listing)
self.collectionView.performBatchUpdates({
self.collectionView.insertItems(at: [IndexPath(item: self.listing.count, section: 1)])
}, completion: nil)
}
}
Delegates:
// MARK: UICollectionViewDataSource
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return listing.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! ListingCollectionViewCell
cell.awakeFromNib()
return cell
}
I've tried every permutation of self.listing.count 0, 1, -1 , +1 as well as section 0, 1, -1, +1 and the exception raised is the same plus or minus the section and items that exist. Calling reloadData() doesn't help either.
Anyone solve this with a collection view?
Solved
With Realm, the mindset is different than what I'm accustomed to-- you're manipulating data that effects the table or collection, not the table or collection directly. Sounds obvious, but... anyway, TiM's answer is correct. Here's the collection view version:
// MARK: - Observe Results Notifications
notificationToken = articles.addNotificationBlock { [weak self] (changes: RealmCollectionChange) in
guard (self?.collectionView) != nil else { return }
// MARK: - Switch on State
switch changes {
case .initial:
self?.collectionView.reloadData()
break
case .update(_, let deletions, let insertions, let modifications):
self?.collectionView.performBatchUpdates({
self?.collectionView.insertItems(at: insertions.map({ IndexPath(row: $0, section: 0)}))
self?.collectionView.deleteItems(at: deletions.map({ IndexPath(row: $0, section: 0)}))
self?.collectionView.reloadItems(at: modifications.map({ IndexPath(row: $0, section: 0)}))
}, completion: nil)
break
case .error(let error):
print(error.localizedDescription)
break
}
}
The lines of code for listing in listingDates { listing.append(listing) } seem a bit unsafe. Either you're referring to separate objects named listing (such as a class property), or that's in reference to the same listing object. If listing is a Realm Results object, it shouldn't be possible to call append on it.
In any case, you're probably doing a bit more work than you need to. Realm objects, whether they are Object or Results are live, in that they'll automatically update if the underlying data changes them. As such, it's not necessary to perform multiple queries to update a collection view.
Best practice is to perform the query once, and save the Results object as a property of your view controller. From that point, you can use Realm's Change Notification feature to assign a Swift closure that'll be executed each time the Realm query changes. This can then be used to animate the updates on the collection view:
class ViewController: UITableViewController {
var notificationToken: NotificationToken? = nil
override func viewDidLoad() {
super.viewDidLoad()
let realm = try! Realm()
let results = realm.objects(Person.self).filter("age > 5")
// Observe Results Notifications
notificationToken = results.addNotificationBlock { [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()
break
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()
break
case .error(let error):
// An error occurred while opening the Realm file on the background worker thread
fatalError("\(error)")
break
}
}
}
deinit {
notificationToken?.stop()
}
}
I am using Realm as the alternative for coredata for the first time.
Sadly, I had this bumpy scrolling issue(It is not too bad, but quite obvious) for collectionView when I try Realm out. No data were downloaded blocking the main thread, I use local stored image instead.
Another issue is when I push to another collectionVC, if the current VC will pass data to the other one, the segue is also quite bumpy.
I am guessing it is because of the way I write this children property in the Realm Model. But I do not know what might be the good way to compute this array of array value (merging different types of list into one)
A big thank you in advance!!
Here is the main model I use for the collectionView
class STInstitution: STHierarchy, STContainer {
let boxes = List<STBox>()
let collections = List<STCollection>()
let volumes = List<STVolume>()
override dynamic var _type: ReamlEnum {
return ReamlEnum(value: ["rawValue": STHierarchyType.institution.rawValue])
}
var children: [[AnyObject]] {
var result = [[AnyObject]]()
var tempArr = [AnyObject]()
boxes.forEach{ tempArr.append($0) }
result.append(tempArr)
tempArr.removeAll()
collections.forEach{ tempArr.append($0) }
result.append(tempArr)
tempArr.removeAll()
volumes.forEach{ tempArr.append($0) }
result.append(tempArr)
return result
}
var hierarchyProperties: [String] {
return ["boxes", "collections", "volumes"]
}
}
Here is how I implement the UICollectionViewController:
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView?.alwaysBounceVertical = true
dataSource = STRealmDB.query(fromRealm: realm, ofType: STInstitution.self, query: "ownerId = '\(STUser.currentUserId)'")
}
// MARK: - datasource:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of items
guard let dataSource = dataSource else { return 0 }
return dataSource.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! STArchiveCollectionViewCell
guard let dataSource = dataSource,
dataSource.count > indexPath.row else {
return cell
}
let item = dataSource[indexPath.row]
DispatchQueue.main.async {
cell.configureUI(withHierarchy: item)
}
return cell
}
// MARK: - Open Item
func pushToDetailView(dataSource: [[AnyObject]], titles: [String]) {
guard let vc = storyboard?.instantiateViewController(withIdentifier: STStoryboardIds.archiveDetailVC.rawValue) as? STArchiveDetailVC
else { return }
vc.dataSource = dataSource
vc.sectionTitles = titles
self.navigationController?.pushViewController(vc, animated: true)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let dataSource = self.dataSource,
dataSource.count > indexPath.row else {
return
}
let item = dataSource[indexPath.row]
self.pushToDetailView(dataSource: item.children, titles: item.hierarchyProperties)
}
Modification(more codes on configureUI):
// configureUI
// data.type is an enum type
func configureUI<T: STHierarchy>(withHierarchy data: T) {
print("data", kHierarchyCoverImage + "\(data.type)")
titleLabel.text = data.title
let image = data.type.toUIImage()
self.imageView.image = image
}
// toUIImage of enum data.type
func toUIImage() -> UIImage {
let key = kHierarchyCoverImage + "\(self.rawValue)" as NSString
if let image = STCache.imageCache.object(forKey: key) {
return image
}else{
print("toUIImage")
let defaultImage = UIImage(named: "institution")
let image = UIImage(named: "\(self)") ?? defaultImage!
STCache.imageCache.setObject(image, forKey: key)
return image
}
}
If your UI is bumpy when you're scrolling, it simply means the operations you're performing in collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell are too heavy.
Realm itself is structured in such a way that reading data from objects is very fast, so you shouldn't be seeing substantial dropped frames if all you're doing is populating a cell with values from Realm.
A couple of considerations:
If you're calling item.children inside the cellForItem block method, since you're manually looping through and paging in every Realm object doing that, that will cause frame drops. If you are, it'd be best to either do that ahead of time, or re-desing the logic to only access those arrays when absolutely needed.
You mentioned you're including images. Even if the images are on disk, unless you force image decompression ahead of time, Core Animation will lazily decompress the image at draw time on the main thread which can severely kill scroll performance. See this question for more info.
The cellForItemAt method call should already be on the main thread, so configuring your cell in a DispatchQueue.main.async closure seems un-necessary, and given that it's not synchronous, may be causing additional issues by running out of order.
Collection views are notoriously hard for performance since entire rows of cells used to be created and configured in one run loop iteration. This behavior was changed in iOS 10 to spread cell creation out across multiple run loop iterations. See this WWDC video for tips on optimizing your collection view code to take advantage of this.
If you're still having trouble, please post up more of your sample code; most importantly, the contents of configureUI. Thanks!
Turned out I was focusing on the wrong side. My lack of experience with Realm made me feel that there must be something wrong I did with Realm. However, the true culprit was I forgot to define the path for shadow of my customed cell, which is really expensive to draw repeatedly. I did not find this until I used the time profile to check which methods are taking the most CPU, and I should have done it in the first place.
I have working uicollectionview codes with CustomCollectionViewLayout , and inside have a lot of small cells but user cannot see them without zoom. Also all cells selectable.
I want to add my collection view inside zoom feature !
My clear codes under below.
class CustomCollectionViewController: UICollectionViewController {
var items = [Item]()
override func viewDidLoad() {
super.viewDidLoad()
customCollectionViewLayout.delegate = self
getDataFromServer()
}
func getDataFromServer() {
HttpManager.getRequest(url, parameter: .None) { [weak self] (responseData, errorMessage) -> () in
guard let strongSelf = self else { return }
guard let responseData = responseData else {
print("Get request error \(errorMessage)")
return
}
guard let customCollectionViewLayout = strongSelf.collectionView?.collectionViewLayout as? CustomCollectionViewLayout else { return }
strongSelf.items = responseData
customCollectionViewLayout.dataSourceDidUpdate = true
NSOperationQueue.mainQueue().addOperationWithBlock({ () -> Void in
strongSelf.collectionView!.reloadData()
})
}
}
}
extension CustomCollectionViewController {
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return items.count
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items[section].services.count + 1
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as! CustomCollectionViewCell
cell.label.text = items[indexPath.section].base
return cell
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath cellForItemAtIndexPath: NSIndexPath) {
print(items[cellForItemAtIndexPath.section].base)
}
}
Also my UICollectionView layout properties under below you can see there i selected maxZoom 4 but doesnt have any action !
Thank you !
You don't zoom a collection like you'd zoom a simple scroll view. Instead you should add a pinch gesture (or some other zoom mechanism) and use it to change the layout so your grid displays a different number of items in the visible part of the collection. This is basically changing the number of columns and thus the item size (cell size). When you update the layout the collection can animate between the different sizes, though it's highly unlikely you want a smooth zoom, you want it to go direct from N columns to N-1 columns in a step.
I think what you're asking for looks like what is done in the WWDC1012 video entitled Advanced Collection Views and Building Custom Layouts (demo starts at 20:20) https://www.youtube.com/watch?v=8vB2TMS2uhE
You basically have to add pinchGesture to you UICollectionView, then pass the pinch properties (scale, center) to the UICollectionViewLayout (which is a subclass of UICollectionViewFlowLayout), your layout will then perform the transformations needed to zoom on the desired cell.