애플사이다의 iOS 개발 일지

[CollectionView] Diffable DataSource 이해하기 (3/3) - 상품 배너/목록/상세 화면을 구현한 예제코드 본문

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

[CollectionView] Diffable DataSource 이해하기 (3/3) - 상품 배너/목록/상세 화면을 구현한 예제코드

Applecider 2022. 6. 12. 23:29

DiffableData을 처음 들어봤다면,

[CollectionView] Diffable DataSource 이해하기 (1/3) - Advances in UI Data Sources (WWDC19) 포스팅을 참고

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

 

orthogonalScrollingBehavior을 처음 들어봤다면,

[CollectionView] Section마다 다른 Scroll Direction 설정하기, Carousel Paging 구현하기 (feat. AppStore) 포스팅을 참고


이전 포스팅에서 다룬 내용을 써먹어보자.

CollectionView의 DiffableDataSource, CompositionalLayout, orthogonalScrollingBehavior을 활용하는 예제코드를 작성해봤다.

 

총 2개 화면이다.

화면-1 : 상품 배너와 상품 목록을 보여준다. 

화면-2 : 사용자가 배너 또는 목록의 상품을 탭하면, 해당 상품의 상세 화면을 띄운다.

🍎 상품 배너 및 목록 예제코드

먼저 배너와 상품 목록을 구현해보자.

위의 banner section은 horizontal scroll을, 아래의 list section은 vertical scroll을 할 수 있다.

 

그리고 1개 CollectionView 내에서 "Section마다 Scroll Direction을 다르게" 지정하고 있으므로

compositionalLayout의 orthogonalScrollingBehavior 프로퍼티를 활용하면 된다.

 

🔍 상품 목록을 좀 더 자세히 보면, 상단의 메뉴 버튼을 통해 Grid 형태와 Table 형태 2개 종류로 나타냈다.

이를 위해 2개 종류의 Cell 타입 (GridCell, TableCell)을 사용했다.

메뉴 버튼을 구현할 때, 버튼에 UnderLine을 만들고, 버튼을 탭할 때 UnderLine의 위치가 이동하도록 했는데
이 내용은 별도로 포스팅할 예정이다.

1. CollectionView의 Section 및 Layout 설정

먼저 CollectionView에 나타낼 Section의 종류를 정의하고, compositionalLayout을 잡아준다.

private enum SectionKind: Int {
    // Section 종류
    case banner
    case list

    // ✅ 한 줄에 보여줄 item의 개수를 설정 
    var columnCount: Int {
        switch self {
        case .banner:
            return 1
        case .list:
            if ProductListViewController.isGrid {
                return 2  // grid 형태면 한 줄에 2개의 item을 나타냄
            } else {
                return 1  // table 형태면 한 줄에 1개의 item을 나타냄
            }
        }
    }

    // ✅ banner section은 horizontal scroll, list section은 vertical scroll로 설정
    func orthogonalScrollingBehavior() -> UICollectionLayoutSectionOrthogonalScrollingBehavior {
        switch self {
        case .banner:
            return .groupPagingCentered
        case .list:
            return .none
        }
    }
}
  • SectionKind는 배너, 목록 2개 종류로 구분했다.
    이때 선택지를 한정 짓는 상황이므로 enum으로 선언했다.
  • columnCount 프로퍼티를 통해 상품 목록을 나타낼 때, 한 줄 (row)에 보여줄 item의 개수를 설정한다.
    한 줄에 Grid 형태는 2개씩, Table 형태는 1개씩 나타낸다.
  • orthogonalScrollingBehavior 메서드에서 각 Section 마다 어떤 형태로 Scroll 할지 지정한다.
    banner는 horizontal scroll을 하므로 .groupPagingCentered를, list section은 vertical scroll을 해야하므로 .none으로 설정했다.
private func createLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ -> NSCollectionLayoutSection? in
        // section 정보에 접근
        guard let sectionKind = SectionKind(rawValue: sectionIndex) else {
            self?.showUnknownSectionErrorAlert()
            return nil
        }
        
        // item 설정
        // ✅ Cell 내부 컨텐츠의 높이를 알아서 계산하여 반영하는 estimatedHeight를 적용
        let screenWidth = UIScreen.main.bounds.width
        let estimatedHeight = NSCollectionLayoutDimension.estimated(screenWidth)
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight)
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        // group 설정
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                       subitem: item,  
                                                       count: sectionKind.columnCount)  // 1개 group에 나타낼 item 개수를 설정
        // section 설정
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = sectionKind.orthogonalScrollingBehavior()  // ✅ 여기서 Section별 Scroll 방향을 할당       
        section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
            let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
            if environment.container.contentSize.height == environment.container.contentSize.width {
                self?.currentBannerPage.onNext(bannerIndex)
            }
        }

        // header 및 footer 설정
        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight),
            elementKind: SupplementaryKind.header,
            alignment: .top
        )
        let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: estimatedHeight),
            elementKind: SupplementaryKind.footer,
            alignment: .bottom
        )
        section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
        return section
    }
    return layout
}

// ✅ createLayout 메서드의 반환값은 CollectionView의 collectionViewLayout 프로퍼티에 할당함
// 아래의 두 형태 모두 가능
collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) 
collectionView.collectionViewLayout = createLayout()
  • createLayout 메서드에서 item / group / section, header / footer에 대한 레이아웃을 설정한다.
  • estimatedHeight를 사용하면 Cell 내부 컨텐츠의 높이를 알아서 계산하여 반영해주므로 매우 유용하다.
    이때 itemSize와 groupSize에 모두 적용해야 작동한다.
  • .fractionalWidth / .fractionalHeight은 화면 크기에 대한 상대적인 크기를 적용하므로 다양한 기기에 유연하게 대응 가능하다.
  • section의 orthogonalScrollingBehavior 프로퍼티로 Section마다 Scroll 방향을 설정한다.
  • section의 boundarySupplementaryItems 프로퍼티로 header 및 footer를 설정한다.
    alignment를 header는 .top, footer는 .bottom으로 설정한다.
  • createLayout 메서드의 반환값은 CollectionView의 collectionViewLayout 프로퍼티에 할당한다.
    CollectionView를 초기화할 때 할당하는 것, 초기화한 이후에 할당하는 것 모두 가능하다.
  • ❗배너의 좌우로 다른 item이 보이게 하려면?
    item의 fractionalWidth는 1.0, group의 fractionalWidth는 0.7 정도로 설정하고,
    item의 contentInsets을 약 10으로 설정하면 된다. (아래의 상세화면 코드에서 설명)

🔍 참고 - 배너 하단에 PageControl 구현하기

*자세한 설명은 [CollectionView] 배너 하단에 PageControl 구현 포스트를 참고

예제처럼 PageControl을 구현하려면 추가적인 작업이 필요하다. 

배너를 Horizontal Scroll을 했을 때 현재 페이지의 index를 구한 다음 PageControl의 currentPage에 할당해야 한다.

이 작업은 어디서 처리했을까? 

createLayout 메서드의 section 부분에 visibleItemsInvalidationHandler로 클로저를 할당하여 처리했다.

(예제에서 PageControl을 FooterView에 배치했는데, 이 부분은 별도로 포스팅할 예정이다.)

 

참고 - scrollViewWillEndDragging 메서드에 이 코드를 작성하면,

의도했던 Horizontal Scroll 뿐만 아니라 Vertical Scroll을 할 때도 호출되기 때문에 PageControl이 비정상적으로 작동한다.

// 위의 createLayout 메서드에 포함된 코드 
section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
    // 비교용 - Device의 Screen 크기
    print("=== Screen Width/Height :", UIScreen.main.bounds.width, "/", UIScreen.main.bounds.height)

    // ✅ contentOffset은 CollectionView의 bound를 기준으로 Scroll 결과 보여지는 컨텐츠의 Origin을 나타냄
    // 배너 및 목록화면의 경우, Scroll하면 어디서 클릭해도 0부터 시작
    // 상세화면의 경우, Scroll하면 어디서 클릭해도 약 -30부터 시작 (기기마다 다름, CollectionView의 bound를 기준으로 cell(이미지)의 leading이 왼쪽 (-30)에 위치하므로 음수임)
    print("OffsetX :", contentOffset.x)

    // ✅ environmnet는 collectionView layout 관련 정보를 담고 있음
    // environment.container.contentSize는 CollectionView 중에서 현재 Scroll된 Group이 화면에 보이는 높이를 나타냄
    print("environment Width :", environment.container.contentSize.width)   // Device의 스크린 너비와 동일
    print("environment Height :", environment.container.contentSize.height) // Horizontal Scroll하면 스크린 너비와 같고, Vertical Scroll하면 그보다 커짐

    let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))  // 음수가 되는 것을 방지하기 위해 max 사용
    if environment.container.contentSize.height == environment.container.contentSize.width {  // ❗Horizontal Scroll 하는 조건
        self?.currentBannerPage.onNext(bannerIndex)  // 클로저가 호출될 때마다 pageControl의 currentPage로 값을 보냄
    }
}
  • visibleItemsInvalidationHandler에 할당되는 클로저를 뜯어보자.
  • Horizontal Scroll 했을 때 바뀐 Banner 페이지의 index를 pageControl의 currentPage로 지정하는 기능이다.
  • contentOffset, environment.container.contentSize에 대한 이해가 필요하다. 직접 프린트를 찍어보면 쉽다.
    • contentOffsetCollectionView의 bound를 기준으로 Scroll 결과 보여지는 컨텐츠의 Origin을 나타낸다.
      오른쪽으로 Horizontal Scroll 하면, contentOffset.x의 값은 계속해서 커진다.
    • environment.container.contentSize는 공식문서에 명확히 나와있지 않아서 실험이 필요했다.
      일단 environmnet는 collectionView layout 관련 정보를 담고 있다.
      environment.container.contentSizeCollectionView 중에서 현재 Scroll된 Group이 화면에 보이는 높이를 나타낸다.
      이 contentSize의 height를 찍어보면 Horizontal Scroll할 때는 스크린 너비와 같고, Vertical Scroll할 때는 그보다 커진다.
      (아래 gif 참고)
      -> 따라서 이 특징을 활용하여 if문의 조건으로 Horizontal Scroll하는 경우를 찾은 뒤, PageControl에 현재 페이지를 보내줬다.

Scroll 방향에 따라 달라지는 offSetX 및 environment height 확인

검색 키워드 : orthogonalScrollingBehavior, page control
How can I detect orthogonal scroll events when using `UICollectionViewCompositionalLayout`?

(근데 이 클로저도 Vertical Scroll할 때 호출되므로 한계가 있어보였지만, 다른 해결방법을 찾지 못했다.)

2. Custom Cell 생성

CollectionView에 나타내기 위해 총 3개 종류의 Custom Cell을 생성한다.

배너에 1개 (BannerCell), 목록에 2개 (GridListCell, TableListCell) 종류가 필요하다.

 

StackView를 추가하고, 내부에 필요한 UI를 addArrangedSubview 메서드로 넣어서 배치한다.

3. DiffableDataSource 생성 및 SnapShot 적용

이제 DiffableDataSource를 사용하는 4단계 STEP을 따라가 보자.

  1. Connect a diffable data source to your collection view.
  2. Implement a cell provider to configure your collection view’s cells.
  3. Generate the current state of the data.
  4. Display the data in the UI.

STEP 1~2로 Cell을 등록하고, DiffableDataSource를 생성하고, ViewController와 연결한다.

아래처럼 typealias를 활용하면 가독성을 개선할 수 있다.

private typealias DiffableDataSource = UICollectionViewDiffableDataSource<SectionKind, UniqueProduct>  
// ❗제네릭 매개변수로 SectionIdentifier, ItemIdentifier를 설정

private typealias BannerCellRegistration = UICollectionView.CellRegistration<BannerCell, UniqueProduct>
private typealias TableListCellRegistration = UICollectionView.CellRegistration<TableListCell, UniqueProduct>
private typealias GridListCellRegistration = UICollectionView.CellRegistration<GridListCell, UniqueProduct>

private typealias HeaderRegistration = UICollectionView.SupplementaryRegistration<HeaderView>
private typealias FooterRegistration = UICollectionView.SupplementaryRegistration<FooterView>
  • Section 및 Item의 타입 (SectionIdentifier, ItemIdentifier)은 모두 Hashable 해야 한다.
  • Section 타입인 SectionKind는 위 코드에 있고, Item 타입인 UniqueProduct는 내부에 UUID 타입의 id 프로퍼티를 갖고 있다.
private var collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
private var dataSource: DiffableDataSource!

private func configureCellRegistrationAndDataSource() {
    // ✅ Cell 등록
    // ❗제네릭 매개변수로 Item 타입을 지정해주면 알아서 Item 데이터가 전달됨 (기존의 indexPath가 필요 없음)
    let bannerCellRegistration = BannerCellRegistration { cell, _, uniqueProduct in
        cell.apply(imageURL: uniqueProduct.product.thumbnail, productID: uniqueProduct.product.id)  // Cell에 데이터를 전달하여 화면에 나타냄
    }
    let tableListCellRegistration = TableListCellRegistration { cell, _, uniqueProduct in
        cell.apply(data: uniqueProduct.product)
    }
    let gridListCellRegistration = GridListCellRegistration { cell, _, uniqueProduct in
        cell.apply(data: uniqueProduct.product)
    }

    // ✅ DiffableDataSource 생성
    dataSource = DiffableDataSource(collectionView: collectionView,
                                    cellProvider: { collectionView, indexPath, product in
        guard let sectionKind = SectionKind(rawValue: indexPath.section) else {
            return UICollectionViewCell()
        }

        // 3개 종류의 Cell을 각각 dequeque 하도록 설정
        switch sectionKind {
        case .banner:
            return  collectionView.dequeueConfiguredReusableCell(using: bannerCellRegistration,
                                                                 for: indexPath,
                                                                 item: product)
        case .list:
            switch ProductListViewController.isGrid {
            case true:
                return collectionView.dequeueConfiguredReusableCell(using: gridListCellRegistration,
                                                                    for: indexPath,
                                                                    item: product)
            case false:
                return collectionView.dequeueConfiguredReusableCell(using: tableListCellRegistration,
                                                                    for: indexPath,
                                                                    item: product)
            }
        }
    })
}
  • 3개 종류의 Custom Cell을 사용하므로 모든 Cell을 등록하고, DataSource를 생성한다.

header 및 footer도 비슷하게 처리하면 된다.

UICollectionReusableView을 상속받아 Custom View를 생성하여 등록하고, DataSource에 연결시키면 된다. 

private func configureSupplementaryViewRegistrationAndDataSource() {
    // ✅ header 및 footer 등록
    let headerRegistration = HeaderRegistration(elementKind: SupplementaryKind.header) { view, _, indexPath in
        view.apply(indexPath)
    }
    let footerRegistration = FooterRegistration(elementKind: SupplementaryKind.footer) { [weak self] view, _, indexPath in
        guard let self = self else { return }
        view.bind(input: self.currentBannerPage.asObservable(),
                  indexPath: indexPath,
                  pageNumber: Content.bannerCount)
    }

    // ✅ DiffableDataSource에 반영
    dataSource.supplementaryViewProvider = { [weak self] _, kind, index in
        switch kind {
        case SupplementaryKind.header:
            return self?.collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration,
                                                                               for: index)
        case SupplementaryKind.footer:
            return self?.collectionView.dequeueConfiguredReusableSupplementary(using: footerRegistration,
                                                                               for: index)
        default:
            return UICollectionReusableView()
        }
    }
}

 

STEP 3~4로 전체 데이터를 전달해서 초기 Snapshot을 만들고 DiffableDataSource에 적용한다.

예제에서는 초기화면을 구현할 때, 서버에서 20개의 상품 데이터를 받아서 Snapshot을 만들고 apply 했다.

사용자가 Vertical Scroll로 아래로 이동하면, 추가로 20개의 데이터를 받아서 기존 Snapshot에 append하고 apply 했다.

 

먼저 Inital Snapshot을 구성하는 코드이다.

private var snapshot: NSDiffableDataSourceSnapshot<SectionKind, UniqueProduct>!

private func configureInitialSnapshotWith(listProducts: [UniqueProduct], bannerProducts: [UniqueProduct]) {
    // ✅ 새로운 snapshot 생성
    snapshot = NSDiffableDataSourceSnapshot<SectionKind, UniqueProduct>()

    // ✅ Section 및 Item에 순서대로 데이터를 append
    snapshot.appendSections([.banner])
    snapshot.appendItems(bannerProducts)
    snapshot.appendSections([.list])
    snapshot.appendItems(listProducts)
    
    // ✅ 현재 snapshot을 적용하여 View를 그림
    dataSource.apply(snapshot, animatingDifferences: true)
}
  • 추가 데이터를 받아서 Snapshot을 업데이트할 수 있도록 snapshot 프로퍼티를 생성했다.
  • appendSections(), appendItems()를 통해 각 Section마다 원하는 Item 데이터를 넣어준다.
  • Snapshot을 만든 뒤에 apply 메서드를 호출한다.

마지막으로 snapshot을 업데이트하는 코드를 보자.

private func applySnapshotWith(listProducts: [UniqueProduct]) {
    // ✅ snapshot을 업데이트 - list section에만 특정 데이터를 추가함
    snapshot.appendItems(listProducts, toSection: .list)
    
    // ✅ "DiffiableDataSource야, 업데이트한 snapshot을 apply해서 View를 다시 그려줘"
    dataSource.apply(snapshot, animatingDifferences: true)
}
  • 서버에서 추가로 받아온 상품 데이터를 list section에 append하는 작업이다.
  • appendItems(_:toSection:) 메서드로 데이터를 추가할 특정 Section을 지정한다.
  • 위의 configureInitialSnapshotWith 메서드에서 생성한 snapshot에 다시 apply하여 View를 새로 그린다.

🍎 상품 상세화면

상세화면 코드도 가져와봤다.

여러 이미지를 Scroll할 때 1칸씩 Paging 되도록 구현했다.

상품 상세화면

이미지가 들어갈 위치를 잡아서 CollectionView와 PageControl을 올리고, Custom Cell을 등록하고,

상품 배너 및 목록 화면과 마찬가지로 작업을 하면 된다.

 

상세화면의 이미지는 고정적이므로 DiffableDataSource를 사용하지 않았다.

// CollectionView 생성
private let imageCollectionView: UICollectionView = {
    let collectionView = UICollectionView(frame: .zero,
                                          collectionViewLayout: UICollectionViewLayout())
    collectionView.translatesAutoresizingMaskIntoConstraints = false
    collectionView.heightAnchor.constraint(equalTo: collectionView.widthAnchor, multiplier: 0.85).isActive = true
    collectionView.backgroundColor = CustomColor.backgroundColor
    collectionView.isScrollEnabled = false
    return collectionView
}()

// PageControl 생성
private let imagePageControl: UIPageControl = {  // 참고 - numberOfPages를 지정은 별도 메서드에서 처리
    let pageControl = UIPageControl()
    pageControl.translatesAutoresizingMaskIntoConstraints = false
    pageControl.pageIndicatorTintColor = .systemGray
    pageControl.currentPageIndicatorTintColor = CustomColor.darkGreenColor
    pageControl.currentPage = 0
    pageControl.isUserInteractionEnabled = false
    pageControl.hidesForSinglePage = true
    return pageControl
}()

// Cell 등록 (1개 이미지가 들어감)
private func configureCollectionView() {
    imageCollectionView.register(ProductDetailImageCell.self,
                                 forCellWithReuseIdentifier: String(describing: ProductDetailImageCell.self))
    imageCollectionView.collectionViewLayout = createCollectionViewLayout()
}

// Compositional Layout 설정
private func createCollectionViewLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout { _, _ -> NSCollectionLayoutSection? in
        let screenWidth = UIScreen.main.bounds.width
        let estimatedHeight = NSCollectionLayoutDimension.estimated(screenWidth)
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: estimatedHeight)
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)  // ✅ item contentInsets 설정

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                               heightDimension: estimatedHeight)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPagingCentered  // ✅ 여기서 설정
        section.visibleItemsInvalidationHandler = { [weak self] _, contentOffset, environment in
            let bannerIndex = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
            self?.imagePageControl.currentPage = bannerIndex   // PageControl에 현재 페이지를 반영시킴
        }
        return section
    }
    return layout
}
  • orthogonalScrollingBehavior에 .groupPagingCentered을 할당했다.
  • ❗배너의 좌우로 다른 item이 보이게 하기 위해
    item의 fractionalWidth는 1.0, group의 fractionalWidth는 0.85로 설정하고,
    item contentInsets으로 leading 및 trailing에 10을 설정했다. 

그리고 이때 아래 사진처럼 Cell이 겹치는 문제가 발생한다면,

Cell의 contentView가 아니라 self에 대해 constraint를 적용해야 한다.

// 문제 발생 - Cell이 겹침
private func configureUI() {
    addSubview(productImageView)
    NSLayoutConstraint.activate([
        productImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
        productImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        productImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
        productImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
    ])
}

// 해결
private func configureUI() {
    addSubview(productImageView)
    NSLayoutConstraint.activate([
        productImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
        productImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
        productImageView.topAnchor.constraint(equalTo: self.topAnchor),
        productImageView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
    ])
}

왼쪽 : Cell이 겹치는 문제 -> 오른쪽 : 해결

 

쓰다 보니 글이 길어졌지만, 한 번 예제코드를 작성해보면 생각보다 간단하다.

전체 코드는 GitHub 링크를 참고

 

- Reference

Comments