iOS

[ iOS ] 데이터 유무에 따라 CollectionView UI 변경하기 with DiffableDataSource, Snapshot

Forest Yun 2024. 6. 17. 20:36
728x90

 

 

글을 게시한 이후 코드에 문제가 있다는 걸 발견해 이를 수정했습니다!

문제를 해결하기까지 시행착오가 궁금하시다면 이 글 봐주세요~

 

[ iOS 시행착오 ] CollectionView에 Cell이 하나만 보이는 이유

👩‍💻배경👩‍💻ChapterList를 개발하던 중, 분명 item이 3개가 있는데 cell이 1개만 보이는 문제가 발생했다! 그래서 이전에 작업했던 BookList에서도 확인해 보니 똑같은 문제가 발생했다.. BookList

88yhtserof.tistory.com

 

 

 

 

 

 

미리보기

 

 

 

1️⃣ 구현하기 전에 먼저 알아보기

 

☝️ 구현 간략 요약

Cell과 Section, Item을 BookList와 Empty 두 경우로 구분하여, 데이터 유무를 확인한 후 DiffableDataSource에 Cell을 등록하고 SnapShot에 Section과 Item를 할당해 UI를 구현한다.

 

 

 

📖 DiffableDataSource와 SnapShot

기존에 사용해왔던 CollectionView와 TableView는 일차원적인 데이터 구성을 가졌다. 하지만 데이터의 증가로 다차원의 데이터 구성과 늘어나는 기능들로 복잡해졌다. 이 리스트들의 UI를 업데이트하는 담당자는 바로 Controller인데, Controller는 사실 UI Layer 업데이트말고도 CoreData, Web Service 등 모델들과의 상호작용도 해야하기 때문에 하는 일이 많아 데이터와 UI 간의 Sync를 맞추는데에 문제가 많았다. 따라서 Apple은 WWDC19에서 DiffableDataSource를 발표했다.

여기서 언급했던 오류는 나도 경험해본 오류였다. 그때 구글링을 통해 그건 해결이 어렵다는 얘기를 여러 개발자들에게 들을 수 있었는데, 에러를 경험해본 후 이렇게 WWDC에서 해당 에러를 만나게 되니 왜 Apple이 새로운 기술을 도입했는지 정말 잘 이해할 수 있었다.

 

다시 돌아가서 DiffableDataSource를 사용해 UI Layer를 업데이트 하기 위해 개발자는 DataSource에 SnapShot을 제공해야한다.

SnapShot은 용어에서 알 수 있듯 사진을 찍는 것이다. 정확하게 말하자면 순간을 포착하는 것이다. 이 의미를 기반으로 생각하면 쉽다. 개발자가 UI를 구성해서 사진 찍고 DataSource에게 이대로 화면에 display해줘 하는 것이다. 데이터가 변경되어 UI를 업데이트 해야할 경우, 개발자는 새 데이터로 다시 UI를 구성하고 사진을 찍은 다음에 DataSource에게 이걸로 바꿔줘라고 요청하는 것이다.

 

새롭게 도입된 기술로 collection view를 구성해보며 Snapshot이 정말 간단하고 직관적이라고 느꼈다. 변경사항이 생기면 update 시점을 고려할 필요없이 그저 Snapshot을 DataSource에 재적용해주면 될 뿐이어서 정말 편했다.

 

Advances in UI Data Sources - WWDC19 - Videos - Apple Developer

Use UI Data Sources to simplify updating your table view and collection view items using automatic diffing. High fidelity, quality...

developer.apple.com

 

 

 


 

 

 

 

🧚🏻‍♀️ UI를 갱신하는 3가지 단계

Three-step Cycle:

  1. Get a Snapshot
    : Snapshot을 생성하세요
  2. Populate it
    : 생성한 Snapshot에 해당하는 Section과 Item 목록을 덧붙이세요
  3. Apply it
    : 지금까지 구성한 Snapshot을 DataSource에 적용하세요
func updateSnapshot() {
    let books = [Book("원스"), Book("해리포터"), Book("셜록홈즈")] // 데이터

    // 1) Get a Snapshot
    var snapshot = NSDiffableDataSourceSnapshot<Int, Book>()

    // 2) Populate it
    snapshot.appendSections([0])
    snapshot.appendItems(books)

    // 3) Apply it
    dataSource.apply(snapshot)

}

 

 

 

🧚‍♂️ CollectionView에 데이터를 채우는 4가지 단계

To fill a collection view with data:

  1. Connect a diffable data source to your collection view.
    : diffable data source를 collection view에 연결하세요
  2. Implement a cell provider to configure your collection view’s cells.
    : cell을 구성하기 위해 cell provider를 구현하세요 (+ cell provider를 위해 cell Registration을 통한 cell 등록)
  3. Generate the current state of the data.
    : 현재 collection view에서 보여줄 데이터 생성하세요
  4. Display the data in the UI.
    : UI에서 데이터 보여주세요
var dataSource: NSDiffableDatasource<Int, Book>!

func configureDataSource() {
   // 2. Implement a cell provider to configure your collection view’s cells
   let cellRegisteration = CollectionView.CellRegisteration(handler: cellRegisterationHandler)

   dataSource = NSDiffableDataSource<Int, Book>(collectionView: collectionView, cellProvider: {
       (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: Book) in
       return collectionView.dequeueConfigureReusableCell(using: cellRegisteration, for indexPath, item: itemIdetifier)
   })

   // 3. Generate the current state of the data.
   // 4. Display the data in the UI. <- dataSource.apply(snapshot)
   updateSnapshot()

   //  1. Connect a diffable data source to your collection view.
   collectionView.dataSource = dataSource
}

func cellRegisterationHandler(cell: UICollectionViewListCell, indexPath: IndexPath, item: Book) {
   cell.text = Book.title
}

 

 

 


 

 

 

 

2️⃣ BookList 목록과 Empty 목록 구현하기

 

👩🏻‍🎨 CollectionView의 Layout 구성하기

collection view의 레이아웃은 데이터가 있는 버전인 BookListLayout데이터가 없는 EmptyListLayout으로 나눠 생성한다. 만약 데이터 유무에 따라 레이아웃이 달라질 필요가 없다면 하나만 생성해도 된다. 나는 EmptyList가 화면에 표시될 경우 cell이 화면 전체를 덮고 있었으면 해서 Layout부터 구분해 구현했다.

// BookListViewController.swift

//MARK: - Layout
extension BookListViewController {
    func bookListLayout() -> UICollectionViewLayout {
        let estimatedHeight = CGFloat(100)
        let layoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
        let item = NSCollectionLayoutItem(layoutSize: layoutSize)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize,
                                                       repeatingSubitem: item,
                                                       count: 1)
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
        section.interGroupSpacing = CGFloat(15)
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }

    func emptyListLayout() -> UICollectionViewLayout {
        let layoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.8))
        let item = NSCollectionLayoutItem(layoutSize: layoutSize)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, 
                                                       repeatingSubitem: item,
                                                       count: 1)
        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

 

 

collection view는 초기화할 때부터 layout이 존재해야한다. 그래서 처음 collection view 객체를 생성할 때에는 collectionViewLayout을 .init()으로 설정해두고, 이후 데이터 유무를 확인한 후 collectionView.setCollectionViewLayout()으로 적절한 layout을 할당해주는 로직을 구성했다.

// BookListViewController.swift

class BookListViewController: UIViewController {
   var collectionView= UICollectionView(frame: .zero, collectionViewLayout: .init())

   ...
}


// BookListViewController+DataSource.swift

extension BookListViewController {
   // 나중에 호출되는 함수
   func setStateOfList() {
        books = fetchedResultsController.fetchedObjects ?? [] // 화면에 표시할 데이터 목록
        if books.isEmpty {
            collectionView.setCollectionViewLayout(emptyListLayout(), animated: true)
            updateEmptyListSnapshot()
        } else {
            collectionView.setCollectionViewLayout(bookListLayout(), animated: true)
            updateBookListSnapshot()
        }
    }
}

 

 

✂️ 각 목록을 구분해 줄 Section과 Row 열거형 with Identifiers

BookList 목록과 Empty 목록에 따라 다른 Cell과 데이터를 할당하기 위해서는 Section과 Row 열거형이 필요하다.

이전 collection view에는 indexPath를 사용했었다면, DiffableDataSource에서는 identifier를 사용한다.

indexPath ➡ fragile(약한) • emphemeral(수명이 짧은)
identifier ➡ robust(탄탄한) • enduring(오래가는)

 

identifier는 DiffableDataSource가 현재 Snapshot과 새 Snapshot 사이에서 무엇을 업데이트해야할지 충분히 track(추적)하고 unique 고유성을 가질 수 있게 한다.이 identifier를 통해 DiffableDataSource는 최종적인 SnapShot을 도출하게 된다. 

DiffableDataSource와 Snapshot의 파라미터로 들어가는 Section과 Item은 각각 identifier가 있어 우리가 따로 작업할 것은 없다.

단 하나 신경 써야 할 것은 Section과 Item은 Hashable해야한다는 것이다. identifier가 고유한 값을 가져 추적이 가능하려면 identifier 내의 고유한 hash value가 있어야 한다. 이 hash value를 unique하게 식별하기 위해 각 타입들은 Hashable을 준수해야한다.

 

Swift의 enumeratation(열거형)의 경우 자동으로 Hashable을 준수한다. 

  • Item: 각 Section 별로 Item이 다르기 때문에 Struct를 사용해 구현한다. 각 Section 별로 해당하는 프로퍼티를 사용한다.
//  BookListViewController+Section.swift

extension BookListViewController {
    enum Section {
        case all
        case empty
    }
}

extension BookListViewController {
    struct Item: Hashable {
        let book: Book?
        let userGuide: EmptyListCell.ListType?
        
        init(book: Book? = nil, userGuide: EmptyListCell.ListType? = nil) {
            self.book = book
            self.userGuide = userGuide
        }
    }
}

 

 

 

 

 

👨‍👦 DiffableDataSource와 Snapshot

DiffableDataSource와 SnapShot은 Generic 타입으로, Section에 해당하는 타입과 Item에 해당하는 타입을 파라미터로 표시해야한다.

//  BookListViewController.swift

class BookListViewController: YagiViewController {
    var dataSource: DataSource!
    var collectionView = CollectionViewWithAddingControl(frame: .zero, collectionViewLayout: .init())
}

//  BookListViewController+DataSource.swift

extension BookListViewController {
    // typeailas를 사용해 타입을 간단하고 직관적으로 표현
    typealias DataSource = UICollectionViewDiffableDataSource<BookListSection, Item>
    typealias SnapShot = NSDiffableDataSourceSnapshot<BookListSection, Item>
 }

 

 

 

 

 

 

🤸‍♂️ Cell 구성하기 전 CellRegistration 생성

각 목록 별로 다른 UI를 사용할 예정이라 Cell 또한 두 개로 나눠 작업했다.

CellRegistration은 이전의 collection view의 DataSource에서 사용했던 cellForItemAt 메서드를 생각하면 쉽다. 그 안에서 작업했던 것들을 CellRegisterationHandler에서 작성하면 된다.

//  BookListViewController.swift

class BookListViewController: YagiViewController {
    var dataSource: DataSource!
    var collectionView = CollectionViewWithAddingControl(frame: .zero, collectionViewLayout: .init())
    var books: [Book]!

    override func viewDidLoad() {
        super.viewDidLoad()

        configureDataSource()
    }
}

//MARK: - Configuration
extension BookListViewController {

    func configureDataSource() {
        let bookListCellRegistration = UICollectionView.CellRegistration(handler: bookListCellRegistrationHandler)
        let emptyCellRegistration = UICollectionView.CellRegistration(handler: emptyListCellRegistrationHandler)

        // 다음 Chapter 코드
    }
}

//  BookListViewController+DataSource.swift

extension BookListViewController {
    // typeailas를 사용해 타입을 간단하고 직관적으로 표현
    typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
    typealias SnapShot = NSDiffableDataSourceSnapshot<Section, Item>
    
    func bookListCellRegistrationHandler(cell: BookListCell, indexPath: IndexPath, item: Book) {
        cell.titleLabel.text = item.title
        cell.dateLabel.text = item.date
    }
    
    func emptyListCellRegistrationHandler(cell: EmptyListCell, indexPath: IndexPath, item: EmptyListCell.ListType) {
        cell.listType = item
    }
 }

 

 

 

 

🏋️ Cell 구성하기

DataSource를 생성하면서 각 Section별 사용될 cell을 반환하자. DataSource의 파라미터 중 하나인 cellProvider는 DataSour가 제공하는 데이터로부터 collectionView에 전달할 Cell를 생성하고 반환한다.

각 section에 따라 해당하는 cellRegistration과 item의 프로퍼티를 파라미터로 전달하며 dequeueConfiguredReusableCell을 생성하자.

//  BookListViewController.swift

//MARK: - Configuration
extension BookListViewController {
    func configureDataSource() {
        let bookListCellRegistration = UICollectionView.CellRegistration(handler: bookListCellRegistrationHandler)
        let emptyCellRegistration = UICollectionView.CellRegistration(handler: emptyListCellRegistrationHandler)

        dataSource = DataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, item in
            if let book = item.book {
                return collectionView.dequeueConfiguredReusableCell(using: bookListCellRegistration, for: indexPath, item: book)
            } else {
                return collectionView.dequeueConfiguredReusableCell(using: emptyCellRegistration, for: indexPath, item: item.userGuide)
            }
        })

        // 다음 Chapter
    }
 }

 

 

 

🎨 update Snapshot

데이터 유무에 따라, 다른 Section•Item 를 가진 SnapShot을 DataSource에 적용해야하기 때문에 Snapshot을 만드는 함수도 두 가지 구현한다. 

Section 별로 다른 item을 갖지만 모든 Section의 item 타입이 Item으로 같으므로, books의 각 요소들을 Item 타입으로 변환해 snapshot에 append하고 EmptyList의 경우 하나의 item만 있으면 되니깐 한 개의 요소가 담긴 배열을 생성해 snapshot에 append 해준다

//  BookListViewController+DataSource.swift

extension BookListViewController {
    ...

    func setStateOfList() {
        books = fetchedResultsController.fetchedObjects ?? []
        if books.isEmpty {
            collectionView.setCollectionViewLayout(emptyListLayout(), animated: true)
            updateEmptyListSnapshot()
        } else {
            collectionView.setCollectionViewLayout(bookListLayout(), animated: true)
            updateBookListSnapshot()
        }
    }

    func updateBookListSnapshot() {
        let items = books.map{ Item(book: $0) }
        var snapshot = SnapShot()
        snapshot.appendSections([.all])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: true)
    }
    
    func updateEmptyListSnapshot() {
        let items = [ Item(userGuide: .book) ]
        var snapshot = SnapShot()
        snapshot.appendSections([.empty])
        snapshot.appendItems(items)
        dataSource.apply(snapshot)
    }
}

 

 

 

 

📱 collection view에 표시할 데이터를 생성하고 UI에 데이터 보이기

마지막으로 위에서 구현했던 setStateOfList()를 호출한 뒤, collectionView.dataSource에 이전 Chapter에서 생성한 dataSource를 할당하면 작업은 끝난다.

//  BookListViewController.swift

//MARK: - Configuration
extension BookListViewController {
    func configureDataSource() {
        ...


        setStateOfList()
        collectionView.dataSource = dataSource
    }
 }

 

 

 

 

 

 

DiffableDataSource를 공부할 수 있는 Apple Tutorials

728x90