You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 11:52:02