如何使用Diffable DataSource实现每个Section仅选中一项
问题:实现UICollectionView List每个Section单选功能并修复reconfigure崩溃问题
需求说明
当前代码支持UICollectionView List的多选功能,需要修改为每个Section仅允许选中一项。目前通过删除再插入的方式更新选中状态以显示勾选标记,但尝试使用snapshot.reconfigureItems([settingItem])时崩溃,提示“尝试更新不存在的元素”,该问题与SettingItem的Hashable特性相关。
效果对比
- 预期效果:每个Section内仅有一个选项显示勾选标记,点击其他选项时自动取消同Section内原选中项的勾选。
- 当前效果:同一Section内可同时勾选多个选项。
原代码
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 }
问题原因分析
SettingItem默认使用合成的Hashable实现,其哈希值包含isSelected属性。当修改isSelected后,updatedSelectedItem的哈希值与原selectedItem不同,系统会认为这是两个完全不同的item。此时调用snapshot.reconfigureItems([settingItem])时,snapshot中找不到对应的item,因此触发崩溃。
另外,当前的多选逻辑没有处理同Section内的其他选项,无法满足单选需求。
最优解决方案
1. 修复SettingItem的Hashable实现
自定义Hashable,让item的身份仅由唯一标识(name)决定,忽略可变的isSelected状态:
struct SettingItem: Hashable { let name: String var isSelected: Bool // 自定义哈希计算,仅基于name func hash(into hasher: inout Hasher) { hasher.combine(name) } // 自定义相等判断,仅比较name static func == (lhs: SettingItem, rhs: SettingItem) -> Bool { lhs.name == rhs.name } }
2. 实现Section单选逻辑
修改UICollectionViewDelegate的didSelectItemAt方法,处理同Section内的选中状态切换,并使用reconfigureItems高效更新UI:
extension ListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return } let sectionIdentifier = dataSource.snapshot().sectionIdentifier(for: indexPath.section)! // 获取当前section的所有item let sectionItems = dataSource.snapshot().items(inSection: sectionIdentifier) // 生成更新后的item数组:仅选中当前点击的item,取消其他项的选中状态 let updatedItems = sectionItems.map { item in var updatedItem = item updatedItem.isSelected = item.name == selectedItem.name return updatedItem } // 应用更新,使用reconfigureItems避免删除插入操作 var snapshot = dataSource.snapshot() snapshot.reconfigureItems(updatedItems) dataSource.apply(snapshot, animatingDifferences: true) } }
3. (可选)优化ViewModel状态管理
为了符合MVVM架构,让ViewModel统一维护数据源状态,避免View和ViewModel数据不一致:
extension ListViewController { @MainActor final class ViewModel { var sections: [SettingSection] // 改为可变属性 init(sections: [SettingSection]) { self.sections = sections } // 新增方法:更新指定section的选中项 func updateSelectedItem(inSection sectionName: String, selectedItemName: String) { guard let sectionIndex = sections.firstIndex(where: { $0.name == sectionName }) else { return } sections[sectionIndex].settingItems = sections[sectionIndex].settingItems.map { item in var updatedItem = item updatedItem.isSelected = item.name == selectedItemName return updatedItem } } // 原有方法保持不变 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) } } }
对应的修改didSelectItemAt方法:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return } let sectionIdentifier = dataSource.snapshot().sectionIdentifier(for: indexPath.section)! // 更新ViewModel中的状态 viewModel.updateSelectedItem(inSection: sectionIdentifier, selectedItemName: selectedItem.name) // 从ViewModel获取当前section的最新item数组 guard let updatedSection = viewModel.sections.first(where: { $0.name == sectionIdentifier }) else { return } let updatedItems = updatedSection.settingItems var snapshot = dataSource.snapshot() snapshot.reconfigureItems(updatedItems) dataSource.apply(snapshot, animatingDifferences: true) }
方案优势
- 自定义
Hashable后,isSelected变化不会改变item的身份,reconfigureItems可以正常工作,相比删除插入操作更高效。 - 单选逻辑清晰,一次性处理同Section内的所有状态变化,UI更新连贯自然。
- 优化ViewModel状态管理后,数据来源统一,避免了View和ViewModel的状态不一致问题,更符合MVVM架构设计。
内容的提问来源于stack exchange,提问作者Fernando Cruz
相关产品推荐
相关产品推荐

