Swift DiffableDataSource make insert&delete instead of reload
Asked Answered
D

3

10

I have a hard time to understand how DiffableDataSource works. I have ViewModel like this

struct ViewModel: Hashable {
  var id: Int
  var value: String

  func hash(into hasher: inout Hasher) {
     hasher.combine(id)
  }
}

I have tableView populated by cachedItems like ViewModele above. When API response arrives I want to add a new one, delete missing one, refresh viewModel.value of items already present in tableView and finally order it. Everything works fine except one thing - reloading items.

My understanding of DiffableDataSource was that it compares item.hash() to detect if the item is already present and if so then if cachedItem != apiItem, it should reload. Unfortunately, this is not working and snapshot does delete & insert instead of reloading.

Is DiffableDataSource supposed to do that?

Of course, I have a solution - to make it work I need to iterate through cachedItems, when new items contains the same id, I update cachedItem, then I applySnapshot without animation and after then i finally can applySnapshot with animation for deleting/inserting/ordering animation.

But this solution seems to be more like a hack than a valid code. Is there a cleaner way how to achieve this?

UPDATE:

There is the code showing the problem. It should work in playground. For example. items and newItems containt viewModel with id == 0. Hash is the same so diffableDataSource should just reload because subtitle is different. But there is visible deletion / inserting instead reload


import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {
    let tableView = UITableView()

    var  diffableDataSource: UITableViewDiffableDataSource<Section, ViewModel>?

    enum SelectesItems {
        case items
        case newItems
    }

    var selectedItems: SelectesItems = .items

    let items: [ViewModel] = [ViewModel(id: 0, title: "Title1", subtitle: "Subtitle2"),
    ViewModel(id: 1, title: "Title2", subtitle: "Subtitle2"),
    ViewModel(id: 2, title: "Title3", subtitle: "Subtitle3"),
    ViewModel(id: 3, title: "Title4", subtitle: "Subtitle4"),
    ViewModel(id: 4, title: "Title5", subtitle: "Subtitle5")]

    let newItems: [ViewModel] = [ViewModel(id: 0, title: "Title1", subtitle: "New Subtitle2"),
    ViewModel(id: 2, title: "New Title 2", subtitle: "Subtitle3"),
    ViewModel(id: 3, title: "Title4", subtitle: "Subtitle4"),
    ViewModel(id: 4, title: "Title5", subtitle: "Subtitle5"),
    ViewModel(id: 5, title: "Title6", subtitle: "Subtitle6")]

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white
        self.view = view

        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CellID")

        diffableDataSource = UITableViewDiffableDataSource<Section, ViewModel>(tableView: tableView, cellProvider: { (tableView, indexPath, viewModel) -> UITableViewCell? in
            let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "CellID")
            cell.textLabel?.text = viewModel.title
            cell.detailTextLabel?.text = viewModel.subtitle
            return cell
        })
        applySnapshot(models: items)

        let tgr = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        view.addGestureRecognizer(tgr)
    }

    @objc func handleTap() {
        switch selectedItems {
        case .items:
            applySnapshot(models: items)
            selectedItems = .newItems
        case .newItems:
           applySnapshot(models: newItems)
           selectedItems = .items
        }
    }

    func applySnapshot(models: [ViewModel]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, ViewModel>()
        snapshot.appendSections([.main])
        snapshot.appendItems(models, toSection: .main)
        diffableDataSource?.apply(snapshot, animatingDifferences: true)
    }
}

enum Section {
    case main
}

struct ViewModel: Hashable {
    let id: Int
    let title: String
    let subtitle: String

    func hash(into hasher: inout Hasher) {
       hasher.combine(id)
    }
}


// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Decant answered 8/6, 2020 at 16:52 Comment(1)
M
7

It's because you implemented Hashable incorrectly.

Remember, Hashable also means Equatable — and there is an inviolable relationship between the two. The rule is that two equal objects must have equal hash values. But in your ViewModel, "equal" involves comparing all three properties, id, title, and subtitle — even though hashValue does not, because you implemented hash.

In other words, if you implement hash, you must implement == to match it exactly:

struct ViewModel: Hashable {
    let id: Int
    let title: String
    let subtitle: String

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    static func ==(lhs: ViewModel, rhs: ViewModel) -> Bool {
        return lhs.id == rhs.id
    }
}

If you make that change, you'll find that the table view animation behaves as you expect.

If you also want the table view to pick up on the fact that the underlying data has in fact changed, then you also have to call reloadData:

    diffableDataSource?.apply(snapshot, animatingDifferences: true) {
        self.tableView.reloadData()
    }

(If you have some other reason for wanting ViewModel's Equatable to continue involving all three properties, then you need two types, one for use when performing equality comparisons plain and simple, and another for contexts where Hashable is involved, such as diffable data sources, sets, and dictionary keys.)

Mythologize answered 8/6, 2020 at 18:17 Comment(6)
Ok, but this is not solve my problem. When I add "==" method to compare just id, table view will not find out subtitle in item with index 0 is changed from "Subtitle2" to "New Subtitle2". I thought DiffableDataSource handles all changes. Does it mean there is no other solutions than before datasource.apply... I need update currentItems without animation and then apply snapshot with animation?Decant
OK I see what you mean. I'm afraid the best I can do is suggest that you also call reloadData. There is a delay, of course, but I don't know what to do about that; if you want the automatic animation, you just have to put up with that.Mythologize
Cool. Actually it never occurred to me that is possible call reloadData when i am using DiffableDataSource. Thank you. You made my day.Decant
“if you implement hash, you must implement == to match it exactly” – I don't think so. If == compares three properties (id, title and subtitle), and hash hashes only one of them (id) then the contract “equal objects must have the same hash values” is still satisfied.Currency
@MartinR Can you confirm that == doesn't have to match the hash value ?Rockwell
@BlazejSLEBODA: From developer.apple.com/documentation/swift/hashable: “Two instances that are equal must feed the same values to Hasher in hash(into:), in the same order.” It is not required that all values which determine identity are used for the hash value. (On the other hand, I do not know of a good reason to make this distinction.)Currency
C
1

I am using diffable datasource and compositional layout with an estimated cell height.

If you use the .reloadData() it will cause the cells to jump because of the .estimated(value).

I fixed this as follows:

diffableDataSource?.apply(snapshot, animatingDifferences: true) {
    self.collectionView.reloadData()
    // add this line here
    self.collectionView.collectionViewLayout.invalidateLayout()
}
Chaperone answered 14/6, 2022 at 11:51 Comment(0)
S
0

I suggest reading this article from Apple that perfectly explain all the issue you face. In short, if you want to implement perfect updates you need to:

  • Make sure you use item identifiers for UITableViewDiffableDataSource.ItemIdentifierType instead of items.
  • You track updates manually and add them to snapshot changes: For example:
    snapshot.reloadItems(updates) // updates is an array of item identifier that got updated
    
    For my project I just compare all new items with previous items before they change for equality.
Sludge answered 24/1, 2022 at 12:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.