RxDataSource_Animatable(with multi data)

Zeto·2023년 1월 3일
0

Swift_Rx

목록 보기
1/1

이전 포스트를 작성하기 위해 텍스트 파일 생성 프로젝트를 만들던 중, 파일 목록을 CollectionView에 출력하기 위해 RxDataSource를 적용하다 예상치 못하게 작업 시간이 많이 늘어지게 되었다. 사실 rx.items를 사용해서 간편하게 구현할 수도 있었지만 어차피 학습을 위한 프로젝트였으니 이왕 하는 거 그동안 궁금했었던 RxDataSource도 학습하면서 사용해보자라는 생각이었다.

덕분에 생각보다 많은 공수가 들어갔기는 했지만 앞으로도 자주 사용할 여지가 많은 CollectionViewTableView를 좀 더 간편하게 쓸 수 있지 않을까 싶다. 따라서 이번 포스트는 나름 학습하면서 적용해본 RxDataSource에 대해 작성해보고자 한다.

🧐 Why RxDataSource?

RxSwift를 적용하기 이전의 바닐라 상태에서는 UICollectionViewDataSourceViewController 혹은 따로 분리시킨 객체에 채택을 시키고 필요한 구문들을 활용하여 깔끔하게 UI가 출력되도록 정의해주어야 했다. 무지하게 복잡하거나 그렇지는 않지만 아무래도 프로토콜을 채택하고 몇 가지 요소들을 구현해주고 하는 부분들이 번거롭다고 생각될 수 있다.

이러한 부분들을 해결하고 RxSwift의 비동기와 적절하게 어울리도록 RxCocoa에서 rx.items 오퍼레이터를 제공해준다.

rx.items(_ source)
rx.items(dataSource:)
rx.items(cellIdentifier:,cellType:)

원하는 인자 타입을 받는 오퍼레이터를 CollectionViewTableView에 보여줄 셀에 필요한 데이터 값들을 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, 이 두가지 요소가 중요하다.

이제 기본적인 정리가 되었으니 실제로 활용하는 법에 대해 정리해보고자 한다.

👉 How can use RxDataSource

1. DataSource 선정하기

RxDataSource는 기본적인 형태와 Animated 형태 두 가지로 나눠서 구현이 가능하다. AnimatedDataSource는 자동으로 셀 애니메이션을 관리해주며, 기본 형태와 구현 과정이 아주 약간 다르다. 일단 이번 포스팅에서는 AnimatedDataSource를 기본으로 진행해보고자 한다.
(기본 형태는 검색하면 주루룩 나오므로 상대적으로 적은 검색결과가 나오는 요놈으로 진행)

2. DataModel 구현하기

기본 형태에서는 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을 준수하도록 해주었다.

3. SectionModel 구현하기

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를 채택시키는 형태로도 작성할 수 있다.

4. DataSource 구현하기

이제 우리가 앞서 만들었던 SectionModelRxTableViewSectionedAnimatedDataSource 클래스의 제네릭 타입으로 넣어주고, 이를 통해 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를 활용하면 헤더나 푸터에 대한 생성 처리도 맡길 수 있게 된다. 이외에 canEditRowAtIndexPathcanMoveRowAtIndexPath도 활용할 수 있지만 TableView에서만 사용이 가능하니 유의해야 한다.
(CollectionView는 은근히 안 되는 거 많고 구현하려면 손이 많이 가는 구석이 있으니, 꼭 필요한 경우가 아니면 TableView를 쓰는 것이 마음 편할 듯 하다.)

5. (추가) DataSourceManager 구현하기

이 부분은 번외이긴하나 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 프로퍼티에 접근해서 수정해줄 수도 있는 듯 하다.)

6. CollectionView 바인딩

이제 마지막으로 갱신된 SectionModel을 emit하는 RelayCollectionView를 바인딩해주면 데이터 변경에 따라 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를 인자로 받도록 해서 해당 데이터가 변경될 때마다 앞서 설정해둔 셀 관련 클로저가 호출될 수 있도록 해준다.

📝 정리

위에서 장황하게 코드도 넣고 주저리주저리 글을 많이 쓰기는 했지만 핵심 포인트는 DataSourceSectionModel이다. 이 두 요소만 정확하게 생성해주면 이후의 것들은 이전에도 사용했던 것들의 응용에 불과하기 때문에 해당 부분들에 좀 더 집중하면 좋을 것 같다.

(예시 프로젝트 코드)

profile
중2병도 iOS가 하고싶어

0개의 댓글