이전 포스트를 작성하기 위해 텍스트 파일 생성 프로젝트를 만들던 중, 파일 목록을 CollectionView
에 출력하기 위해 RxDataSource
를 적용하다 예상치 못하게 작업 시간이 많이 늘어지게 되었다. 사실 rx.items
를 사용해서 간편하게 구현할 수도 있었지만 어차피 학습을 위한 프로젝트였으니 이왕 하는 거 그동안 궁금했었던 RxDataSource
도 학습하면서 사용해보자라는 생각이었다.
덕분에 생각보다 많은 공수가 들어갔기는 했지만 앞으로도 자주 사용할 여지가 많은 CollectionView
나 TableView
를 좀 더 간편하게 쓸 수 있지 않을까 싶다. 따라서 이번 포스트는 나름 학습하면서 적용해본 RxDataSource
에 대해 작성해보고자 한다.
RxSwift
를 적용하기 이전의 바닐라 상태에서는 UICollectionViewDataSource
를 ViewController
혹은 따로 분리시킨 객체에 채택을 시키고 필요한 구문들을 활용하여 깔끔하게 UI가 출력되도록 정의해주어야 했다. 무지하게 복잡하거나 그렇지는 않지만 아무래도 프로토콜을 채택하고 몇 가지 요소들을 구현해주고 하는 부분들이 번거롭다고 생각될 수 있다.
이러한 부분들을 해결하고 RxSwift
의 비동기와 적절하게 어울리도록 RxCocoa
에서 rx.items
오퍼레이터를 제공해준다.
rx.items(_ source)
rx.items(dataSource:)
rx.items(cellIdentifier:,cellType:)
원하는 인자 타입을 받는 오퍼레이터를 CollectionView
나 TableView
에 보여줄 셀에 필요한 데이터 값들을 emit
하는 Observable
에 바인딩해주면 데이터 값이 변경될 때마다 변경 메서드 호출이나 리로드 메서드 호출 등의 번거로운 작업 없이도 UI를 변경시켜준다.
let example: Observable<[Int]> = Observable.just([1, 2, 3, 4])
example.bind(to: collectionView.rx.items(cellIdentifier: ExampleCell.identifier)) { index, cellData, cell in
cell.setProperties(from: cellData)
}
.disposed(by: disposeBag)
이렇듯 해당 오퍼레이터를 사용하면 굉장히 간편하게 DataSource
작업을 관리해준다. 다만 위의 예시를 언뜻 보면 약간 느낌이 오는 것처럼 해당 오퍼레이터만으로는 충분한 퍼포먼스를 제공해주지는 못 하며, 아래와 같은 치명적인 단점이 존재를 한다.
- 여러 Section들에 대한 데이터 바인딩이 어렵다
- 아이템들에 대한 추가나 수정, 삭제 등의 액션에 대한 애니메이션 적용이 어렵다
그렇기에 이러한 문제점을 해결하기 위해 RxDataSource
라는 라이브러리를 추가로 제공하는 것이다. RxDataSource
에서는 섹션을 구분하고 해당 섹션에 필요한 데이터들을 가지고 있는 SectionModel
과 해당 섹션들을 가지고 셀과의 작업들을 수행해주는 SectionDataSource
, 이 두가지 요소가 중요하다.
이제 기본적인 정리가 되었으니 실제로 활용하는 법에 대해 정리해보고자 한다.
RxDataSource
는 기본적인 형태와 Animated 형태 두 가지로 나눠서 구현이 가능하다. AnimatedDataSource
는 자동으로 셀 애니메이션을 관리해주며, 기본 형태와 구현 과정이 아주 약간 다르다. 일단 이번 포스팅에서는 AnimatedDataSource
를 기본으로 진행해보고자 한다.
(기본 형태는 검색하면 주루룩 나오므로 상대적으로 적은 검색결과가 나오는 요놈으로 진행)
기본 형태에서는 SectionModel
의 구현에만 신경 써주면 되지만 Animated 형태에서는 SectionModel
이 관리할 데이터들도 약간의 가공이 필요하다.
struct TxtFileDTO {
let fileUrl: URL
let title: String
let subText: String
}
extension TxtFileDTO: Equatable { }
// CellModel 구현 때에는 RxDataSource가 import 되어 있음
enum CellModel {
case textFile(TxtFileDTO)
}
extension CellModel: IdentifiableType, Equatable {
typealias Identity = String
var identity: String {
switch self {
case .textFile(_):
return "TxtCellModel"
}
}
}
현재 구조체 하나와 Enum 타입 하나, 이렇게 구현되어 있는데 섹션마다 다른 데이터 값이 필요한 경우를 가정해서 각각 분기에 맞춰 적절한 값을 접근할 수 있도록 해주기 위해서 해당 방식으로 구현한 것이다.
(일반적으로는 TxtFileDTO
만 구현하고 해당 타입을 SectionModel
의 데이터 타입으로 넣어주면 된다)
Animated 타입에서의 SectionModel
소유 데이터들은 각자의 고유 identity를 가져야만 하며, 이를 위해서는 IdentifiableType
채택이 필수이다. 각 액션에 따른 애니메이션을 이용하기 위해서는 데이터에 대한 명확한 구분이 필요하기 때문인데, 해당 프로토콜을 채택할 경우에 Equatable
또한 필연적으로 채택해야 한다.
다만 현재 DTO는 본인이 직접 만든 타입이다보니 CellModel
에서 Equatable
을 채택하면 ==
함수를 추가로 구현하여 동일성 여부에 대한 판별 조건을 만들어줘야 한다. 이 때문에 DTO 자체에 미리 Equatable
을 채택하여 굳이 추가 구현을 하지 않고 CellModel
에서 Equatable
을 준수하도록 해주었다.
SectionModel
을 구현하기 위해서는 AnimatableSectionModelType
라는 프로토콜을 해당 모델 타입에다가 채택해주어야 한다 (기본 형태는 SectionModelType
을 채택).
struct SectionModel {
var header: String
var items: [CellModel]
}
extension SectionModel: AnimatableSectionModelType {
typealias Item = CellModel
typealias Identity = String
var identity: String {
return header
}
init(original: SectionModel, items: [CellModel]) {
self = original
self.items = items
}
}
앞선 데이터 모델과 동일하게 SectionModel
또한 identity를 등록해주어야 한다. 위의 예시에서는 익스텐션 구문에서 identity를 등록하여 연산 프로퍼티로 작성했지만 미리 프로퍼티를 등록하고 그에 맞는 이니셜라이저를 구현한 뒤, 익스텐션 구문에서 AnimatableSectionModelType
를 채택시키는 형태로도 작성할 수 있다.
이제 우리가 앞서 만들었던 SectionModel
을 RxTableViewSectionedAnimatedDataSource
클래스의 제네릭 타입으로 넣어주고, 이를 통해 CollectionView
의 셀들을 구성해주면 된다.
class MainViewController: UIViewController {
private lazy var mainCollectionView: UICollectionView = .init(frame: .zero, collectionViewLayout: .init()).then {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: self.view.frame.width, height: 70)
layout.scrollDirection = .vertical
layout.minimumLineSpacing = 10
$0.backgroundColor = .white
$0.collectionViewLayout = layout
$0.showsVerticalScrollIndicator = false
$0.showsHorizontalScrollIndicator = false
$0.register(TxtFileCell.self, forCellWithReuseIdentifier: TxtFileCell.reuseIdentifier)
}
// ...중략
private var collectionViewDataSource: RxCollectionViewSectionedAnimatedDataSource<SectionModel>?
// ...중략
}
private extension MainViewController {
// ...중략
func configureCollectionViewDataSource() {
self.collectionViewDataSource = RxCollectionViewSectionedAnimatedDataSource<SectionModel>(animationConfiguration: .init(insertAnimation: .top, reloadAnimation: .fade, deleteAnimation: .left)) { dataSource, collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TxtFileCell.reuseIdentifier, for: indexPath) as? TxtFileCell else {
return .init()
}
switch item {
case .textFile(let textDTO):
cell.setProperties(with: textDTO)
}
return cell
}
}
// 헤더나 푸터 생성
self.collectionViewDataSource?.configureSupplementaryView = { dataSource, collectionView, kind, indexPath in
return
}
}
init(configureCell:)
만으로도 충분히 셀을 생성할 수 있지만, AnimatableSectionModelType
를 사용했던 이유가 자동 애니메이션 때문이었으니 init(animationConfiguration:,configureCell:)
으로 기본적인 애니메이션 세팅까지 진행해주었다.
DataSource
에 있는 configureSupplementaryView
를 활용하면 헤더나 푸터에 대한 생성 처리도 맡길 수 있게 된다. 이외에 canEditRowAtIndexPath
나 canMoveRowAtIndexPath
도 활용할 수 있지만 TableView
에서만 사용이 가능하니 유의해야 한다.
(CollectionView
는 은근히 안 되는 거 많고 구현하려면 손이 많이 가는 구석이 있으니, 꼭 필요한 경우가 아니면 TableView
를 쓰는 것이 마음 편할 듯 하다.)
이 부분은 번외이긴하나 DataSource
에 대한 책임을 온전히 질 수 있는 객체를 분리시키고 싶어서 만든 객체로서 SectionModel
의 생성과 수정, 삭제 등의 작업을 진행하고 최신의 데이터들을 보존하고 있다.
final class RxDataSourceManager {
static let shared: RxDataSourceManager = .init()
private let txtFileManger: TextFileManager = .init()
private var allSections: [SectionModel] = []
private var mainSection: SectionModel?
private var mainSectionItems: [CellModel] = []
private init() {
self.fetchFileDatas()
}
func fetchSectionModels() -> [SectionModel] {
return self.allSections
}
// ...중략
}
private extension RxDataSourceManager {
func fetchFileDatas() {
guard let fileURLs = self.txtFileManger.fetchAllTextFilesURL() else { return }
var sectionItems: [CellModel] = []
fileURLs.forEach {
guard let content = try? String(contentsOf: $0, encoding: .utf8) else { return }
let title = $0.lastPathComponent
let originTitle = title.replacingOccurrences(of: ".txt", with: "")
let item: TxtFileDTO = .init(fileUrl: $0, title: originTitle, subText: content)
sectionItems.append(.textFile(item))
}
self.mainSectionItems = sectionItems
let section: SectionModel = .init(header: "Main", items: sectionItems)
self.mainSection = section
self.allSections = [section]
}
}
생성된 매니저 인스턴스를 공유하는 로직을 이번 프로젝트에서는 굳이 작성할 필요를 느끼지 못 해서 일단은 싱글톤 타입으로 구현해주었고, 생성되는 즉시 초기 데이터들을 가져온다.
func createFileData(title: String, context: String) {
guard let fileURL = self.txtFileManger.createTextFile(title: title, context: context) else { return }
let newItem: TxtFileDTO = .init(fileUrl: fileURL, title: title, subText: context)
self.mainSectionItems.insert(.textFile(newItem), at: 0)
guard let mainSection else { return }
let newSection: SectionModel = .init(original: mainSection, items: self.mainSectionItems)
self.mainSection = newSection
self.allSections = [newSection]
}
func modifyFileData(with model: CellModel, newTitle: String, newContext: String) {
switch model {
case .textFile(let txtFileDTO):
guard self.txtFileManger.removeTextFile(with: txtFileDTO.fileUrl) else { return }
for section in allSections {
guard section.items.contains(.textFile(txtFileDTO)) else { break }
let newItems = section.items.filter { $0 != .textFile(txtFileDTO) }
self.mainSectionItems = newItems
let newSection: SectionModel = .init(original: section, items: newItems)
self.mainSection = newSection
self.allSections = [newSection]
self.createFileData(title: newTitle, context: newContext)
return
}
}
}
func removeFileData(with indexPath: [IndexPath]) {
guard let sectionIndex = indexPath.first?.section, let rowIndex = indexPath.first?.row, var section = allSections[safe: sectionIndex], let item = section.items[safe: rowIndex] else { return }
switch item {
case .textFile(let txtFileDTO):
guard self.txtFileManger.removeTextFile(with: txtFileDTO.fileUrl) else { return }
section.items.remove(at: rowIndex)
self.mainSectionItems = section.items
guard let mainSection else { return }
let newSection: SectionModel = .init(original: mainSection, items: self.mainSectionItems)
self.mainSection = newSection
self.allSections = [newSection]
}
}
이외로 생성이나 수정, 삭제 등을 수행하는 메서드도 구현해주었다. SectionModel
의 경우 신규 생성이 아닌 데이터 모델의 변경 등으로 인해 수정하는 상황일 경우에는 init(original:, items:)
를 활용하였다.
(그냥 SectionModel
의 items 프로퍼티에 접근해서 수정해줄 수도 있는 듯 하다.)
이제 마지막으로 갱신된 SectionModel
을 emit하는 Relay
와 CollectionView
를 바인딩해주면 데이터 변경에 따라 UI도 변화하는 모습을 출력해줄 수 있다.
// ViewModel의 Output
struct Output {
let collectionSectionModels: PublishRelay<[SectionModel]> = .init()
}
// ViewController에서 바인딩한 로직
func bindWithViewModel() {
guard let collectionViewDataSource else { return }
let input: MainViewModel.Input = .init(viewWillAppearDriver: self.rx.viewWillAppear.asDriver(onErrorJustReturn: false))
let output = mainVM.transform(with: input)
output.collectionSectionModels
.bind(to: mainCollectionView.rx.items(dataSource: collectionViewDataSource))
.disposed(by: disposeBag)
}
이전에 DataSource
를 사용하지 않고 간편하게 바인딩할 때에도 rx.items
를 사용했지만, 이번에는 DataSource
를 인자로 받도록 해서 해당 데이터가 변경될 때마다 앞서 설정해둔 셀 관련 클로저가 호출될 수 있도록 해준다.
위에서 장황하게 코드도 넣고 주저리주저리 글을 많이 쓰기는 했지만 핵심 포인트는 DataSource
와 SectionModel
이다. 이 두 요소만 정확하게 생성해주면 이후의 것들은 이전에도 사용했던 것들의 응용에 불과하기 때문에 해당 부분들에 좀 더 집중하면 좋을 것 같다.