Last active
April 10, 2020 07:30
-
-
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`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// # 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