I have the following code
final class ListViewController: UIViewController {
let viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Data Source
private func makeDataSource() -> UICollectionViewDiffableDataSource<String, SettingItem> {
let cellRegistration = UICollectionView
.CellRegistration<UICollectionViewListCell, SettingItem> { [viewModel] cell, _, settingItem in
var configutation = UIListContentConfiguration.cell()
configutation.text = viewModel.cellTitle(for: settingItem)
cell.contentConfiguration = configutation
cell.accessories = [
.checkmark(displayed: .always, options: .init(isHidden: !settingItem.isSelected))
]
}
let headerRegistration = UICollectionView
.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView
.elementKindSectionHeader) { [viewModel] supplementaryView, _, indexPath in
var configutation = UIListContentConfiguration.groupedHeader()
configutation.text = viewModel.headerTitle(in: indexPath.section)
supplementaryView.contentConfiguration = configutation
}
let dataSource = UICollectionViewDiffableDataSource<String, SettingItem>(collectionView: collectionView,
cellProvider: { collectionView, indexPath, settingItem in
collectionView
.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: settingItem
)
})
dataSource.supplementaryViewProvider = { collectionView, _, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
return dataSource
}
private lazy var dataSource = makeDataSource()
// MARK: Loading a View
private func makeCollectionView() -> UICollectionView {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .systemBackground
view.translatesAutoresizingMaskIntoConstraints = false
view.delegate = self
return view
}
private lazy var collectionView = makeCollectionView()
override func viewDidLoad() {
super.viewDidLoad()
title = .localized(.settings)
let doneButton = UIBarButtonItem(barButtonSystemItem: .done,
target: self,
action: #selector(doneButtonTapped))
navigationItem.rightBarButtonItem = doneButton
view.addSubview(collectionView)
NSLayoutConstraint.activate(
collectionView.constraints(pinningTo: view, edges: [.all])
)
viewModel.reloadContent(in: dataSource)
}
@objc
private func doneButtonTapped() {
dismiss(animated: true)
}
}
extension ListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return }
// update the item
var updatedSelectedItem = selectedItem
updatedSelectedItem.isSelected.toggle()
// update snapshot
var newSnapShot = dataSource.snapshot()
newSnapShot.insertItems([updatedSelectedItem], beforeItem: selectedItem)
newSnapShot.deleteItems([selectedItem])
dataSource.apply(newSnapShot)
}
}
extension ListViewController {
@MainActor final class ViewModel {
let sections: [SettingSection]
init(sections: [SettingSection]) {
self.sections = sections
}
func cellTitle(for settingItem: SettingItem) -> String {
settingItem.name
}
func headerTitle(in section: Int) -> String {
sections[section].name
}
func reloadContent(in dataSource: UICollectionViewDiffableDataSource<String, SettingItem>) {
var snapshot = NSDiffableDataSourceSnapshot<String, SettingItem>()
snapshot.appendSections(sections.map(\.name))
sections.forEach { section in
snapshot.appendItems(section.settingItems, toSection: section.name)
}
dataSource.apply(snapshot)
}
}
}
struct SettingSection: Hashable {
let name: String
let settingItems: [SettingItem]
static let language = SettingSection(name: "Section 1", settingItems: [
SettingItem(name: "value 1", isSelected: true),
SettingItem(name: "value 2", isSelected: false)
])
static let dateFormat = SettingSection(name: "Section 2", settingItems: [
SettingItem(name: "value 3", isSelected: false),
SettingItem(name: "value 4", isSelected: false)
])
}
struct SettingItem: Hashable {
let name: String
var isSelected: Bool
}
and I need to select only 1 item per section, I have been trying but right now you can select multiple items and I don't know which is the best way, since in the didSelect I'm inserting and deleting the item to update the dataSource and display the checkmark, and that is because if I try just doing
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([settingItem])
dataSource.apply(snapshot, animatingDifferences: true)
It crashes because it says I'm trying to update an element that does not exist, I guess it is something related with the hash but not sure
This is actually a great question and there are couple of different strategies to pursue. But I will focus on only one to keep this answer short. The main problem I see is that you are including the state of your cell as a part of your model (SettingItem). The state can be anything like selected, highlighted, disabled etc... When you are using DiffableDatasource it might be better to manage the state of your items in a separate array. So you configure the collectionview such that it manages the selected/highlighted states of each cell. This recommendation is also inline with various examples/tutorials that Apple provides as I personally have not seen where the state of the cell is also part of the model. If you decide to follow this advice, your data model simplifies to this:
However, this brings another problem because the moment you reload collectionview all state information in the collectionview will also be erased. This is an oversight from Apple's SDK IMHO. There are situations where you want to reload the data but you want to preserve previous selections, for example. This requires you to create a separate array/collections where you personally keep track of the state of each cell after it changes. One way to achieve is this:
So in your
func collectionView(_:didSelectItemAt:)delegate method you update the trackers and then perform deselection if there is already another item selected in that section. Something like this:You also need to handle the deselection events for example something like this:
Notice that you no longer need to reload the data source after each selection. However, you might still find that in a different situation a reload is necessary, for example because the backing database has changed. Since we delegated the visual management of the state to the collectionview, the selected information will be lost after a reload. In order to counter that you can reapply your selections to the collection view since you were keeping track of them in a separate collection: