1 year ago

#365709

test-img

thexande

UICollectionViewDiffableDataSource updates horizontally scrolling section incorrectly, but handles vertical section updates just fine

I’m attempting to create a collection view with 2 sections which supports live reloading of data. Leveraging UICollectionVIewDiffableDataSource, this should be quite straightforward.

final class ViewController: UIViewController {
    
    private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init())
    private var dataSource: UICollectionViewDiffableDataSource<Model.Section, Model.Item>?
    
    var model = Model.default {
        didSet {
            applyModel()
        }
    }
    
    typealias HorizontalRegistration = UICollectionView.CellRegistration<HorizontalView, Model.Item>
    
    private let horizontalRegistration = HorizontalRegistration { cell, _, model in
        guard case .horizontal(let props) = model else { return }
        cell.model = props
    }
    
    typealias VerticalRegistration = UICollectionView.CellRegistration<VerticalView, Model.Item>
    
    private let verticalRegistration = VerticalRegistration { cell, _, model in
        guard case .vertical(let props) = model else { return }
        cell.model = props
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        configureDataSource()
        collectionView.collectionViewLayout = makeLayout()
        
        self.model = Model(sections: [
            .init(items: (0..<20).map { .horizontal(.init(title: "\($0)", id: $0)) }, id: 1),
            .init(items: (0..<20).map { .vertical(.init(title: "\($0)", id: $0)) }, id: 2),
        ])
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            var model = self.model
            model.sections[0].items[0] = .horizontal(.init(title: "new", id: 0))
            model.sections[1].items[0] = .vertical(.init(title: "new", id: 0))
            self.model = model
        }
    }
    
    override func loadView() {
        view = collectionView
    }
    
    private func applyModel() {
        var snap = NSDiffableDataSourceSnapshot<Model.Section, Model.Item>()
        snap.appendSections(model.sections)
        model.sections.forEach {
            snap.appendItems($0.items, toSection: $0)
        }
        dataSource?.apply(snap)
    }
    
    private func configureDataSource() {
        dataSource = .init(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
            guard let self = self else { return nil }
            switch item {
            case .horizontal:
                return collectionView.dequeueConfiguredReusableCell(
                    using: self.horizontalRegistration,
                    for: indexPath,
                    item: item
                )
            case .vertical:
                return collectionView.dequeueConfiguredReusableCell(
                    using: self.verticalRegistration,
                    for: indexPath,
                    item: item
                )
            }
        }
    }
}

extension ViewController {
    struct Model: Hashable {
        var sections: [Section]
        static var `default`: Self { .init(sections: []) }
        struct Section: Hashable {
            var items: [Item]
            let id: Int
            static var `default`: Self { .init(items: [], id: -1) }
        }
        enum Item: Hashable {
            case horizontal(HorizontalView.Model)
            case vertical(VerticalView.Model)
        }
    }
}
    

My collection has 2 sections, one horizontally scrolling and one vertically scrolling. Using UICollectionViewCompositionalLayout, constructing such a layout is quite simple.

 private func makeLayout() -> UICollectionViewCompositionalLayout {
        .init { [weak self] index, _ in
            guard
                let self = self,
                self.model.sections.indices.contains(index),
                let itemType = self.model.sections[index].items.first
            else { return nil }

            switch itemType {
            case .vertical:
                let itemSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: .fractionalHeight(1.0)
                )
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: .absolute(80)
                )
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: groupSize,
                    subitem: item,
                    count: 1
                )
                let section = NSCollectionLayoutSection(group: group)
                return section
            case .horizontal:
                let itemSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: .fractionalWidth(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)

                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .absolute(160),
                    heightDimension: .absolute(218))
                let group = NSCollectionLayoutGroup.vertical(
                    layoutSize: groupSize,
                    subitem: item,
                    count: 1)
                group.contentInsets = NSDirectionalEdgeInsets(
                    top: 0,
                    leading: 0,
                    bottom: 0,
                    trailing: 0
                )

                let section = NSCollectionLayoutSection(group: group)
                section.interGroupSpacing = 8
                section.contentInsets = NSDirectionalEdgeInsets(
                    top: 24,
                    leading: 24,
                    bottom: 24,
                    trailing: 24
                )
                section.orthogonalScrollingBehavior = .groupPaging

                return section
            }
        }

Each cell type has a simple view model, with an ID and a title. Synthesized Hashable conformance should be enough for the diffable data source to determine I want to update an item, not remove it or move it.

final class HorizontalView: UICollectionViewCell {
    
    var model = Model.default {
        didSet {
            label.text = model.title
        }
    }

    private let label: UILabel = {
        let label = UILabel()
        label.font = .boldSystemFont(ofSize: 40)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    struct Model: Hashable {
        let title: String
        let id: Int
        static var `default`: Self { .init(title: "", id: -1) }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .green
        
        addSubview(label)
        label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

final class VerticalView: UICollectionViewCell {
    
    var model = Model.default {
        didSet {
            label.text = model.title
        }
    }
    
    private let label: UILabel = {
        let label = UILabel()
        label.font = .boldSystemFont(ofSize: 40)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    struct Model: Hashable {
        let title: String
        let id: Int
        static var `default`: Self { .init(title: "", id: -1) }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.borderWidth = 1
        layer.borderColor = UIColor.black.cgColor
        
        addSubview(label)
        label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

My issue is the following: The horizontal scrolling collection reloads the entire section any time I mutate the data source, even when the same identifiers are used as the previous view model. this results in the horizontal collection scrolling back to the beginning, as well as every item in the section flashing as it reloads.

horizontal reload does not function correctly

Alternatively, the vertically scrolling collection functions correctly with the same mutation. Scrolling is not reset to the beginning, and the mutated item intelligently reloads with new model. It is this functionality I would like to replicate in the horizontally scrolling collection.

vertical section inteligent reload works

Am I utilizing UICollectionViewDiffableDatasource incorrectly? Any ideas on why it works for the vertical section, but not the horizontal?

The project is available in a git repo here should anyone want to check it out on their machine. https://github.com/thexande/UICollectionViewDiffableDataSource_horizontal_reload_issue

ios

uicollectionview

uikit

uicollectionviewcell

uicollectionviewdiffabledatasource

0 Answers

Your Answer

Accepted video resources