개발 시행착오

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

Forest Yun 2024. 6. 28. 22:35
728x90

 

 

 

 

👩‍💻배경👩‍💻

ChapterList를 개발하던 중, 분명 item이 3개가 있는데 cell이 1개만 보이는 문제가 발생했다! 그래서 이전에 작업했던 BookList에서도 확인해 보니 똑같은 문제가 발생했다.. BookList에서는 item이 하나만 있었던지라 발견하지 못했던 것 같다. 분명 BookList에 객체가 3개가 담긴 샘플 데이터를 적용할 때만 해도 화면에 item들이 잘 나타났는데, 실제 데이터를 적용한 뒤 작업했던 Section별 UI 작업에서 로직이 바뀌면 발생한 것으로 보인다. CollectionViewLayout부터 DiffableDataSource를 거쳐 Snapshot까지 문제를 찾아보는 과정을 기록해보자~

 


 

 

 

 

 

🖼️ CollectionViewCompositionalLayout

문제가 발생하고 가장 먼저 의심한 건 Layout이었다. View의 크기를 잘못 적용해 첫 번째 이후 cell들이 가려져 안 보이는 것일 수도 있었다. 종종 발생했던 문제라 가장 먼저 의심은 했지만, 사실 직접 시뮬레이터를 스크롤했을 때 양 끝에 바운딩이 잘 작동되었기 때문에 Layout이 원인일 가능성이 낮긴 했다.

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.vertical(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
}

 

Layout을 구성하는 코드들 중 Size도 확인하고 group의 vertical 메서드도 맞게 사용된 것인지 확인해 보았다. vertical 메서드의 count 파라미터를 1로 설정한 이유는 한 group에 한 item만 넣을 예정이었기 때문이었다. 그래서 사실문제와는 전혀 상관이 없었고 작성한 이유가 있는 코드였다. 하지만 이번 기회에 해당 메서드의 문서를 다시 읽어보면 좋겠다고 생각했다.

 

class func vertical(
    layoutSize: NSCollectionLayoutSize,
    repeatingSubitem subitem: NSCollectionLayoutItem,
    count: Int
) -> Self
vertical(layoutSize:repeatingSubitem:count:)
세로축을 따라 지정된 하위 item을 특정한 개수만큼 반복하여 한 그룹을 생성한다.

layoutSize
그룹의 크기
subitem
item의 반복 횟수에 맞게 그룹의 layout 크기를 조정해야 한다.
count
하위 item을 반복할 횟수

 

. vertical 메서드의 공식문서를 읽어보면, 세로 방향으로 item을 특정 횟수만큼 반복해 하나의 group을 생성한다고 나와있다. 즉 group 내 item이 여러 개 있을 때 그 item들의 정렬 방향을 정하는 메서드인 것이다. 처음에 예상했듯이 Layout은 문제의 원인이 아니었다. 그래도 이번 기회에 CompositionalLayout의 방향을 제대로 이해할 수 있었다.

 

 

[ iOS ] CompositionLayout 방향 이해하기

📖 CompositionalLayoutCollectionView의 CompositionalLayout은 Section, Group, Item으로 구성되어있다. 여기에서 Group은 NSCollectionLayoutItem 의 하위 클래스이기 때문에 Group 안에 Group이 들어갈 수도 있다.       

88yhtserof.tistory.com

 

 

 

 

 

 

 

🤔 DiffableDataSource

WWDC19에서 발표된 DiffableDataSource는 Generic 타입으로, 우리가 사용할 SectionIdentifier와 ItemIdentifier을 파라미터로 받는다. Section과 Item이 다양할 경우 보통 Enumeration을 사용해 커스텀 타입을 만들어 지정한다. 빈 목록 화면과 List 화면을 Section을 구분하여 구현했는데, 이 과정에서 문제가 발생한 게 아닐까 생각했다.  

  • 의심 1, 파라미터로 넘긴 타입이 Item의 정확하고 구체적인 타입이 아니라서 그런 것일까?
  • 의심 2, ItemIdentifier인데 식별자인 ID값을 전달한 게 아니라서 item 각각을 구분하지 못해 오직 하나만 화면에 표시되는 걸까?

 

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

extension BookListViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<BookListSection, BookListRow>
    typealias SnapShot = NSDiffableDataSourceSnapshot<BookListSection, BookListRow>
    typealias FetchedResultsController = NSFetchedResultsController<Book>
    
    ...
}

extension BookListViewController {
    enum BookListSection {
        case all
        case empty
    }
    
    enum BookListRow: Hashable {
        case book([Book])
        case userGuide
    }
}

 

먼저 DataSource의 파라미터 각각을 만들어 둔 열거형으로 선언해 주었다. DiffableDataSource의 파라미터로 사용되기 위해서는 Hashable 해야 하는데, 열거형은 자동으로 Hashable을 준수하기 때문에 따로 신경 쓰지 않아도 된다. BookListRow 같은 경우 연관 값이 있기 때문에 따로 Hashable을 채택했다.

 

첫 번째 의심, 정확한 Item 타입만 파라미터로 사용하는가?

아니다! DataSource의 파라미터로 들어가는 Item의 타입은 DataSource를 생성할 때 initialize의 파라미터 중 하나인 cellProvider에서 사용되는데, 전달된 타입이 무엇이든 dequeueConfiguredReuseable을 사용해 Cell을 생성할 때 파라미터로 원하는 타입의 데이터를 전달해 주면 Cell이 잘 생성된다.

(cellProvider: DiffableDataSource가 제공하는 데이터로부터 CollectionView에 대한 Cell를 생성하고 반환한다.)

 

cellRegistraion에서 각 Section 별로 item을 구분할 수도 있지만, 나는 UICollectionViewCell도 Section에 따라 다르게 구현할 예정이기 때문에 cellProvider 클로저 구현부에서부터 switch를 사용해 Item을 구분해 주었다. 그리고 값 바인딩을 통해 데이터를 받고, 데이터를 dequeueConfiguredReuseable의 item 파라미터에 전달해 주었다.

//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, row in
            switch row {
            case .book(let books):
                return collectionView.dequeueConfiguredReusableCell(using: bookListCellRegistration, for: indexPath, item: books)
            case .userGuide:
                return collectionView.dequeueConfiguredReusableCell(using: emptyCellRegistration, for: indexPath, item: row)
            }
        })
        configureFetchedResultController()
        setStateOfList()
        collectionView.dataSource = dataSource
    }
}

 

 

 

두 번째 의심, 식별 가능한 ID 값만을 파라미터로 전달해야 하는가?

아니다! DiffableDataSource에서는 이제 fragile 한 IndexPath가 아니라 robust 한 Identifier를 사용한다. 이를 위해 DiffableDataSource는 파라미터로 Hashable 한 타입을 받는다. 즉, Hashble하기만 하다면 식별 가능한 hash value가 있기 때문에 item들 간 식별이 가능하다. 따라서 DiffableDataSource의 파라미터로 Hashable한 여러 타입이 올 수 있다.

 

 

 

 

 

 

 

 

 

🔫 Snapshot

코드를 하나씩 따라가며 마지막인 Snapshot까지 도달하게 되었다. DiffableDataSource를 사용해 CollectionView의 UI를 업데이트하는 방법에는 4가지 단계가 있는데, 그중 "3, Populate it" 과정에서 Snapshot을 사용한다.

// Create a snapshot.
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()        


// Populate the snapshot.
snapshot.appendSections([0])
snapshot.appendItems([UUID(), UUID(), UUID()])


// Apply the snapshot.
dataSource.apply(snapshot, animatingDifferences: true)

 

 

 

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

1️⃣ 구현하기 전에 먼저 알아보기 ☝️ 구현 간략 요약Cell과 Section, Item을 BookList와 Empty 두 경우로 구분하여, 데이터 유무를 확인한 후 DiffableDataSource에 Cell을 등록하고 SnapShot에 Section과 Item를

88yhtserof.tistory.com

CollectionView의 UI를 업데이트하는 4가지 단계 알아보기

 

 

 

Snapshot에는 Section과 Item을 덧붙여 최종적으로 DataSource에 apply 하며 UI를 업데이트하게 된다. 여기서 문제는 appendItems에서 발생했다..!

appendItems는 Identifiers, 즉 item들을 필수적으로 받고, 선택적으로 해당 item들을 어떤 Section에 덧붙일 것인지 지정할 수 있다. 만약 Section을 지정하지 않는다면, appendSections에 전달된 Section들 중 가장 마지막 Section에 해당 Item들이 덧붙여지게 된다.

mutating func appendItems(
    _ identifiers: [ItemIdentifierType],
    toSection sectionIdentifier: SectionIdentifierType? = nil
)

 

 

여기서 내가 잘못 알고 있었던 부분은 바로 Section이 담긴 배열과 ItemList들이 담긴 배열을 전달하면 각각의 인덱스 위치에 맞춰 알아서 매치되는 줄 알고 있었다. 생각해 보니 그동안 공부했던 예제들이 Item이 Section별로 하나이거나 Section이 하나였어서 잘못 이해했었던 것 같다. 그래서 공식문서가 중요하다😮‍💨

아래는 내가 DiffableDataSource를 공부했던 예제 프로젝트의 Snapshot 부분을 발췌한 것이다. header와 Item이 하나씩 구성되어 있는 구조인데, Iteme들이 어떤 Section에 할당될 것인지 지정되어 있다. 만약 Section을 지정해주지 않는다면, 모두 마지막 Section인 notes에 덧붙여질 것이다.

    private func updateSnapshotForEditing() {
        var snapshot = Snapshot()
        snapshot.appendSections([.title, .date, . notes])
        // title 섹션
        snapshot.appendItems([.header(Section.title.name), .editableText(reminder.title)], toSection: .title)
        // date 섹션
        snapshot.appendItems([.header(Section.date.name), .editableDate(reminder.dueDate)], toSection: .date)
        // notes 섹션
        snapshot.appendItems([.header(Section.notes.name), .editableText(reminder.notes)], toSection: .notes)
        dataSource.apply(snapshot)
    }
    
    
    private func updateSnapshotForEditing() {
        var snapshot = Snapshot()
        snapshot.appendSections([.title, .date, . notes])
        // 모두 notes 섹션
        snapshot.appendItems([.header(Section.title.name), .editableText(reminder.title)])
        snapshot.appendItems([.header(Section.date.name), .editableDate(reminder.dueDate)])
        snapshot.appendItems([.header(Section.notes.name), .editableText(reminder.notes)])
        dataSource.apply(snapshot)
    }

 

 

 

내가 작성했던 코드는 아래와 같다. 배열 안에 단 한 개의 case가 담겨 있다. 그러니깐 화면에 Cell이 단 하나만 표시된 것이었다. 나는 books를 전달하고 싶었는데, 연관 값에 books가 담긴 것뿐이지 실제로 전달된 건 case 하나였다.

    func updateBookListSnapshot() {
        var snapshot = SnapShot()
        snapshot.appendSections([.all])
        snapshot.appendItems([.book(books)])
        dataSource.apply(snapshot, animatingDifferences: true)
    }

 

 

 

 

 

 

 

 

🏋️‍♂️ 코드 수정하기

코드를 수정하기 위해서는 DiffableDataSource와 Snapshot의 ItemIdentifier의 파라미터부터 변경해야 한다. 그렇다고 앞에서 설명했던 모든 것이 틀린 건 아니다. Item에 열거형이 들어가도 된다! 하지만 그럴 경우에는 각 Section에 해당 열거형의 case들이 들어가게 된다는 걸 기억하자!

  1. 각 section 별 다른 item을 넣어줘야 하지만 ItemIdentifier는 하나이기때문에 Struct를 사용해 Item 타입을 만들었다.
  2. 그리고 각 section에 해당하는 프로퍼티를 item으로 전달했다.
  3. Snapshot에 item들을 덧붙일 때는 각 item들을 Item 타입으로 전달해야하기 때문에 map을 사용해 타입을 변환했다.

 

extension BookListViewController {
    enum Section {
        case all
        case empty
    }
    
    // 1.
    struct Item: Hashable {
        let book: Book?
        let userGuide: EmptyListCell.ListType?
        
        init(book: Book? = nil, userGuide: EmptyListCell.ListType? = nil) {
            self.book = book
            self.userGuide = userGuide
        }
    }
}


extension BookListViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
    typealias SnapShot = NSDiffableDataSourceSnapshot<Section, Item>
    
    ...
    func setStateOfList() {
        books = fetchedResultsController.fetchedObjects ?? []
        print()
        if books.isEmpty {
            collectionView.setCollectionViewLayout(emptyListLayout(), animated: true)
            updateEmptyListSnapshot()
        } else {
            collectionView.setCollectionViewLayout(bookListLayout(), animated: true)
            updateBookListSnapshot()
        }
    }
    
    // 3.
    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)
    }
}

//MARK: - Configuration
extension BookListViewController {
    ...
    
    func configureDataSource() {
        let bookListCellRegistration = UICollectionView.CellRegistration(handler: bookListCellRegistrationHandler)
        let emptyCellRegistration = UICollectionView.CellRegistration(handler: emptyListCellRegistrationHandler)
        
        // 2.
        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)
            }
        })
        configureFetchedResultController()
        setStateOfList()
        collectionView.dataSource = dataSource
    }
}

 

 

 

 

 

 

 

 

 

🧚🏻‍♀️ 마무리

DiffableDataSource를 WWDC와 예제를 통해 공부했을 때는 약간 알겠으면서도 헷갈리는 부분이 분명 있었다. 근데 직접 사용해고 이렇게 시행착오까지 겪으며 이제 확실히 그 구조를 이해하게 된 것 같다.

시행착오를 겪고 이를 남기기까지는 정말 고통스럽지만, 그 과정 속에서 해답을 발견할 때의 기쁨과 이를 정리해 공유하는 뿌듯함은 개발과 블로그를 계속 할 수밖에 없게 만든다! 내일 화이팅~

 

 

 

 

 

728x90