apple/UIKit & ReactiveX

ReactorKit + RxDataSources #2(RxTableViewSectionedAnimatedDataSource)

lgvv 2022. 10. 5. 23:51

ReactorKit + RxDataSources #2

(RxTableViewSectionedAnimatedDataSource)

 

개발환경

ReactorKit 3.2.0

RxDataSources 5.0.2

SnapKit 5.6.0

Xcode 14.0

폴더구조

 

1편을 기반으로 하고 있습니다.

https://rldd.tistory.com/501

 

ReactorKit + RxDataSources #1(SectionModelType)

ReactorKit + RxDataSources #1(SectionModelType) 개발환경 ReactorKit 3.2.0 RxDataSources 5.0.2 SnapKit 5.6.0 Xcode 14.0 RxDataSource는 이전에도 많이 사용해봄. DiffableDataSource를 이해하고 있다면 사..

rldd.tistory.com

 

TableView를 DiffableDataSource처럼 애니메이션이 적용되도록 구현하기 위해

RxTableViewSectionedAnimatedDataSource으로 변경하여 구현함.

 

1편 주석은 ✅ (0~14번)

2편의 주석은 🍋 (1~5번)

🌿 결과물 UI 🌿 

- RxTableViewSectionedAnimatedDataSource 사용시 애니메이션이 적용되는지 확인하기 위해 itemSelect시 첫번째 섹션 삭제

- RxTableViewSectionedReloadDataSource의 경우에는 reloadData()로 애니메이션 작동 안함.

애니메이션이 있다는 것만 확인

 

Step 1. 모델을 정의하기

import UIKit
import RealmSwift
import Differentiator // 🍋 0. Differentinator를 import
// Differentinator는 IdentifiableType 등을 정의해 두었음.

// ✅ 0. 사용할 모델읠 정의
class GWRxDataSourceModel: IdentifiableType {
    
    // 🍋 1. DiffableDataSource에서 그랬듯이 Hashable한 id값을 갖고 변화를 만든다.
    let identity: String = 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 UIKit
import RxDataSources
import Differentiator

// ✅ 1. Item을 정의합니다. -> 단일 섹션에 들어갈 수 있는 셀들을 정의
// 🍋 2. Equatable를 상속받아야 함. -> 그 이유는 AnimatableSectionModelType가 요구함.
enum TableViewCellSectionItem: Equatable, IdentifiableType {
    var identity: some Hashable { UUID().uuidString }
    
    // ✅ 1-1. Reactor를 셀에 붙여줄 것이므로 이렇게 설계합니다.
    case grayTextCell(GWGrayTextCellReactor)
    case toggleTextCell(GWToggleTextCellReactor)
    case imageCell(GWGWImageCellReactor)
    
    static func == (lhs: TableViewCellSectionItem, rhs: TableViewCellSectionItem) -> Bool {
        lhs.identity == rhs.identity
    }
}

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

// ✅ 3. 섹션을 RxDataSource의 SectionModelType을 상속받아서 구현
// - SectionModelType
// - AnimatableSectionModelType
// 다르게 사용할 수 있음
// 🍋 3. AnimatableSectionModelType을 상속
extension TableViewCellSection: AnimatableSectionModelType {
    // 🍋 4. identity를 선언 Hashable!
    var identity: String { UUID().uuidString }
    
    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 정의하기

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))
            ])
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .setSelectedIndexPath(let indexPath):
            newState.selectedIndexPath = indexPath
            
            // 🍋 6. 그냥 애니메이션을 보기 위한 코드
            var sections = GWViewReactor.confugurationSections()
            sections.removeFirst()
            newState.sections = sections
        }
        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]
        return [defaultsection, switchSection, defaultsection2, switchSection2]
        /*
         ✅ NOTE: - 만약 셀마다 서로 다른 모델을 사용할 경우에는 CellReactor에서 모델을 바꿔주면 된다!
         */
    }
}

Step 4. ViewController 정의하기

// ✅ 8. RxDataSourceDataSource정의
//typealias ManageMentDataSource = RxTableViewSectionedReloadDataSource<TableViewCellSection>

// 🍋 5. RxTableViewSectionedAnimatedDataSource로 정의 -> 이후에는 똑같음
typealias ManageMentDataSource = RxTableViewSectionedAnimatedDataSource<TableViewCellSection>

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 정의하기

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
    }
}

 

다음에는 셀안에 컬렉션 뷰나 테이블 뷰를 넣어보자!