Reordering Cells with UICollectionViewDiffableDataSource and NSFetchedResultsController - ios

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
}

Related

Is there a way to update supplementary view efficiently, analogy to update items efficiently using reconfigureItems?

In iOS15, we have an efficient way to update items cell, by using reconfigureItems.
Here's the code snippet to perform such efficient update.
😄 Update items cell efficiently using reconfigureItems
private func reconfigureRecordingRow(_ recording: Recording) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([recording])
dataSource.apply(snapshot)
}
private func makeDataSource() -> DataSource {
let dataSource = DataSource(
collectionView: collectionView,
cellProvider: { [weak self] (collectionView, indexPath, anyHashable) -> UICollectionViewCell? in
guard let self = self else { return nil }
guard let recordingCell = collectionView.dequeueReusableCell(
withReuseIdentifier: "recording",
for: indexPath) as? RecordingCell else {
return nil
}
When reconfigureRecordingRow is called, cellProvider's function will be executed.
collectionView.dequeueReusableCell is able to re-use existing UICollectionViewCell, without constructing new UICollectionViewCell
However, I was wondering, how can I achieve a similar efficiency, if I have a section, with header supplementary view, and without any item? For instance
😭 Not able to update supplementary view efficiently
private func reloadAttachmentRow() {
var snapshot = dataSource.snapshot()
let sectionIdentifiers = snapshot.sectionIdentifiers
if sectionIdentifiers.contains(.attachment) {
snapshot.reloadSections([.attachment])
} else {
snapshot.insertSections([.attachment], beforeSection: .title)
}
dataSource.apply(snapshot)
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard let self = self else { return nil }
if kind == UICollectionView.elementKindSectionHeader {
let section = indexPath.section
let sectionIdentifier = self.sectionIdentifier(section)
switch sectionIdentifier {
case .attachment:
guard let collageViewHeader = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: "attachment",
for: indexPath) as? CollageViewHeader else {
return nil
}
When reloadSections is called, dataSource.supplementaryViewProvider will be executed.
As per my testing, collectionView.dequeueReusableSupplementaryView will return a new instance of UICollectionReusableView each time.
As a result, I can visually observe the entire section is "flickering", when reloadAttachmentRow is called.
I was wondering, how can we update supplementary view efficiently?

DiffableDataSource - Is there a way to limit reordering only be performed within the same section?

In https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views , Apple has shown a simple example on how to perform reordering using DiffableDataSource
ReorderableListViewController.swift
dataSource.reorderingHandlers.canReorderItem = { item in return true }
dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
guard let self = self else { return }
// method 1: enumerate through the section transactions and update
// each section's backing store via the Swift stdlib CollectionDifference API
if self.reorderingMethod == .collectionDifference {
for sectionTransaction in transaction.sectionTransactions {
let sectionIdentifier = sectionTransaction.sectionIdentifier
if let previousSectionItems = self.backingStore[sectionIdentifier],
let updatedSectionItems = previousSectionItems.applying(sectionTransaction.difference) {
self.backingStore[sectionIdentifier] = updatedSectionItems
}
}
// method 2: use the section transaction's finalSnapshot items as the new updated ordering
} else if self.reorderingMethod == .finalSnapshot {
for sectionTransaction in transaction.sectionTransactions {
let sectionIdentifier = sectionTransaction.sectionIdentifier
self.backingStore[sectionIdentifier] = sectionTransaction.finalSnapshot.items
}
}
}
Is there any way, to limit the reordering can only be performed within the same section?
There is not much we can do in reorderingHandlers.canReorderItem, because the closure parameter item is referring to the current source item we are dragging. There is no information on destination item, which we can compare with to decide whether to return true or false.
This behaviour isn't a question for your data source. It is a question for your delegate
You can use
collectionView(_:targetIndexPathForMoveFromItemAt:toProposedIndexPath:) to determine whether a move is permitted
func collectionView(_ collectionView: UICollectionView,
targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath,
toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let sourceSection = sourceIndexPath.section
let destSection = proposedDestinationIndexPath.section
var destination = proposedDestinationIndexPath
if destSection < sourceSection {
destination = IndexPath(item: 0, section: sourceSection)
} else if destSection > sourceSection {
destination = IndexPath(item: self.backingStore[sourceSection].count-1, section: sourceSection)
}
return destination
}
This constrains an item's movement to its own section.
If your Items had titles, here a different approach:
dataSource.reorderingHandlers.canReorderItem = {item in
let exclude = ["Library","Favorites","Recents","Search","All"]
if(exclude.contains(item.title!)){return false}
return true
}
You can also use such record in delegate method collectionView(_:targetIndexPathForMoveFromItemAt:toProposedIndexPath:)
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
if originalIndexPath.section != proposedIndexPath.section {
return originalIndexPath
}
return proposedIndexPath
}
I read this in Matt Neuburg «Programming iOS 14» book and it works fine:)

UITableView resets to start position before the user has released it

I have an app with an UITableView and its data gets updated regularly. If the data receives new element, the table view is reloaded. Let’s say the table view has place for 10 visible cells, but data for only 2 of them. The user has scrolled in either direction and not released the table view from touch. If the user has scrolled up, they may have hidden the first cell and only the second one would be visible. Then a new element is received and reloadData is called. Instead of waiting for releasing the table view to update, the tableview gets updated right away and the contentOffset is reset to 0. The tableView just resets to start position while the user has scrolled and not released.
I tried similar setup in separate Xcode project and the issue does not appear there. I wonder what the difference could be.
This is some of the code:
For the ViewController that is the dataSource:
func viewDidLoad() {
super.viewDidLoad()
//some other code
DataManager.shared.onElementReceival = { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return DataManager.shared.data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
cell.textLabel?.textColor = .white
cell.backgroundColor = .clear
if let name = DataManager.shared.data[indexPath.row].name {
cell.textLabel?.text = name
} else {
cell.textLabel?.text = "Unnamed"
}
return cell
}
From the DataManager
func didReceive(_ element: Element) {
data.append(element)
onElementReceival()
}
Try to reload only cell using:
tableview.insertRowsAtIndexPaths([IndexPath(forRow: Yourarray.count-1, inSection: 0)], withRowAnimation: .Automatic)
In your example:
let onElementReceival:(IndexPath) -> Void = { [weak self] inx in
guard let self = self else { return }
DispatchQueue.main.async {
tablview.beginUpdates()
tablview.insertRowsAtIndexPaths(at: [inx], with: .automatic)
tablview.endUpdates()
}
}
From DataManager
func didReceive(_ element: Element) {
data.append(element)
onElementReceival(IndexPath(row:data.count, section: 0))
}

Updating UICollectionView after deleting a Realm object

When I try to delete an item from a Realm database I am unable to update a UICollection View appropriately.
Lets assume a Realm container children of type List<Child>:
var children = realm.objects(Parent).first!.children
When I want to remove this child from the database by:
try! realm.write {
realm.delete(children[indexPath.row])
}
updating the collectionView by collectionView.deleteItemsAtIndexPaths([indexPath]) gives the following error:
Got error: *** Terminating app due to uncaught exception 'RLMException', reason: 'Object has been deleted or invalidated.'
The only way I get the collectionView updated is by using collectionView.reloadData(), but that is not what I want since the animation of a cell deletion is missing.
However, when I only remove a child from this container at indexPath.row (without removing it from the database) by:
try! realm.write {
children.removeAtIndex(indexPath.row)
}
updating the collectionView with collectionView.deleteItemsAtIndexPaths([indexPath]) works without problems.
What would be the best way to update a UICollectionView after removing an item from the database?
The error you're facing appears when you keep accessing an object, which was already deleted. So, you're storing likely somewhere a reference to your object, which is fine per se, but keep accessing it after it was invalidated.
That could happen e.g. in your custom subclass of UICollectionViewCell. I'd recommend to implement a setter on your cell and pull from that method the property values into your view components. You can even use KVO in your cell to update these. (We've an example based on ReactKit for that up in our repo.) You can't though keep accessing the properties when the object might be already deleted at a later point in time, e.g. if your cell needs to be drawn or layout when it is faded out.
I'd recommend to subscribe to fine-grained notifications for the list you're using to fill your collection view's cells and only propagate updates in that way to the collection view. In that way you can make sure that your items will be removed with a nice animation as requested and it is automatically taken care of. All put together this could look like seen below. Over at our repo, you'll find a complete runnable sample.
class Cell: UICollectionViewCell {
#IBOutlet var label: UILabel!
func attach(object: DemoObject) {
label.text = object.title
}
}
class CollectionViewController: UICollectionViewController {
var notificationToken: NotificationToken? = nil
lazy var realm = try! Realm()
lazy var results: Results<DemoObject> = {
self.realm.objects(DemoObject)
}()
// MARK: View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// Observe Notifications
notificationToken = results.addNotificationBlock { [weak self] (changes: RealmCollectionChange) in
guard let collectionView = self?.collectionView else { return }
switch changes {
case .Initial:
// Results are now populated and can be accessed without blocking the UI
collectionView.reloadData()
break
case .Update(_, let deletions, let insertions, let modifications):
// Query results have changed, so apply them to the UITableView
collectionView.performBatchUpdates({
collectionView.insertItemsAtIndexPaths(insertions.map { NSIndexPath(forRow: $0, inSection: 0) })
collectionView.deleteItemsAtIndexPaths(deletions.map { NSIndexPath(forRow: $0, inSection: 0) })
collectionView.reloadItemsAtIndexPaths(modifications.map { NSIndexPath(forRow: $0, inSection: 0) })
}, completion: { _ in })
break
case .Error(let error):
// An error occurred while opening the Realm file on the background worker thread
fatalError("\(error)")
break
}
}
}
deinit {
notificationToken?.stop()
}
// MARK: Helpers
func objectAtIndexPath(indexPath: NSIndexPath) -> DemoObject {
return results[indexPath.row]
}
// MARK: UICollectionViewDataSource
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return results.count
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
let object = objectAtIndexPath(indexPath)
try! realm.write {
realm.delete(object)
}
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let object = objectAtIndexPath(indexPath)
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! Cell
cell.attach(object)
return cell
}
}

Using long press gesture to reorder cells in tableview?

I want to be able to reorder tableview cells using a longPress gesture (not with the standard reorder controls). After the longPress is recognized I want the tableView to essentially enter 'edit mode' and then reorder as if I was using the reorder controls supplied by Apple.
Is there a way to do this without needing to rely on 3rd party solutions?
Thanks in advance.
EDIT: I ended up using the solution that was in the accepted answer and relied on a 3rd party solution.
They added a way in iOS 11.
First, enable drag interaction and set the drag and drop delegates.
Then implement moveRowAt as if you are moving the cell normally with the reorder control.
Then implement the drag / drop delegates as shown below.
tableView.dragInteractionEnabled = true
tableView.dragDelegate = self
tableView.dropDelegate = self
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { }
extension TableView: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
return [UIDragItem(itemProvider: NSItemProvider())]
}
}
extension TableView: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
if session.localDragSession != nil { // Drag originated from the same app.
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
return UITableViewDropProposal(operation: .cancel, intent: .unspecified)
}
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
}
}
Swift 3 and no third party solutions
First, add these two variables to your class:
var dragInitialIndexPath: IndexPath?
var dragCellSnapshot: UIView?
Then add UILongPressGestureRecognizer to your tableView:
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(onLongPressGesture(sender:)))
longPress.minimumPressDuration = 0.2 // optional
tableView.addGestureRecognizer(longPress)
Handle UILongPressGestureRecognizer:
// MARK: cell reorder / long press
func onLongPressGesture(sender: UILongPressGestureRecognizer) {
let locationInView = sender.location(in: tableView)
let indexPath = tableView.indexPathForRow(at: locationInView)
if sender.state == .began {
if indexPath != nil {
dragInitialIndexPath = indexPath
let cell = tableView.cellForRow(at: indexPath!)
dragCellSnapshot = snapshotOfCell(inputView: cell!)
var center = cell?.center
dragCellSnapshot?.center = center!
dragCellSnapshot?.alpha = 0.0
tableView.addSubview(dragCellSnapshot!)
UIView.animate(withDuration: 0.25, animations: { () -> Void in
center?.y = locationInView.y
self.dragCellSnapshot?.center = center!
self.dragCellSnapshot?.transform = (self.dragCellSnapshot?.transform.scaledBy(x: 1.05, y: 1.05))!
self.dragCellSnapshot?.alpha = 0.99
cell?.alpha = 0.0
}, completion: { (finished) -> Void in
if finished {
cell?.isHidden = true
}
})
}
} else if sender.state == .changed && dragInitialIndexPath != nil {
var center = dragCellSnapshot?.center
center?.y = locationInView.y
dragCellSnapshot?.center = center!
// to lock dragging to same section add: "&& indexPath?.section == dragInitialIndexPath?.section" to the if below
if indexPath != nil && indexPath != dragInitialIndexPath {
// update your data model
let dataToMove = data[dragInitialIndexPath!.row]
data.remove(at: dragInitialIndexPath!.row)
data.insert(dataToMove, at: indexPath!.row)
tableView.moveRow(at: dragInitialIndexPath!, to: indexPath!)
dragInitialIndexPath = indexPath
}
} else if sender.state == .ended && dragInitialIndexPath != nil {
let cell = tableView.cellForRow(at: dragInitialIndexPath!)
cell?.isHidden = false
cell?.alpha = 0.0
UIView.animate(withDuration: 0.25, animations: { () -> Void in
self.dragCellSnapshot?.center = (cell?.center)!
self.dragCellSnapshot?.transform = CGAffineTransform.identity
self.dragCellSnapshot?.alpha = 0.0
cell?.alpha = 1.0
}, completion: { (finished) -> Void in
if finished {
self.dragInitialIndexPath = nil
self.dragCellSnapshot?.removeFromSuperview()
self.dragCellSnapshot = nil
}
})
}
}
func snapshotOfCell(inputView: UIView) -> UIView {
UIGraphicsBeginImageContextWithOptions(inputView.bounds.size, false, 0.0)
inputView.layer.render(in: UIGraphicsGetCurrentContext()!)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
let cellSnapshot = UIImageView(image: image)
cellSnapshot.layer.masksToBounds = false
cellSnapshot.layer.cornerRadius = 0.0
cellSnapshot.layer.shadowOffset = CGSize(width: -5.0, height: 0.0)
cellSnapshot.layer.shadowRadius = 5.0
cellSnapshot.layer.shadowOpacity = 0.4
return cellSnapshot
}
You can't do it with the iOS SDK tools unless you want to throw together your own UITableView + Controller from scratch which requires a decent amount of work. You mentioned not relying on 3rd party solutions but my custom UITableView class can handle this nicely. Feel free to check it out:
https://github.com/bvogelzang/BVReorderTableView
So essentially you want the "Clear"-like row reordering right? (around 0:15)
This SO post might help.
Unfortunately I don't think you can do it with the present iOS SDK tools short of hacking together a UITableView + Controller from scratch (you'd need to create each row itself and have a UITouch respond relevant to the CGRect of your row-to-move).
It'd be pretty complicated since you need to get the animation of the rows "getting out of the way" as you move the row-to-be-reordered around.
The cocoas tool looks promising though, at least go take a look at the source.
There's a great Swift library out there now called SwiftReorder that is MIT licensed, so you can use it as a first party solution. The basis of this library is that it uses a UITableView extension to inject a controller object into any table view that conforms to the TableViewReorderDelegate:
extension UITableView {
private struct AssociatedKeys {
static var reorderController: UInt8 = 0
}
/// An object that manages drag-and-drop reordering of table view cells.
public var reorder: ReorderController {
if let controller = objc_getAssociatedObject(self, &AssociatedKeys.reorderController) as? ReorderController {
return controller
} else {
let controller = ReorderController(tableView: self)
objc_setAssociatedObject(self, &AssociatedKeys.reorderController, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return controller
}
}
}
And then the delegate looks somewhat like this:
public protocol TableViewReorderDelegate: class {
// A series of delegate methods like this are defined:
func tableView(_ tableView: UITableView, reorderRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath)
}
And the controller looks like this:
public class ReorderController: NSObject {
/// The delegate of the reorder controller.
public weak var delegate: TableViewReorderDelegate?
// ... Other code here, can be found in the open source project
}
The key to the implementation is that there is a "spacer cell" that is inserted into the table view as the snapshot cell is presented at the touch point, so you need to handle the spacer cell in your cellForRow:atIndexPath: call:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if let spacer = tableView.reorder.spacerCell(for: indexPath) {
return spacer
}
// otherwise build and return your regular cells
}
Sure there's a way. Call the method, setEditing:animated:, in your gesture recognizer code, that will put the table view into edit mode. Look up "Managing the Reordering of Rows" in the apple docs to get more information on moving rows.

Resources