apple/RxSwift, ReactorKit

ReactorKit + RxDataSources #1(SectionModelType)

lgvv 2022. 10. 5. 12:49

ReactorKit + RxDataSources #1(SectionModelType)

 

개발환경

  • ReactorKit 3.2.0
  • RxDataSources 5.0.2
  • SnapKit 5.6.0
  • Xcode 14.0

 

RxDataSources 를 활용한 코드 플로우 간단 정리

  • 섹션마다 다른 셀을 적용
  • tableView를 활용하여 동적 높이를 구현함.
  • ReactorKit + RxDataSource를 활용하면 궁합이 좋음
  • cellReactor에 initialState의 값을 Model로 초기화

 

결과물 UI

결과물 UI

 

 

전체 코드 샘플

코드는 하나씩 플로우를 따라가면서 볼 예정

 

Step 1. 모델을 정의하기

// ✅ 0. 사용할 모델읠 정의
class GWRxDataSourceModel {
    var id = UUID().uuidString
    
    var content: String?
    var imageUrl: String?
    
    convenience init(content: String?, imageUrl: String? = nil) {
        self.init()
        self.content = content
        self.imageUrl = imageUrl
    }
}

 

Step 2. Section 정의하기

import RxDataSources

// ✅ 1. Item을 정의합니다. -> 단일 섹션에 들어갈 수 있는 셀들을 정의
enum TableViewCellSectionItem {
    // ✅ 1-1. Reactor를 셀에 붙여줄 것이므로 이렇게 설계합니다.
    case grayTextCell(GWGrayTextCellReactor)
    case toggleTextCell(GWToggleTextCellReactor)
    case imageCell(GWGWImageCellReactor)
}

// ✅ 2. Section을 정의합니다. -> 섹션!
enum TableViewCellSection {
    case grayTextSection([TableViewCellSectionItem])
    case toggleTextSection([TableViewCellSectionItem])
    case imageSection([TableViewCellSectionItem])
}

// ✅ 3. 섹션을 RxDataSource의 SectionModelType을 상속받아서 구현
// - SectionModelType
// - AnimatableSectionModelType
// 다르게 사용할 수 있음
extension TableViewCellSection: SectionModelType {
    
    typealias Item = TableViewCellSectionItem
    
    // ✅ 3-1. SectionModelType에 있는 아이템을 정의 각 아이템이 어떤 섹션으로 연결되는지를 결정.
    var items: [Item] {
        switch self {
        case .grayTextSection(let items):
            return items
        case .toggleTextSection(let items):
            return items
        case .imageSection(let items):
            return items
        }
    }
    
    // ✅ 3-2. init을 통해서 각 아이템에 맞도록 매핑
    init(original: TableViewCellSection, items: [TableViewCellSectionItem]) {
        switch original {
        case .grayTextSection:
            self = .grayTextSection(items)
        case .toggleTextSection:
            self = .toggleTextSection(items)
        case .imageSection:
            self = .imageSection(items)
        }
        
        /*
         ✅ 위의 코드와 같은 문법
        var original = original
        switch original {
        case .grayTextSection(let items):
            original = .grayTextSection(items)
        case .toggleTextSection(let items):
            original = .toggleTextSection(items)
        case .imageSection(let items):
            original = .imageSection(items)
        }
        
        self.init(original: original, items: items)
         */
    }
    
}


Step 3. ViewReactor 정의하기

만약 네트워크 통신 혹은 로컬에서 데이터를 가져온다면 비동기로 데이터를 가져와서 사용하는데 UseCase나 Repository등을 사용해 클린아키텍처 형태로 구성 가능!

import Foundation
import ReactorKit
import RxDataSources

class GWViewReactor: Reactor {
    
    // ✅ 4. CellType정의
    enum CellType {
        case grayTextCell(String, String)
        case toggleTextCell(String)
        case imageCell(String)
    }
    
    // MARK: - Property
    let initialState: State
    
    // MARK: - Constants
    enum Action {
        case cellSelected(IndexPath)
    }
    
    enum Mutation {
        case setSelectedIndexPath(IndexPath?)
    }
    
    struct State {
        var selectedIndexPath: IndexPath?
        var sections: [TableViewCellSection] // ✅ 5. sections 정의
    }
    
    init() {
        self.initialState = State(
            sections: GWViewReactor.confugurationSections() // ✅ 6. section을 reaction의 confugurationSections 메소드를 통해 값을 주입
        )
    }

    // MARK: - func
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .cellSelected(let indexPath):
            return Observable.concat([
                Observable.just(Mutation.setSelectedIndexPath(indexPath)),
                Observable.just(Mutation.setSelectedIndexPath(nil))
            ])
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .setSelectedIndexPath(let indexPath):
            newState.selectedIndexPath = indexPath
        }
        return newState
    }
    
    // ✅ 7. section을 reaction의 confugurationSections 메소드를 통해 값을 주입
    static func confugurationSections() -> [TableViewCellSection] {
        // ✅ 7-1. 셀 세팅
        let defaultCell = TableViewCellSectionItem.grayTextCell(GWGrayTextCellReactor(initialState: GWRxDataSourceModel(content: "여기는 textCell입니다. 여기는 텍스트가 들어가고 검정색 글자로 들어갑니다.", imageUrl: nil)))
        
        let defaultCell2 = TableViewCellSectionItem.grayTextCell(GWGrayTextCellReactor(initialState: GWRxDataSourceModel(content: "여기도 textCell입니다. 여기도 마찬가지지만 위랑 차이점은 height을 동적으로 구성할 수 있다는 것을 보여들고자 했습니다. 그리니까 넘 신경쓰지 않아주셔도 됩니다. 그냥 길이만 길어요. 지금부터는 아무말이나 적어서 길이를 늘려야 하는데요. 한글이 조금 더 많이 늘릴 수 있어서 한글로 적고 있습니다. 지금 저는 신공학관 로비에 있는데, 우산이 없습니다. 바깥에는 비가 우르르 쾅쾅오려고 합니다. 슬프지만 어쩔 수가 없습니다. 비를 맞으면서 집에 가야겠네요.")))
        
        // ✅ 7-1. 섹션 세팅
        let defaultsection = TableViewCellSection.grayTextSection([defaultCell, defaultCell2])
        
        let toggleCell1 = TableViewCellSectionItem.toggleTextCell(GWToggleTextCellReactor(initialState: GWRxDataSourceModel(content: "안녕하세요. 여기가 toggleTextCell입니다.")))
        let toggleCell2 = TableViewCellSectionItem.toggleTextCell(GWToggleTextCellReactor(initialState: GWRxDataSourceModel(content: "toggleTextCell입니다. 근데 길이가 길어지면 어떻게 될까요? 이렇게 처리하고자 합니다. 이건 뭐 UI라 변경할 수 있습니다. 간단하니까 너무 뭐라고 하지 마세요 다들 화이팅 입니다.")))
        
        let switchSection = TableViewCellSection.toggleTextSection([toggleCell1, toggleCell2])
        
        let defaultCell3 = TableViewCellSectionItem.grayTextCell(GWGrayTextCellReactor(initialState: GWRxDataSourceModel(content: "여기는 textCell입니다. 여기는 텍스트가 들어가고 검정색 글자로 들어갑니다. ")))
        
        let defaultCell4 = TableViewCellSectionItem.grayTextCell(GWGrayTextCellReactor(initialState: GWRxDataSourceModel(content: "여기도 textCell입니다. 여기도 마찬가지지만 위랑 차이점은 height을 동적으로 구성할 수 있다는 것을 보여들고자 했습니다. 그리니까 넘 신경쓰지 않아주셔도 됩니다. 그냥 길이만 길어요. 지금부터는 아무말이나 적어서 길이를 늘려야 하는데요. 한글이 조금 더 많이 늘릴 수 있어서 한글로 적고 있습니다. 지금 저는 신공학관 로비에 있는데, 우산이 없습니다. 바깥에는 비가 우르르 쾅쾅오려고 합니다. 슬프지만 어쩔 수가 없습니다. 비를 맞으면서 집에 가야겠네요.")))
        
        let defaultsection2 = TableViewCellSection.grayTextSection([defaultCell3, defaultCell4])
        
        let toggleCell3 = TableViewCellSectionItem.toggleTextCell(GWToggleTextCellReactor(initialState: GWRxDataSourceModel(content: "안녕하세요. 여기가 toggleTextCell입니다.")))
        
        let switchSection2 = TableViewCellSection.toggleTextSection([toggleCell3])
        
        
        let imageCell = TableViewCellSectionItem.imageCell(GWGWImageCellReactor(initialState: GWRxDataSourceModel(content: nil, imageUrl: "heartIcon")))
        let imageCell2 = TableViewCellSectionItem.imageCell(GWGWImageCellReactor(initialState: GWRxDataSourceModel(content: nil, imageUrl: "mapIcon")))
        
        let imageSection = TableViewCellSection.imageSection([imageCell, imageCell2])
        
        // ✅ 7-2. 섹션값들 반환
        return [defaultsection, switchSection, defaultsection2, switchSection2, imageSection]
        
        /*
         ✅ NOTE: - 만약 셀마다 서로 다른 모델을 사용할 경우에는 CellReactor에서 모델을 바꿔주면 된다!
         */
    }
}

 

Step 4. ViewController 정의하기

import UIKit
import ReactorKit
import RxDataSources
import RxCocoa
import RxSwift

// ✅ 8. RxDataSourceDataSource정의
typealias ManageMentDataSource = RxTableViewSectionedReloadDataSource<TableViewCellSection>
//typealias ManageMentDataSource = RxTableViewSectionedAnimatedDataSource<TableViewCellSection> // 🚨 Error: - Diffable처럼 사용하기 위해서는 이거 학습하기

class GWViewController: UIViewController, View {
    typealias Reactor = GWViewReactor
    
    // MARK: - Properties
    var disposeBag = DisposeBag()
    // ✅ 9. dataSource값을 세팅
    let dataSource: ManageMentDataSource = ManageMentDataSource(configureCell: { _, tableView, indexPath, items -> UITableViewCell in
        // ✅ 9-1. 아이템에 따라서 값을 세팅
        switch items {
        case .grayTextCell(let reactor):
            guard let cell = tableView.dequeueReusableCell(
                withIdentifier: GWGrayTextCell.identifier,
                for: indexPath
            ) as? GWGrayTextCell else { return UITableViewCell() }
            
            cell.reactor = reactor
            
            return cell
        case .toggleTextCell(let reactor):
            guard let cell = tableView.dequeueReusableCell(
                withIdentifier: GWToggleTextCell.identifier,
                for: indexPath
            ) as? GWToggleTextCell else { return UITableViewCell() }
            
            cell.reactor = reactor
            return cell
        case .imageCell(let reactor):
            guard let cell = tableView.dequeueReusableCell(
                withIdentifier: GWImageCell.identifier,
                for: indexPath
            ) as? GWImageCell else { return UITableViewCell() }
            
            cell.reactor = reactor
            return cell
        }
    })
    
    // MARK: - Binding
    func bind(reactor: GWViewReactor) {
        // ✅ 10. 만약 tableView의 delegate를 설정한다면 다음과 같이 줄 수 있음.
//        tableView.rx.setDelegate(self).disposed(by: disposeBag)
        
        // MARK: - Action
        tableView.rx.itemSelected
            .map { Reactor.Action.cellSelected($0) }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        // MARK: - State
        reactor.state.map { $0.selectedIndexPath }
            .compactMap { $0 } // nil 제거
            .withUnretained(self)
            .bind { owner, indexPath in
                owner.tableView.deselectRow(at: indexPath, animated: true)
            }
            .disposed(by: disposeBag)

        // ✅ 11. 핵심! -> section을 바인딩하여 dataSource를 주면 된다.
        reactor.state.map { $0.sections }
            .asObservable()
            .bind(to: self.tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
    }
    
    // MARK: - Initialize
    init(reactor: Reactor) {
        super.init(nibName: nil, bundle: nil)
        self.reactor = reactor
        
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setUI() {
        view.addSubview(tableView)
        tableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    // MARK: - UIComponents
    // ✅ 12. 셀을 다 등록해주면 끄읏!
    var tableView: UITableView = {
        var tv = UITableView(frame: .zero, style: .plain)
        tv.register(GWGrayTextCell.self, forCellReuseIdentifier: GWGrayTextCell.identifier)
        tv.register(GWToggleTextCell.self, forCellReuseIdentifier: GWToggleTextCell.identifier)
        tv.register(GWImageCell.self, forCellReuseIdentifier: GWImageCell.identifier)
        tv.rowHeight = UITableView.automaticDimension
        tv.estimatedRowHeight = 56
        return tv
    }()
}

//extension GWViewController: UITableViewDelegate {
////    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
////        return 56
////    }
//}

 

Step 5. Cell 및 CellReactor 정의하기

셀도 Reacotor를 가지는데, 작은 셀이라면 굳이 Reacotor없어도 된다.

import UIKit
import ReactorKit

class GWToggleTextCell: UITableViewCell, View {
    static let identifier = String(describing: self)
    
    typealias Reactor = GWToggleTextCellReactor
    var disposeBag = DisposeBag()
    
    // MARK: - Binding
    func bind(reactor: Reactor) {
        // ✅ 13. reactor에서 content를 가져와서 Cell을 그리기
        // cellReactor의 initialState의 값이 Model로 정의하는 스킬
        reactor.state.compactMap { $0.content }
            .withUnretained(self)
            .bind { owner, text in owner.contentLabel.text = text }
            .disposed(by: disposeBag)
    }
    
    // MARK: - Initialize
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Binding
    private func setUI() {
        [toggleView, contentLabel].forEach { contentView.addSubview($0) }
        
        toggleView.backgroundColor = .black
        toggleView.snp.makeConstraints {
            $0.top.equalToSuperview().inset(30)
            $0.leading.equalToSuperview().inset(30)
            $0.width.height.equalTo(10)
        }
        toggleView.layer.cornerRadius = 5
        
        contentLabel.textColor = .black
        contentLabel.numberOfLines = 0
        contentLabel.lineBreakMode = .byCharWrapping
        contentLabel.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        contentLabel.snp.makeConstraints {
            $0.top.equalTo(toggleView.snp.top)
            $0.leading.equalTo(toggleView.snp.trailing).offset(10)
            $0.bottom.equalToSuperview().inset(10)
            $0.trailing.equalToSuperview().inset(10)
        }
    }
    
    // MARK: - UIComponents
    let toggleView = UIView()
    let contentLabel = UILabel()
}

// ✅ 14. cellReactor를 정의
class GWToggleTextCellReactor: Reactor {
    typealias Action = NoAction
    
    let initialState: GWRxDataSourceModel // ✅ 15. (핵심) 초기 스테이트를 모델로 초기화
    
    init(initialState: GWRxDataSourceModel) {
        self.initialState = initialState
    }
}