[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Combine & Rx

[RxSwift] RxCocoa로 TableView 구현하기

inu 2022. 5. 2. 23:21

안녕하세요 이누입니다.

오늘은 RxCocoa를 기반으로 TableView를 구현하는 방법에 대해 정리해보았습니다.

 

개인적으로 UITableViewDataSource같은 Protocol을 채택하고 구현하지 않아도 되어서 좋더라구요!

그럼 시작합니다.


RxCocoa - TableView 구현

기존에 TableView의 Cell을 구현하기 위해서는 UITableViewDelegate 객체 활용이 필수적이었습니다.

하지만 RxCocoa를 활용하면 DataSource 없이 메서드를 기반으로 Cell 구현이 가능합니다.

 

RxCocoa의 UITableView+Rx.swift를 살펴봅시다

​​​​public func items<Sequence: Swift.Sequence, Source: ObservableType> ​​​​​​​​(_ source: Source) ​​​​​​​​-> (_ cellFactory: @escaping (UITableView, Int, Sequence.Element) -> UITableViewCell) ​​​​​​​​-> Disposable ​​​​​​​​where Source.Element == Sequence { ​​​​​​​​​​​​return { cellFactory in ​​​​​​​​​​​​​​​​let dataSource = RxTableViewReactiveArrayDataSourceSequenceWrapper<Sequence>(cellFactory: cellFactory) ​​​​​​​​​​​​​​​​return self.items(dataSource: dataSource)(source) ​​​​​​​​​​​​} ​​​​}

items라는 메서드가 존재함을 알 수 있습니다. 이를 활용하면 Cell을 구현할 수 있습니다. 구현 내용을 이해하는 것도 좋지만 우선 사용방법을 익히는 것은 목표로 하고 예제를 살펴봅시다.

​​​​​let items = Observable.just([ ​​​​​​​​​"First Item", ​​​​​​​​​"Second Item", ​​​​​​​​​"Third Item" ​​​​​]) ​​​​​items ​​​​​.bind(to: tableView.rx.items) { (tableView, row, element) in ​​​​​​​​​let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")! ​​​​​​​​​cell.textLabel?.text = "\(element) @ row \(row)" ​​​​​​​​​return cell ​​​​​} ​​​​​.disposed(by: disposeBag)

조금 내려보면 이와 같은 형태의 주석처리된 샘플코드를 확인할 수 있습니다. 아주 좋은 사용 예제네요!

 

tableView.rx.items에 bind를 걸어주면 tableView, row, element로 구성된 클로저를 구현할 수 있게됩니다. (이 때 받아오는 event가 배열 한개를 통채로 넘겨주는 Observable<[String]> 타입임에 유의합시다! 배열을 기반으로 구성된 Observable<String>이 아닙니다!) 이를 통해 Cell을 구성하고 리턴하면 됩니다. Cell을 구성하는 것은 뭐 많이 해봤으니 익숙하겠죠? dequeueReusableCell로 Cell을 받아온 다음 element(Observable에서 방출된 item들)을 기반으로 Cell 형태를 구성하면 됩니다.

 

기존 UITableViewDataSource 객체의 cellForRowAt 메서드와 동일한 역할을 수행하고 있네요! 생각보다 쉽군요.

 

파일을 좀 더 내려보면 다른 샘플 코드를 하나 더 확인할 수 있습니다.

​​​​​let items = Observable.just([ ​​​​​​​​​"First Item", ​​​​​​​​​"Second Item", ​​​​​​​​​"Third Item" ​​​​​]) ​​​​​items ​​​​​​​​​.bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: UITableViewCell.self)) { (row, element, cell) in ​​​​​​​​​​​​cell.textLabel?.text = "\(element) @ row \(row)" ​​​​​​​​​} ​​​​​​​​​.disposed(by: disposeBag)

이 역시 비슷한 방식인데요, 기본 Cell을 사용하지 않고 Custom Cell을 사용하는 경우에는 필수적으로 이 방법을 사용해야 합니다. 앞선 코드와 다르게 파라미터를 필요로하고 있죠? 여기에 Cell identifier와 Type을 입력해주면 됩니다.

 

이 메서드는 앞선 기본 items보다 더 많은 일을 수행해줍니다. 클로저를 보면 row, element, cell을 입력값으로 받죠? tableView가 빠진 대신 아예 직접 cell을 반환해주고 있는 것입니다. cell을 불러오는 작업도, 형 변환하는 과정도 내부적으로 처리해주기 때문에 사용자는 이를 받아와서 처리만해주면 됩니다.

RxCocoa - TableView 이벤트처리

기존에는 UITableViewDeleage를 활용해 이벤트를 처리해야 했습니다. 하지만 RxCocoa를 활용하면 Extension에 추가되어 있는 Observable을 구독하는 방식으로 이벤트를 처리할 수 있습니다.

 

이 역시 UITableView+Rx.swift를 살펴봅시다.

​​​​// events ​​​​/** Reactive wrapper for `delegate` message `tableView:didSelectRowAtIndexPath:`. */ ​​​​public var itemSelected: ControlEvent<IndexPath> { ​​​​​​​​let source = self.delegate.methodInvoked(#selector(UITableViewDelegate.tableView(_:didSelectRowAt:))) ​​​​​​​​​​​​.map { a in ​​​​​​​​​​​​​​​​return try castOrThrow(IndexPath.self, a[1]) ​​​​​​​​​​​​} ​​​​​​​​return ControlEvent(events: source) ​​​​} ​​​​...

itemSelected를 포함해 다양한 ControlEvent가 존재함을 알 수 있습니다. itemSelected ControlEvent는 Cell이 선택될 때마다 didSelectRowAt message에 반응하여 Next로 이벤트를 방출합니다. 이벤트에는 IndexPath가 포함되어 있기 때문에 이를 활용한 작업을 처리할 수 있습니다.

 

결과적으로 UITableViewDelegate의 didSelectRowAt 메서드와 동일한 역할을 수행하네요!

​​​​tableView.rx.itemSelected ​​​​​​​​.subscribe(onNext: { [weak self] indexPath in ​​​​​​​​​​​​self?.tableView.deselectRow(at: indexPath, animated: true) ​​​​​​​​}) ​​​​​​​​.disposed(by: disposeBag)

이를 활용해 이런 작업을 처리할 수 있습니다.

itemSelected를 활용해 item이 선택되었을 때 선택상태를 바로 해제하도록하는 예제입니다.

 

그런데 RxSwift에는 더 특이하고 유용한 메서드가 존재합니다.

​​​​public func modelSelected<T>(_ modelType: T.Type) -> ControlEvent<T> { ​​​​​​​​let source: Observable<T> = self.itemSelected.flatMap { [weak view = self.base as UITableView] indexPath -> Observable<T> in ​​​​​​​​​​​​guard let view = view else { ​​​​​​​​​​​​​​​​return Observable.empty() ​​​​​​​​​​​​} ​​​​​​​​​​​​return Observable.just(try view.rx.model(at: indexPath)) ​​​​​​​​} ​​​​​​​​return ControlEvent(events: source) ​​​​}

modelSelected 메서드가 그것입니다. 이를 통하면 ModelType을 받아올 수 있습니다. (버퍼에 Observable로부터 받아온 데이터를 저장하고 있는 형태인 것 같네요)

​​​​tableView.rx.modelSelected(Item.self) ​​​​​​​​.subscribe(onNext: { item in ​​​​​​​​​​​​print(item.name) ​​​​​​​​}) ​​​​​​​​.disposed(by: disposeBag)

이런식으로 구성하면 Item 타입에 존재하는 name값이 결과로 출력됩니다.

Observable.zip(tableView.rx.modelSelected(Item.self), tableView.rx.itemSelected) ​​​​.bind { [weak self] (item, indexPath) in ​​​​​​​​​​​​print(item.name) ​​​​​​​​​​​​self?.tableView.deselectRow(at: indexPath, animated: true) ​​​​} ​​​​disposed(by: disposeBag)

이 두개의 ControlEvent는 한번에 방출을 처리하기 때문에 zip으로 묶어 하나로 관리할 수 있습니다.

RxCocoa - delegate를 지정하는 방법

작업을 하다보면 RxCocoa의 ControlEvent와 더불어 기존 CocoaTouch 시스템의 Delegate를 활용해야 하는 경우가 많습니다.

​​​​tableView.delegate = self

그런데 막상 적용을 하고 사용하려하니 ControlEvent를 바인딩해 지정해준 작업 모두가 처리되지 않는 이슈를 만났습니다.


아마 delegate가 message를 가져가면서 RxCocoa 쪽에서는 확인할 수 없어져서 발생하는 문제인 것 같네요. 해결해봅시다.

​​​​tableView.rx.setDelegate(self) ​​​​​​​​.disposed(by: disposeBag)

Rx에서 해결방법을 자체적으로 제공합니다. rx가 제공하는 setDelegate 메서드를 사용하면 내부적으로 해당 에러를 처리해서 delegate를 지정해줍니다. 아주 편리하네요!


+) Section을 나누어 표시하고 삭제 및 이동을 구현하는 것은 RxCocoa에 존재하는 것만으로는 어렵습니다. RxCocoa를 덜어내고 Delegate만을 사용해 구현하거나, RxDataSource를 사용하는 것이 좋습니다. 다음번엔 이 RxDataSource에 대해 다뤄보겠습니다.