NSCollectionViewDiffableDataSource를 apply를 했음에도 Cell의 내용이 바뀌지 않는다면

NSCollectionViewDiffableDataSource는 편리하다. 기존 dataSource와 다르게 SnapShot에 섹션과 데이터를 넣고, DiffableDatasource에 apply만 해주면, 애니메이션이 적용되면서 즉각적으로 변경이 된다.

 

하지만 사용할때 주의해야 할 점이 있다. 기존 CollectionViewDataSource를 채택해서 reloadData()를 수행 했을 때, CollectionView에 보이는 모드 셀이 reload 되면서 cellForItemAt 함수를 호출하는 방식이였다. 그래서 reload가 되면 셀이 바뀌든 바뀌지 않았든 해당 화면에서 보이는 셀은 다시 cellForItemAt에 의해 셀이 그려지는 방식이었다.

 

하지만 DiffableDataSource는 apply를 해도 셀 자체를 다시 그리지 않는다. 아래 예시를 한번 보자

 

enum Section {
    case main
}

struct Item: Hashable {
    let id: String
    var title: String
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        return lhs.id == rhs.id
    }
}

class ViewController: UIViewController {
    
    lazy var changeButton: UIButton = {
        let button = UIButton()
        button.backgroundColor = .blue
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(changeArr), for: .touchUpInside)
        return button
    }()
    
    @objc func changeArr() {
        var originArr = arr
        arr = originArr.reversed()
        updateCollectionView()
    }
    
    var collectionView: UICollectionView!
    
    var datasource: UICollectionViewDiffableDataSource<Section, Item>?
    var arr: [Item] = [.init(id: UUID().uuidString, title: "나"),.init(id: UUID().uuidString, title: "다"),.init(id: UUID().uuidString, title: "라"),.init(id: UUID().uuidString, title: "마")]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureCollectionView()
        makeDataSource()
        updateCollectionView()
        view.addSubview(changeButton)
        NSLayoutConstraint.activate([
            changeButton.heightAnchor.constraint(equalToConstant: 100),
            changeButton.widthAnchor.constraint(equalToConstant: 100),
            changeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            changeButton.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
    func configureCollectionView() {
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: makeLayout())
        view.addSubview(collectionView)
        self.collectionView = collectionView
    }
    
    func makeLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = .init(width: 100, height: 100)
        return layout
    }
    
    func makeDataSource()  {
        let cellRegistration = UICollectionView.CellRegistration<LabelCollectionViewCell, Item> { cell, indexPath, itemIdentifier in
            cell.label.textColor = .black
            cell.label.text = itemIdentifier.title
        }
        
        datasource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        })
    }
    
    func updateCollectionView() {
        var snapShot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapShot.appendSections([.main])
        snapShot.appendItems(arr)
        datasource?.apply(snapShot)
    }


}

 

복잡해 보이지만, 그냥 arr 배열에 있는 내용을 콜렉션뷰가 보여주고, 버튼을 클릭하면 역정렬한 배열을 apply해서 다시 보여주는 코드다.

gif 가 흐릿해서 잘 보이지 않지만 처음에는 [나, 다, 라, 마] 순서로 되어있다. 이때 버튼을 클릭할때, 배열의 첫번째 내용을 "가"로 바꾸고 apply를 하면 어떻게 될까? 코드대로라면 [나, 다, 라, 마] 에서 [가, 다, 라, 마]가 되고, 역순 정렬이 되므로 [마, 라, 다, 가] 가 되야 한다. 셀 역시 똑같은 순서여야 한다. 

@objc func changeArr() {
        var originArr = arr
        originArr[0].title = "가"
        arr = originArr.reversed()
        updateCollectionView()
    }

하지만 실제 결과는 첫번째 셀의 글자는 바뀌지 않고 셀의 내용 그대로 순서가 변경 되는것 을 볼 수 있다.

이러한 일이 발생하는 이유는 DiffableDataSource는 apply 될 때, hash값이 바뀌지 않으면 cellProvider를 호출하지 않는다. 따라서 cell의 내용이 바뀐 item에 동기화가 되지 않고 처음 cellProvider 가 호출되었을 때 할당된 값을 그대로 가지고 있는 것이다. 

 

따라서 이를 해결하기 위해서는 snapshot의 reload 기능을 이용한다.

func reloadSection(section: Section) {
        guard let dataSource else { return }
        var currentSnapshot = dataSource.snapshot()
        currentSnapshot.reloadSections([section])
        dataSource.apply(currentSnapshot)
    }

이제 정상적으로 셀의 내용이 바뀐것을 볼 수 있다.

 나는 reloadSections를 이용하여 지금 새로 reload 되는 애니메이션이 수행되고 있지만, snapshot에서 reload 하는것을 여러개 지원해주니 필요에 따라 사용하면 될 듯 하다.

이 문제 때문에 프로젝트에서 상당히 시간을 잡아먹혔다. DiffableDatasource의 apply와 reload는 엄연히 다르다는 것을 잊지 말자