프로젝트를 진행하던 도중 다음의 화면을 구현해야 했다.
처음에 든 생각은 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
[TIL]CompositionalLayout으로 섹션마다 다른 레이아웃 구성하기
복잡한 레이아웃을 유연하고, 약간 복잡하게(?) 만들어보자
velog.io
'iOS > UIKit' 카테고리의 다른 글
[iOS/UIKit] input-output구조에서 cell안의 버튼을 탭했을 때 viewModel로 이벤트 보내기 (0) | 2025.03.05 |
---|---|
[iOS/UIKit] Realm을 DiffableDataSource와 함께 사용할때 발생할 수 있는 오류 (0) | 2025.03.03 |
[iOS/UIKit] Diffable DataSource를 이용해 서로 다른 Cell로 구성하기 - Xcode16에서 발생할 수 있는 오류 (0) | 2025.02.28 |
[iOS/UIKit] debounce VS throttle (0) | 2025.02.26 |
[iOS/UIKit] RxSwift에서의 Cell의 중첩 구독 문제 (0) | 2025.02.23 |