Skip to content

Instantly share code, notes, and snippets.

@hellaandrew
Last active April 10, 2020 07:30
Show Gist options
  • Save hellaandrew/54e6918931515e70693ab5b01cbe85e5 to your computer and use it in GitHub Desktop.
Save hellaandrew/54e6918931515e70693ab5b01cbe85e5 to your computer and use it in GitHub Desktop.
This Xcode playground illustrates an issue with applying a new snapshot to the dataSource with animatingDifferences set to `true`
/// # DESCRIPTION
/// This playground generates a random amount of sections containing a random amount of items.
/// Each item has a `count` property which is meant to indicate how many of that item there is in the section which is also a random number.
/// The collection item cells display an item's `title`, `description` and `count`. There are two buttons, and `+` and `-` which increments or decrements the item's count value.
/// Each section in the collection view will draw a section header cell, which displays the section's `title` and how many items total there are in its section (all of the item counts added together)
/// If an items count ever reaches 0, the item is removed from the collection.
///
/// # ISSUE
/// The section headers data doesn't update if I apply the updated snapshot to the dataSource with `animatingDifferences` set to `true`.
/// The entire list updates correctly if I increment or decrement the number when it's above 0 because the logic I have has `animatingDifferences` set to `false` in those circumstances.
///
import UIKit
import PlaygroundSupport
protocol ItemCellDelegate: class {
func itemCountIncreased(model: MyViewController.ItemModel)
func itemCountDecreased(model: MyViewController.ItemModel)
}
class MyViewController : UIViewController, ItemCellDelegate {
// MARK: - Model Classes
class MasterModel: Hashable {
let id: UUID = UUID()
var sections: [SectionModel] = []
static func == (lhs: MasterModel, rhs: MasterModel) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(sections.hashValue)
}
}
class SectionModel: Hashable {
let id: UUID = UUID()
let title: String
var items: [ItemModel]
var itemsCountTotal: Int {
return items.reduce(0) { (result, itemModel) in
return result + itemModel.count
}
}
init(title: String, items: [ItemModel]) {
self.title = title
self.items = items
}
static func == (lhs: SectionModel, rhs: SectionModel) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id.hashValue)
hasher.combine(title.hashValue)
hasher.combine(itemsCountTotal.hashValue)
}
}
class ItemModel: Hashable {
let id: UUID = UUID()
let title: String
let description: String
var count: Int
init(title: String, description: String, count: Int) {
self.title = title
self.description = description
self.count = count
}
static func == (lhs: ItemModel, rhs: ItemModel) -> Bool {
return lhs.id == rhs.id && lhs.count == rhs.count
}
func hash(into hasher: inout Hasher) {
hasher.combine(id.hashValue)
hasher.combine(title.hashValue)
hasher.combine(description.hashValue)
hasher.combine(count.hashValue)
}
}
// MARK: - Cell Classes
class SectionHeaderCell: UICollectionViewCell {
lazy var titleLabel: UILabel = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.font = .systemFont(ofSize: 20, weight: .bold)
$0.textColor = .label
return $0
}(UILabel())
lazy var countLabel: UILabel = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.font = .systemFont(ofSize: 14, weight: .regular)
$0.textColor = .secondaryLabel
return $0
}(UILabel())
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(titleLabel)
contentView.addSubview(countLabel)
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
countLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
countLabel.firstBaselineAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor),
bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor)
])
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
}
class ItemCell: UICollectionViewCell {
lazy var titleLabel: UILabel = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.isUserInteractionEnabled = false
$0.font = .systemFont(ofSize: 16, weight: .medium)
$0.textColor = .label
return $0
}(UILabel())
lazy var descriptionLabel: UILabel = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.isUserInteractionEnabled = false
$0.font = .systemFont(ofSize: 14, weight: .regular)
$0.textColor = .secondaryLabel
return $0
}(UILabel())
lazy var countLabel: UILabel = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.isUserInteractionEnabled = false
$0.font = .systemFont(ofSize: 14, weight: .bold)
$0.textColor = .label
$0.alpha = 0.6
return $0
}(UILabel())
lazy var plusButton: UIButton = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.setImage(UIImage(systemName: "plus"), for: .normal)
$0.tintColor = .secondaryLabel
return $0
}(UIButton())
lazy var minusButton: UIButton = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.setImage(UIImage(systemName: "minus"), for: .normal)
$0.tintColor = .secondaryLabel
return $0
}(UIButton())
lazy var separatorView: UIView = {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.backgroundColor = .separator
$0.isUserInteractionEnabled = false
return $0
}(UIView())
weak var delegate: ItemCellDelegate?
var model: ItemModel!
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(titleLabel)
contentView.addSubview(descriptionLabel)
contentView.addSubview(countLabel)
contentView.addSubview(plusButton)
contentView.addSubview(minusButton)
contentView.addSubview(separatorView)
plusButton.addTarget(self, action: #selector(plusButtonTapped), for: .touchUpInside)
minusButton.addTarget(self, action: #selector(minusButtonTapped), for: .touchUpInside)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 8),
titleLabel.leftAnchor.constraint(equalTo: self.contentView.leftAnchor),
titleLabel.rightAnchor.constraint(lessThanOrEqualTo: countLabel.leftAnchor),
descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
descriptionLabel.leftAnchor.constraint(equalTo: self.contentView.leftAnchor),
descriptionLabel.rightAnchor.constraint(lessThanOrEqualTo: countLabel.leftAnchor),
countLabel.centerYAnchor.constraint(equalTo: plusButton.centerYAnchor),
countLabel.rightAnchor.constraint(equalTo: self.contentView.rightAnchor),
plusButton.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor),
plusButton.rightAnchor.constraint(equalTo: self.contentView.rightAnchor, constant: -48),
plusButton.widthAnchor.constraint(equalToConstant: 32),
plusButton.heightAnchor.constraint(equalToConstant: 32),
minusButton.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor),
minusButton.rightAnchor.constraint(equalTo: plusButton.leftAnchor, constant: -8),
minusButton.widthAnchor.constraint(equalToConstant: 32),
minusButton.heightAnchor.constraint(equalToConstant: 32),
separatorView.leftAnchor.constraint(equalTo: self.contentView.leftAnchor),
separatorView.rightAnchor.constraint(equalTo: self.contentView.rightAnchor),
separatorView.topAnchor.constraint(equalTo: self.contentView.bottomAnchor),
separatorView.heightAnchor.constraint(equalToConstant: 0.5),
self.contentView.bottomAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8)
])
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
@objc private func plusButtonTapped(_ sender: UIButton) {
delegate?.itemCountIncreased(model: model)
}
@objc private func minusButtonTapped(_ sender: UIButton) {
delegate?.itemCountDecreased(model: model)
}
}
class SectionBackgroundCell: UICollectionReusableView {
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .secondarySystemBackground
self.layer.cornerRadius = 8
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
}
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSourceReference!
let data = MasterModel()
override func viewDidLoad() {
super.viewDidLoad()
let randomSectionCount = Int.random(in: 3...10)
data.sections = (0..<randomSectionCount).map {
let randomItemsCount = Int.random(in: 5...10)
let randomItems: [ItemModel] = (0..<randomItemsCount).map {
let newItem = ItemModel(
title: "Item \($0 + 1)",
description: "Test description",
count: Int.random(in: 1...5)
)
return newItem
}
let sectionModel = SectionModel(
title: "Section \($0 + 1)",
items: randomItems
)
return sectionModel
}
collectionView = UICollectionView(
frame: view.bounds,
collectionViewLayout: configureLayout()
)
collectionView.backgroundColor = .systemFill
collectionView.delaysContentTouches = false
collectionView.contentInset = .init(top: 0, left: 0, bottom: 16, right: 0)
collectionView.register(
ItemCell.self,
forCellWithReuseIdentifier: "ItemCell")
collectionView.register(
SectionHeaderCell.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: "SectionHeaderCell")
collectionView.dataSource = configureDataSource()
view.addSubview(collectionView)
updateSnapshot()
}
private func configureLayout() -> UICollectionViewLayout {
let headerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44)
)
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
let collectionLayout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment -> NSCollectionLayoutSection? in
let layoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, subitem: item, count: 1)
let background = NSCollectionLayoutDecorationItem.background(elementKind: "background")
background.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.contentInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16)
layoutSection.decorationItems = [background]
layoutSection.boundarySupplementaryItems = [sectionHeader]
layoutSection.interGroupSpacing = 0.5
return layoutSection
}
collectionLayout.register(SectionBackgroundCell.self, forDecorationViewOfKind: "background")
return collectionLayout
}
private func configureDataSource() -> UICollectionViewDiffableDataSourceReference {
dataSource = UICollectionViewDiffableDataSourceReference(collectionView: collectionView) {
[weak self] collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
case let item as ItemModel:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ItemCell", for: indexPath)
if let cell = cell as? ItemCell {
cell.delegate = self
cell.model = item
cell.titleLabel.text = item.title
cell.descriptionLabel.text = item.description
cell.countLabel.text = "x\(item.count)"
if let section = self?.dataSource.snapshot().sectionIdentifiers[indexPath.section] as? SectionModel {
let isLastItemInSection = section.items.last == item
cell.separatorView.isHidden = isLastItemInSection
}
}
return cell
default:
return nil
}
}
dataSource.supplementaryViewProvider = { [weak self] (
collectionView: UICollectionView,
kind: String,
indexPath: IndexPath) -> UICollectionReusableView? in
guard let self = self else { return nil }
let snapshot = self.dataSource.snapshot()
switch kind {
case UICollectionView.elementKindSectionHeader:
let headerCell = collectionView.dequeueReusableSupplementaryView(
ofKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: "SectionHeaderCell",
for: indexPath)
guard let sectionModel = snapshot.sectionIdentifiers[indexPath.section] as? SectionModel else { return nil }
if let headerCell = headerCell as? SectionHeaderCell {
headerCell.titleLabel.text = sectionModel.title
let totalItems: Int = sectionModel.items.reduce(0) { (result, itemModel) in
return result + itemModel.count
}
headerCell.countLabel.text = "\(totalItems) items"
}
return headerCell
default:
return nil
}
}
return dataSource
}
private func updateSnapshot(animate: Bool = true) {
let snapshot = NSDiffableDataSourceSnapshotReference()
data.sections.forEach { section in
snapshot.appendSections(withIdentifiers: [section])
snapshot.appendItems(withIdentifiers: section.items)
}
dataSource.applySnapshot(snapshot, animatingDifferences: animate)
}
func itemCountIncreased(model: ItemModel) {
let currentSnapshot = dataSource.snapshot()
model.count += 1
currentSnapshot.reloadItems(withIdentifiers: [model])
dataSource.applySnapshot(currentSnapshot, animatingDifferences: false)
}
func itemCountDecreased(model: ItemModel) {
let currentSnapshot = dataSource.snapshot()
let sectionModel = currentSnapshot.sectionIdentifier(forSectionContainingItemIdentifier: model) as! SectionModel
model.count -= 1
if model.count <= 0 {
sectionModel.items.removeAll { $0 == model }
currentSnapshot.deleteItems(withIdentifiers: [model])
if sectionModel.items.isEmpty {
data.sections.removeAll { $0 == sectionModel }
currentSnapshot.deleteSections(withIdentifiers: [sectionModel])
}
dataSource.applySnapshot(currentSnapshot, animatingDifferences: true)
} else {
currentSnapshot.reloadItems(withIdentifiers: [model])
dataSource.applySnapshot(currentSnapshot, animatingDifferences: false)
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment