[iOS/UIKit] 멀티섹션, 멀티모델로 RxDataSource + Compositional Layout 구성하기

프로젝트를 진행하던 도중 다음의 화면을 구현해야 했다.

 

 

처음에 든 생각은 Compositional Layout으로 두 개의 섹션으로 구성하면 될 것 같았다.

그런데 현재 RxSwift로 구성해놓은 상태여서 다중 섹션을 쓰려면 RxDataSource를 사용했어야 했다.

또한 두 개의 섹션에 들어가는 셀과 모델이 서로 다르기도 했다.

 

이번 글에서는 멀티섹션 멀티모델로 RxDataSource와 Compositional Layout을 구현하는 방법에 대해 알아보겠다.


ViewModel에서 RxDataSource를 위한 Section과 Data 세팅

먼저 ViewModel에서 두 개의 섹션을 구성하고, 그 안에 들어갈 모델들을 설정해주었다.

 

첫 번째 섹션에는 `MockTrendingCoinItem`이라는 모델이, 두 번째 섹션에는 `MockTrendingNFTItem`이라는 모델이 들어갈 예정이라 SectionItem이라는 열거형으로 구성하였다.

 

그리고 SectionModelType 프로토콜을 채택해서 RxDataSource로써 사용할 수 있도록 해주었다.

// MARK: - RxDataSource Setting

enum MultipleSectionModel {
    case trendingCoin(items: [SectionItem])
    case trendingNFT(items: [SectionItem])
}

enum SectionItem {
    case trendingCoin(trendingCoin: MockTrendingCoinItem)
    case trendingNFT(trendingNFT: MockTrendingNFTItem)
}

extension MultipleSectionModel: SectionModelType {
    typealias Item = SectionItem
    
    var items: [SectionItem] {
        switch self {
        case .trendingCoin(let items):
            return items
        case .trendingNFT(let items):
            return items
        }
    }
    
    init(original: MultipleSectionModel, items: [SectionItem]) {
        switch original {
        case .trendingCoin(let items):
            self = .trendingCoin(items: items)
        case .trendingNFT(let items):
            self = .trendingNFT(items: items)
        }
    }
}

 

 

다음으로는 이렇게 구성한 RxDataSource를 ViewModel로 방출해준다.

 

네트워크 통신을 통해 trendingCoindData와 trendinfNFTData라는 두 개의 데이터를 받고, 그 둘을 받아서 [CoinInfoSectoinModel ] Observable을 생성해서 내보내준다.

 

이렇게되면 ViewController에서는 각 모델을 받는게 아니라, RxDataSource로써 두 개의 섹션으로 이루어진 SectionModelType의 옵저버블을 받게 된다.

final class CoinInfoViewModel: BaseViewModel {
    var disposBag = DisposeBag()
    
    private let trendingCoinData = PublishRelay<[TrendingCoinItem]>()
    private let trendingNFTData = PublishRelay<[TrendingNFTItem]>()
    
    // 방출할 SectionModel 데이터
    private var trendingData: Observable<[CoinInfoSectionModel]> {
        return Observable.combineLatest(trendingCoinData, trendingNFTData)
            .map { coin, nft in
                var sections: [CoinInfoSectionModel] = []
                
                let coinItems = coin.map { SectionItem.trendingCoin(trendingCoin: $0) }
                let coinSection = CoinInfoSectionModel.trendingCoin(items: coinItems)
                sections.append(coinSection)
                
                let nftItems = nft.map { SectionItem.trendingNFT(trendingNFT: $0) }
                let nftSection = CoinInfoSectionModel.trendingNFT(items: nftItems)
                sections.append(nftSection)
                
                return sections
            }
    }
    
    struct Input {
        // ...
    }
    
    struct Output {
        let trendingData: Driver<[CoinInfoSectionModel]>
        // ...
    }
    
    func transform(input: Input) -> Output {
        
        // ...
        
        apiTimer
            .bind(with: self) { owner, data in
            	// 네트워크 통신을 통해 두 개의 모델을 담아줌
                owner.trendingCoinData.accept(data.coins.filter{ $0.item.score < 14 })
                owner.trendingNFTData.accept(data.nfts)
            }
            .disposed(by: disposBag)
        
        return Output(
            trendingData: trendingData.asDriver(onErrorJustReturn: [])
            // ...
        )
    }
}

각 Section의 Header 설정

RxDataSource를 이용해서 각 Section을 구성할 때 Header도 별도로 설정해줄 수 있다.

Header에 별도의 View를 넣기 위해서 `UICollectionReusableView`를 상속받은 별도의 View를 만들어주어야 한다.

 

`UICollectionReusableView`를 상속받았을 뿐이지 커스텀뷰를 만드는 과정과 다를건 없다.

final class InfoSectionHeader: UICollectionReusableView {
    
    private let titleLabel = UILabel()
    private let dateLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        configureHierarchy()
        configureLayout()
        configureView()
    }
    
    private func configureHierarchy() {
        addSubview(titleLabel)
        addSubview(dateLabel)
    }
    
    private func configureLayout() {
        titleLabel.snp.makeConstraints { make in
            make.leading.equalToSuperview().offset(16)
            make.bottom.equalToSuperview()
            make.height.equalTo(17)
        }
        
        dateLabel.snp.makeConstraints { make in
            make.trailing.equalToSuperview().offset(-16)
            make.bottom.equalToSuperview()
            make.height.equalTo(15)
        }
    }
    
    private func configureView() {
        titleLabel.font = .boldSystemFont(ofSize: 15)
        titleLabel.textColor = .themePrimary
        
        dateLabel.font = ALRFont.headline.font
        dateLabel.textColor = .themeSecondary
    }
    
    func configureData(sectionTitle: String, dateString: String) {
        titleLabel.text = sectionTitle
        
        dateLabel.text = dateString
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

 

이제 이렇게 만든 뷰를 ViewController에서 dataSource 설정과 함께 `configureSupplementaryView`로 넣어주면 된다.

 

코드는 길지만 하나하나 보면 어렵지 않다.

먼저 첫 번째 클로저에서는 dataSource의 인스턴스를 생성해준다.

CoinInfoSectionModel의 Item별로 switch를 해주고, 그에 맞는 cell을 return한다.

 

`configureSupplementaryView` 클로저는 Header를 설정하는 부분이다.

`dequeueReusableCell`을 하는것처럼 `dequeueReusableSupplementaryView`로 Header를 만들어주고,

`dataSource[indexPath.section]`으로 각 섹션에 따라 원하는대로 header를 세팅해주면 된다.

// ...

// dataSource 인스턴스 생성
let dataSource = RxCollectionViewSectionedReloadDataSource<CoinInfoSectionModel> { dataSource, collectionView, indexPath, item in

    switch item {
    case .trendingCoin(let trendingCoin):
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Identifier.TrendingCoinCollectionViewCell.rawValue, for: indexPath) as? TrendingCoinCollectionViewCell else { return UICollectionViewCell() }

        cell.configureData(data: trendingCoin.item)

        return cell
    case .trendingNFT(let trendingNFT):
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Identifier.TrendingNFTCollectionViewCell.rawValue, for: indexPath) as? TrendingNFTCollectionViewCell else { return UICollectionViewCell() }

        cell.configureData(data: trendingNFT)

        return cell
    }
} configureSupplementaryView: { dataSource, collectionView, kind, indexPath in
    switch kind {
    case Identifier.SectionHeader.rawValue:
        guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: Identifier.SectionHeader.rawValue, for: indexPath) as? InfoSectionHeader else { return InfoSectionHeader() }

                switch dataSource[indexPath.section] {
                case .trendingCoin(items: _):
                    header.configureData(sectionTitle: "인기 검색어", dateString: Date().toString(dateFormat: "MM.dd HH:mm 기준"))
                case .trendingNFT(items: _):
                    header.configureData(sectionTitle: "인기 NFT", dateString: "")
                }
        return header
    default:
        return UICollectionReusableView()
    }
}

// ViewModel로부터 받은 trendingData를 dataSource를 통해 collectionView에 보여줌
output.trendingData
    .drive(coinInfoView.infoCollectionView.rx.items(dataSource: dataSource))
    .disposed(by: disposeBag)
    
// ...

 


Compositional Layout 구성

컬렉션뷰를 만들 때 Compositional Layout으로 만들어준다.

여기서 Header의 사이즈도 설정해줄 수 있다.

 

만들어야 하는 화면에따라, 첫 번째 섹션은 내-외부그룹으로 이루어진 레이아웃으로, 두 번째 섹션은 수평스크롤 레이아웃으로 구성했다.

그리고 makeHeaderView라는 header의 사이즈를 설정하는 메서드를 통해 header를 만들고,

`section.boundarySupplementaryItems = [header]`로 각 섹션에 header를 SupplementaryItems로 등록해줘야 정상적으로 보이게 된다.

lazy var infoCollectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())

// ...

private func createLayout() -> UICollectionViewLayout {
    let header = makeHeaderView()

    let layout = UICollectionViewCompositionalLayout { sectionIndex, _ in
        if sectionIndex == 0 {
            // 내부 외부 그룹
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1/7))

            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 16)

            let innerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))

            let innerGroup = NSCollectionLayoutGroup.vertical(layoutSize: innerGroupSize, subitems: [item])

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(400))

            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [innerGroup])

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
            section.boundarySupplementaryItems = [header]

            return section
        } else {
            // 수평스크롤
            let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(80), heightDimension: .fractionalHeight(1.0))

            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0)

            let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(80), heightDimension: .absolute(200))

            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
            section.boundarySupplementaryItems = [header]
            section.orthogonalScrollingBehavior = .continuous

            return section
        }
    }
    return layout
}

// header의 사이즈 설정
private func makeHeaderView() -> NSCollectionLayoutBoundarySupplementaryItem {
    let headerSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(20))
    let header = NSCollectionLayoutBoundarySupplementaryItem(
        layoutSize: headerSize,
        elementKind: Identifier.SectionHeader.rawValue,
        alignment: .top)

    return header
}

이렇게 하면 위의 화면처럼 멀티섹션, 멀티모델로 각 섹션에 서로다른 cell로 collectionView를 만들 수 있다.

처음에는 좀 복잡했지만, 한번 이 구조를 알게된 이후에는 다양하게 활용할 수 있었다.

 


 *참고자료

https://unicus-fortis.tistory.com/23

 

[RxSwift] RxDatasource + Multiple Sections + Multiple Types + Multiple Cell Type

비출시 프로젝트 Chatify를 진행하면서 겪은 어려움과 해결방법에 대해 직접 부딪혀보고 과정과 결과를 기록해보고자 합니다. 검색하면서 내내 좀 답답했는데 결국 해결했습니다. 결정적인 참고

unicus-fortis.tistory.com

https://velog.io/@syong_e/TILCompositionalLayout%EC%9C%BC%EB%A1%9C-%EC%84%B9%EC%85%98%EB%A7%88%EB%8B%A4-%EB%8B%A4%EB%A5%B8-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0

 

[TIL]CompositionalLayout으로 섹션마다 다른 레이아웃 구성하기

복잡한 레이아웃을 유연하고, 약간 복잡하게(?) 만들어보자

velog.io