How to select only 1 item per section using diffable datasource

540 views Asked by At

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 the image of the behavior that I need

This is the image of the behavior that currently I have

1

There are 1 answers

0
deniz On

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:

    struct SettingItem: Hashable {
        let name: String
    }

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:

    // selected item per section tracker
    private var stateTracker = [Int: String]()

    // indexpath of each selected item tracker
    private var indexPathTracker = [String: IndexPath]()

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:

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return }
        
        //check whether there was another item already selected in this section
        if let nameOfCurrentlySelectedItemInThisSection = stateTracker[indexPath.section] {
            //we need to unselect this item, get its indexpath from the other tracker
            if let indexPathToDeselect = indexPathTracker[nameOfCurrentlySelectedItemInThisSection] {
                collectionView.deselectItem(at: indexPathToDeselect, animated: false)
            }
        }


        // update the trackers
        stateTracker[indexPath.section] = selectedItem.name
        indexPathTracker[selectedItem.name] = indexPath
    }

You also need to handle the deselection events for example something like this:

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        //you need to remove the selection status from your trackers when a deselection occurs
        if let nameOfTheItemThatWasRemoved = stateTracker.removeValue(forKey: indexPath.section) {
            indexPathTracker.removeValue(forKey: nameOfTheItemThatWasRemoved)
        }
    }

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:

    ...
    ...
    dataSource.apply(snapshot, completion: {
        //notice that after a reload, the indexpath or section references in your trackers might not be applicable anymore
        //if this is the case, before you apply the snapshot recalculate your indexpath references with the new data
        //and update your trackers accordingly
        for (eachItemName, indexPath) in indexPathTracker {
            collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
        }
    })