애플사이다의 iOS 개발 일지

[CollectionView] Diffable DataSource 이해하기 (2/3) - 흔히 하는 실수, Modern Collection Views 예제 코드 본문

iOS/영문 공식문서 뜯어보기-iOS

[CollectionView] Diffable DataSource 이해하기 (2/3) - 흔히 하는 실수, Modern Collection Views 예제 코드

Applecider 2022. 5. 30. 10:05

저번에는 [CollectionView] Diffable DataSource 이해하기 (1/3) - Advances in UI Data Sources (WWDC19) 포스팅에서

Diffable DataSource의 장점과 기초 개념을 알아봤다.

이번에는 Implementing Modern Collection Views 예제 코드를 보면서 이해도를 높여보자.

흔히 하는 실수-1

Diffable DataSource는 간단해 보이지만 막상 써보면 막히는 부분이 있는데,

보통 프로젝트에서는 여러 종류의 Section을 사용하거나, 여러 종류의 Custom Cell을 쓰기 때문인 것 같다.

 

특히 처음 사용할 때 가장 흔히 발생하는 문제가 

"여러 Section에 동일한 Item을 반영하는 것" 때문이다.

이 경우 컴파일은 되지만, View를 보면 마지막 Section에만 Item이 나타나게 된다.

그리고 콘솔 로그에 아래의 오류 문구가 뜬다..!

[UIDiffableDataSource] Diffable data source detected an attempt to insert or append 5 item identifiers that already exist in the snapshot. The existing item identifiers will be moved into place instead, but this operation will be more expensive. For best performance, inserted item identifiers should always be unique. Set a symbolic breakpoint on BUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE__IDENTIFIER_ALREADY_EXISTS to catch this in the debugger. Item identifiers that already exist: ( // 중복 Item 정보 )

"snapshot에는 동일한 item이 들어갈 수 없다"는 내용이다. 

공식문서에서 Section 타입 (Section identifier)과 Item 타입 (Item identifier)은 모두 Hashable해야 한다고 했던 게 이 때문이다.

 

*그럼에도 불구하고 View에 동일한 콘텐츠를 나타내고 싶다면?

item identifier가 중복되지 않도록 Item 타입에 UUID 등 identifier 프로퍼티를 추가해서 다른 item인 척해야 한다.

흔히 하는 실수-2

DiffableDataSource를 사용할 때 Custom Cell에 데이터를 전달하는 방법도 낯설다.

 

기존에는 DataSource 역할을 하는 items 배열이 있고, items[indexPath.row] 형태로 데이터에 접근한다.

하지만 DiffableDataSource에서는 indexPath가 필요 없다.

Cell register를 할 때 제네릭 매개변수로 Item 타입을 지정해주면 알아서 Item 데이터가 전달된다.

snapshot을 통해 Item 데이터를 전달받는데, 이때 모든 Item이 Hashable하기 때문에 가능한 것이다.

// 제네릭 매개변수 ItemIdentifier에다가 Product 타입을 전달
let bannerCellRegistration = BannerCellRegistration<BannerCell, Product> { cell, indexPath, product in
//  cell.titleLabel.text = products[indexPath.row]  // 잘못된 예시
    cell.titleLabel.text = product  // cellProvider의 product (itemIdentifier)를 전달받음
}

이런 오류를 피하려면 애플이 만들어준 예제 코드를 자세히 살펴봐야 한다.


Implementing Modern Collection Views 예제 코드

3개 예제 코드를 살펴볼 텐데, 큰 틀은 모두 똑같다.

1️⃣ Cell을 등록하고, 2️⃣ Diffable DataSource를 생성하고, 3️⃣ Snapshot을 만들어 적용하면 된다.

예제를 볼 때, 가장 먼저 DataSource와 Snapshot의 제네릭 매개변수 <section, item>를 통해 타입을 확인하면 쉽다.

*위 과정이 이해되지 않는다면 이전 포스팅을 참고

Compositional layout도 중요한 주제인데 별도로 포스팅할 예정이다.

예제 종류-1. 새로운 Snapshot을 생성하여 적용 (1개 Section)

Diffable 폴더의 MountainsViewController 파일이다.

 

Section 종류도 1개이고, Item 및 Cell 타입도 1개 종류를 사용하는 가장 기본적인 예제이다.

처음에는 전체 데이터를 보여주도록 View를 그렸다가,

사용자 이벤트를 받으면 일부 데이터만 보이도록 View를 업데이트한다.

Mountains 예제코드

코드에서 2개만 확인하면 된다.

  1. 먼저 전체 Mountains 데이터를 하나씩 Cell에 담아 나타낸다.
    -> 전체 데이터로 inital snapshot을 생성
  2. 상단의 SearchBar에 사용자가 문자를 입력할 때마다, 입력값을 포함하는 검색결과만 필터링하여 View를 업데이트한다.
    -> 데이터를 필터링하여 새로운 snapshot을 생성
// Mountains 데이터 (Raw Data)
let mountainsRawData = """
Mount Everest,8848
K2,8611
Kangchenjunga,8586
Lhotse,8516
//...
"""

// ✅ Section 타입 (SectionIdentifier)
enum Section: CaseIterable {
    case main
}

// ✅ Item 타입 (ItemIdentifier)
struct Mountain: Hashable {
    let name: String
    let height: Int
    let identifier = UUID()  // Item identifier는 unique해야 하므로
    
    func hash(into hasher: inout Hasher) { // dataSource가 snapshot이 달라진 것 인식하기 위해 필요함
        hasher.combine(identifier)
    }
    static func == (lhs: Mountain, rhs: Mountain) -> Bool {
        return lhs.identifier == rhs.identifier
    }
    func contains(_ filter: String?) -> Bool {
        guard let filterText = filter else { return true }
        if filterText.isEmpty { return true }
        let lowercasedFilter = filterText.lowercased()  // 기존 데이터, 입력값 모두 소문자로 전환하여 비교
        return name.lowercased().contains(lowercasedFilter)
    }
}

class MountainsViewController: UIViewController {
    var mountainsCollectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>!
    
    func configureDataSource() {
        // 1️⃣ Cell 등록
        let cellRegistration = UICollectionView.CellRegistration
        <LabelCell, MountainsController.Mountain> { (cell, indexPath, mountain) in
            // Populate the cell with our item description.
            cell.label.text = mountain.name
        }
        
        // 2️⃣ DiffableDataSource 생성
        dataSource = UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>(collectionView: mountainsCollectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: MountainsController.Mountain) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
    }
}

// 3️⃣ Snapshot 생성 및 적용
// 1) 화면을 띄울 때 (viewDidLoad), 2) SearchBar 입력값이 바뀔 때 (textDidChange) 호출됨
func performQuery(with filter: String?) {
    // SearchBar 입력값을 포함하는 item만 필터링
    let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

    // ✅ 새로운 snapshot 생성
    var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>() 
    snapshot.appendSections([.main])
    snapshot.appendItems(mountains)  // 필터링된 item만 snapshot에 반영시킴  
    
    // ✅ "DiffiableDataSource야, 업데이트한 snapshot을 apply해서 View를 다시 그려줘"
    dataSource.apply(snapshot, animatingDifferences: true) 
}
  • 자세한 설명은 이전 포스팅을 참고

예제 종류-2. 2개 종류의 Section을 사용 (동일한 Item 타입)

다음은 Diffable 폴더의 WiFiSettingsViewController 파일이다.

 

설정 앱의 WiFi 탭과 비슷하다.

첫번째 Section은 WiFi 연결 상태 (ON/OFF, 현재 연결된 네트워크), 두번째 Section은 연결 가능한 네트워크를 보여준다.

WiFi 예제코드

이번에는 Section이 2개 종류이고, Item 및 Cell은 1개 종류를 사용한다.

어떻게 여러 Section을 구현하고, 각각 원하는 Item을 넣는지 보자.

// Section 타입 (2개 종류)
enum Section: CaseIterable {
    case config, networks  
}

// Item 타입
struct Item: Hashable {
    let title: String
    let type: ItemType
    let network: WiFiController.Network?
}

class WiFiSettingsViewController: UIViewController {
    let tableView = UITableView(frame: .zero, style: .insetGrouped)
    var dataSource: UITableViewDiffableDataSource<Section, Item>! = nil
    var currentSnapshot: NSDiffableDataSourceSnapshot<Section, Item>! = nil
    
    // 1️⃣ Cell 등록
    func configureTableView() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: WiFiSettingsViewController.reuseIdentifier)
        //...
    }
    
    // 2️⃣ DiffableDataSource 생성
    func configureDataSource() {
        self.dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell? in
            guard let self = self, let wifiController = self.wifiController else { return nil }
            let cell = tableView.dequeueReusableCell(withIdentifier: WiFiSettingsViewController.reuseIdentifier, for: indexPath)
            // cell의 상태 (isNetwork / isConfig)에 따라 Layout을 변경함 (생략)
        }   
    }
    
    // 3️⃣ snapshot 생성 및 적용
    func updateUI(animated: Bool = true) {
        guard let controller = self.wifiController else { return }
        let configItems = configurationItems.filter { !($0.type == .currentNetwork && !controller.wifiEnabled) }

        currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        currentSnapshot.appendSections([.config])  // ✅ 1번 Section 및 Item 설정
        currentSnapshot.appendItems(configItems, toSection: .config)

        // wifi가 연결되면 2번 Section이 나타남
        if controller.wifiEnabled {
            let sortedNetworks = controller.availableNetworks.sorted { $0.name < $1.name }
            let networkItems = sortedNetworks.map { Item(network: $0) }
            currentSnapshot.appendSections([.networks])  // ✅ 2번 Section 및 Item 설정
            currentSnapshot.appendItems(networkItems, toSection: .networks)
        }

        self.dataSource.apply(currentSnapshot, animatingDifferences: animated)
    }
}
  • appendSections(), appendItems()를 통해 각 Section마다 원하는 Item 데이터를 넣어준다.
    앞의 예제 코드와 뭐가 다를까?
    이번에는 Section 종류가 여러 개라서 appendItems(_:toSection:) 메서드로 특정 Section을 지정하여 Item을 넣는다. 
  • 또한 호출 순서도 중요하다.
    appendSections([.section1]) - appendItems(items1, toSection: .section1) - appendSections([.section2]) - appendItems(items2, toSection: .section2) 순으로 호출해야 한다. 
    (appendSections([.section1]) - appendSections([.section2]) 등으로 호출하면 안된다는 뜻)

✨ 예제 종류-3. Section마다 다른 Cell 타입을 사용

Compositional Layout 폴더의 Basics View Controllers 폴더의 DistinctSectionsViewController 파일이다.

 

이번에는 Section이 3개 종류이고, Cell은 2개 종류를 사용한다.

그리고 Section 마다 Item의 Layout을 다르게 설정했다.

Distinct Sections 예제코드

코드에서 2개만 확인하면 된다.

  1. Section마다 다른 Cell 타입을 사용했다.
    -> 첫번째 Section은 List 형태 (ListCell 타입)로, 두/세번째 Section은 Grid 형태 (TextCell 타입)로 dequeue한다.
  2. 또한 Section 마다 한 줄에 몇 개의 Item을 보여줄지 다르게 설정했다.
    -> 첫번째 Section은 한 줄에 Item이 1개, 두번째 Section은 5개, 세번째 Section은 3개씩 배치된다.
// Section별로 다른 Layout을 사용
enum SectionLayoutKind: Int, CaseIterable {
    case list, grid5, grid3  // rawValue가 각각 0,1,2로 지정됨

    // createLayout()에서 호출되며, 1개 row에 몇 개의 item이 들어갈지 설정함
    var columnCount: Int {  
        switch self {
        case .grid3:
            return 3
        case .grid5:
            return 5
        case .list:
            return 1
        }
    }
}

class DistinctSectionsViewController: UIViewController {
    var dataSource: UICollectionViewDiffableDataSource<SectionLayoutKind, Int>! = nil
    var collectionView: UICollectionView! = nil

    func configureDataSource() {
        // 1️⃣ Cell 등록 
        // 2개 종류의 Cell Type을 사용 (ListCell / TextCell)
        let listCellRegistration = UICollectionView.CellRegistration<ListCell, Int> { (cell, indexPath, identifier) in
            cell.label.text = "\(identifier)"
        }

        let textCellRegistration = UICollectionView.CellRegistration<TextCell, Int> { (cell, indexPath, identifier) in
            cell.label.text = "\(identifier)"
            //...
        }
        
        // 2️⃣ DiffableDataSource 생성
        // ✅ 주의 - dataSource 프로퍼티를 2개로 나누지 않음!
        dataSource = UICollectionViewDiffableDataSource<SectionLayoutKind, Int>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
            // 일반적인 dataSource를 사용할 때처럼 화면에 보이는 Cell의 개수만큼 호출됨
            // ✅ Setion이 .list이면 ListCell 타입, .grid5, .grid3이면 TextCell 타입으로 dequeue
            return SectionLayoutKind(rawValue: indexPath.section)! == .list ?
            collectionView.dequeueConfiguredReusableCell(using: listCellRegistration, for: indexPath, item: identifier) :
            collectionView.dequeueConfiguredReusableCell(using: textCellRegistration, for: indexPath, item: identifier)
        }

        // 3️⃣ snapshot 생성 및 적용
        // initial data
        let itemsPerSection = 10
        var snapshot = NSDiffableDataSourceSnapshot<SectionLayoutKind, Int>()
        SectionLayoutKind.allCases.forEach {
            snapshot.appendSections([$0])
            // ✅ Section마다 다른 Item을 지정 - Section1은 1~9, Section2는 10~19, Section3은 20~29로 구성
            let itemOffset = $0.rawValue * itemsPerSection
            let itemUpperbound = itemOffset + itemsPerSection
            snapshot.appendItems(Array(itemOffset..<itemUpperbound))
        }
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}
  • dequeue 메서드가 호출되는 시점은 언제일까?
    초기화면에서는 일반적인 dataSource를 사용할 때처럼 "화면에 보이는 Cell의 개수만큼" 호출된다.
  • 이때 Setion이 .list이면 ListCell 타입, .grid5, .grid3이면 TextCell 타입으로 dequeue한다.
    Cell 타입이 다르다고 dataSource를 여러 개 만들면 안된다. dataSource는 1개여야 한다.
  • Snapshot을 생성하는 부분은 2번 예제와 비슷하지만 살짝 다르다.
    appendSections(), appendItems() 메서드를 사용한다. (appendItems(_:toSection:) 메서드를 쓰지 않았음)
  • 이 예제에서 Section에 들어가는 Item의 타입은 모두 Int이다.
    만약 Section마다 다른 타입의 Item을 넣어주고 싶다면?
    해당 Item들을 추상화 (프로토콜 타입)해서 사용해야 한다.

참고 - Compositional Layout을 사용하여 Item의 배치 형태를 설정하는 코드는 아래와 같다.

func createLayout() -> UICollectionViewLayout {
    // ✅ sectionProvider를 통해 CollectionView 관련 정보를 받아서 활용함 (cellProvider와 비슷)
    let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int,
        layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

        guard let sectionLayoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }  // section의 rawValue로 column을 찾아둠
        let columns = sectionLayoutKind.columnCount  // ✅ Section 종류별로 column 개수가 다름

        // The group auto-calculates the actual item width to make
        // the requested number of columns fit, so this widthDimension is ignored.
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                             heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

        let groupHeight = columns == 1 ?
            NSCollectionLayoutDimension.absolute(44) :
            NSCollectionLayoutDimension.fractionalWidth(0.2)
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: groupHeight)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns) // ✅ count의 매개변수로 전달됨

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
        return section
    }
    return layout
}

예제 종류-4. Header / Footer 구현 

Compositional Layout 폴더의 Basics View Controllers 폴더의 SectionHeadersFootersViewController 파일이다.

 

마지막으로 Header와 Footer를 구현한 코드를 보자.

아주 간단하다. 1️⃣ Cell을 등록하고, 2️⃣ Diffable DataSource에 끼워넣기만 하면 된다.

 

위에서 Item에 들어갈 Cell을 등록했듯이 Header/Footer에 들어갈 Cell을 등록하면 된다.

이때 Cell은 UICollectionReusableView를 상속받도록 한다.

Headers/Footers 예제코드

// ✅ Custom Header/Footer Cell 타입
class TitleSupplementaryView: UICollectionReusableView {
    let label = UILabel()
    //...
}
    
class SectionHeadersFootersViewController: UIViewController {
    static let sectionHeaderElementKind = "section-header-element-kind"
    static let sectionFooterElementKind = "section-footer-element-kind"

    var dataSource: UICollectionViewDiffableDataSource<Int, Int>! = nil
    var collectionView: UICollectionView! = nil

    func configureDataSource() {
        // 1️⃣ Header Cell 등록 
        let headerRegistration = UICollectionView.SupplementaryRegistration
        <TitleSupplementaryView>(elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind) {
            (supplementaryView, string, indexPath) in
            supplementaryView.label.text = "\(string) for section \(indexPath.section)"
            //...
        }
        
        // 1️⃣ Footer Cell 등록 
        let footerRegistration = UICollectionView.SupplementaryRegistration
        <TitleSupplementaryView>(elementKind: SectionHeadersFootersViewController.sectionFooterElementKind) {
            (supplementaryView, string, indexPath) in
            supplementaryView.label.text = "\(string) for section \(indexPath.section)"
            //...
        }
            
        dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
        
        // 2️⃣ DiffableDataSource에 끼워넣기
        dataSource.supplementaryViewProvider = { (view, kind, index) in
            return self.collectionView.dequeueConfiguredReusableSupplementary(
                using: kind == SectionHeadersFootersViewController.sectionHeaderElementKind ? headerRegistration : footerRegistration, for: index)
        }

        // Snapshot 생성 및 적용 (다른 예제와 동일함)
        // initial data
        let itemsPerSection = 5
        let sections = Array(0..<5)
        var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
        var itemOffset = 0
        sections.forEach {
            snapshot.appendSections([$0])
            snapshot.appendItems(Array(itemOffset..<itemOffset + itemsPerSection))
            itemOffset += itemsPerSection
        }
        dataSource.apply(snapshot, animatingDifferences: false)
    }
  • Custom Header/Footer Cell을 구현하고, 해당 Cell을 등록했다.
  • dataSource에 끼워넣을 때는 dataSource.supplementaryViewProvider에 클로저를 전달하면 된다.

마지막으로 Compositional Layout을 생성하는 부분에서도 작업이 필요하다.

func createLayout() -> UICollectionViewLayout {
    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(44))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 5
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

    // ✅ Header/Footer 관련 설정
    let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                 heightDimension: .estimated(44))
    let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize,
        elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
    let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize,
        elementKind: SectionHeadersFootersViewController.sectionFooterElementKind, alignment: .bottom) 
    // ✅ 주의 - alignment는 Header는 .top, Footer는 .bottom으로 설정
    section.boundarySupplementaryItems = [sectionHeader, sectionFooter]

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}
  • section의 boundarySupplementaryItems에 Header 및 Footer를 넣어주면 된다.
  • 이때 alignment는 Header는 .top, Footer는 .bottom으로 설정해야 한다.

 

다음 포스팅에서는 개인적으로 인디앱 개발 프로젝트를 진행하면서 Diffable DataSource를 적용했던 코드를 살펴보려고 한다.

최근 E-commerce 분야 상용앱에서 자주 보이는 화면인데,

화면 상단에 Horizontal Scroll이 가능한 배너 (Banner Section)와 Vertical Scroll이 가능한 상품 목록 (List Section)을 CollectionView로 구현해봤다.

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

 

Comments