UICollectionViewDiffableDataSource is replacing data instead of updating

1.1k Views Asked by At

I am trying to understand UICollectionViewDiffableDataSource and NSDiffableDataSourceSnapshot.

I have created a very crude version below. Essentially on load it should fetch photos.

On tap of a button in the navigation bar it fetches the next page. This is however just replacing the existing data, I was expecting it to append the vales to the array.

How should I update my data rather than replace it please?

class ViewController: UIViewController {

  lazy var collectionView = UICollectionView(frame: view.frame, collectionViewLayout: UICollectionViewFlowLayout())

  enum Section {
    case main
  }

  typealias DataSource = UICollectionViewDiffableDataSource<Section, AnyHashable>
  typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>

  private var dataSource: DataSource!
  private var snapshot = DataSourceSnapshot()

  override func viewDidLoad() {
    super.viewDidLoad()

    let updateButton = UIBarButtonItem(title: "Add", style: .plain, target: self, action: #selector(onTapLoad))
    navigationItem.rightBarButtonItem = updateButton

    view.addSubview(collectionView)
    collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
    collectionView.delegate = self
    collectionView.backgroundColor = .systemBackground
    collectionView.register(PhotoCell.self, forCellWithReuseIdentifier: "PhotoCell")

    dataSource = DataSource(collectionView: collectionView, cellProvider: { (cv, indexPath, object) -> PhotoCell? in

      if let object = object as? Photo {
        let cell = cv.dequeueReusableCell(withReuseIdentifier: "PhotoCell", for: indexPath) as! PhotoCell
        cell.backgroundColor = indexPath.item % 2 == 0 ? .systemTeal : .systemPink
        cell.label.text = object.title
        return cell
      }

      return nil
    })

    load()
  }

  @objc func onTapLoad() {
    load(page: 1)
  }

  func load(page: Int = 0) {
    PhotoLoader.shared.load(page: page) { result in
      if let photos = try? result.get() {
        self.apply(photos)
      }
    }
  }

  func apply(_ photos: [Photo]) {

    snapshot = DataSourceSnapshot()
    snapshot.appendSections([Section.main])
    snapshot.appendItems(photos)

    dataSource.apply(snapshot, animatingDifferences: false)
  }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    return .init(width: collectionView.frame.width - 32, height: 100)
  }
}

struct Photo: Decodable, Hashable {
  let id = UUID()
  let title: String
}

final class PhotoLoader {

  static let shared = PhotoLoader()

  func load(page: Int, completion: @escaping (Result<[Photo], Error>) -> Void) {

    URLSession.shared.dataTask(with: URL(string: "https://jsonplaceholder.typicode.com/photos?_start=\(page)&_limit=5")!, completionHandler: { data, response, error in
      if let data = data, let model = try? JSONDecoder().decode([Photo].self, from: data) {
        completion(.success(model))
      }
    }).resume()
  }
}

final class PhotoCell: UICollectionViewCell {
  lazy var label = UILabel(frame: .zero)

  override init(frame: CGRect) {
    super.init(frame: frame)

    label.translatesAutoresizingMaskIntoConstraints = false
    label.numberOfLines = 3
    addSubview(label)
    NSLayoutConstraint.activate([
      label.topAnchor.constraint(equalTo: topAnchor),
      label.leadingAnchor.constraint(equalTo: leadingAnchor),
      label.bottomAnchor.constraint(equalTo: bottomAnchor),
      label.trailingAnchor.constraint(equalTo: trailingAnchor)
    ])

  }

  required init?(coder: NSCoder) {
    return nil
  }

}

2

There are 2 best solutions below

0
On BEST ANSWER

Without using an extra array initialize the snapshot in viewDidLoad

override func viewDidLoad() {
    super.viewDidLoad()
    
    ...
    let snapshot = DataSourceSnapshot()
    snapshot.appendSections([Section.main])
    dataSource.apply(snapshot, animatingDifferences: false)
}

and in apply(_ photos) append the photos to the current snapshot rather than creating a new one

func apply(_ photos: [Photo]) {

    var snapshot = dataSource.snapshot()
    snapshot.appendItems(photos, toSection: .main)  
    dataSource.apply(snapshot, animatingDifferences: true)
}

The property is not needed

private var snapshot = DataSourceSnapshot()

Note:

Declare the datasource as specific as possible

typealias DataSource = UICollectionViewDiffableDataSource<Section, Photo>
typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, Photo>

Then you get rid of the unnecessary type check in

dataSource = DataSource(collectionView: collectionView, cellProvider: { (cv, indexPath, photo) -> PhotoCell? in

    let cell = cv.dequeueReusableCell(withReuseIdentifier: "PhotoCell", for: indexPath) as! PhotoCell
    cell.backgroundColor = indexPath.item % 2 == 0 ? .systemTeal : .systemPink
    cell.label.text = photo.title
    return cell
 
})
0
On

You are providing your apply(_ photos: [Photo]) method with a new data set each time. You need to provide it with an updated data set so the diffing logic can apply changes.

Right now you are just giving it new data each time.

Capture your Photos in an array and call apply using the didSet observer

  private var photos: [Photo] = [] {
    didSet { apply(photos) }
  }
.....
  func load(page: Int = 0) {
    PhotoLoader.shared.load(page: page) { result in
      if let photos = try? result.get() {
        self.photos.append(contentsOf: photos)
      }
    }
  }