관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. ReactorKit 샘플 앱 - RxDataSources을 이용한 Section, Item 모델 구현 패턴 (with 동적 사이즈 셀) 본문

iOS framework

[iOS - swift] 2. ReactorKit 샘플 앱 - RxDataSources을 이용한 Section, Item 모델 구현 패턴 (with 동적 사이즈 셀)

jake-kim 2021. 12. 16. 23:53

1. ReactorKit 샘플 앱 - RxDataSources 사용 방법

2. ReactorKit 샘플 앱 - RxDataSources을 이용한 Section, Item 모델 구현 패턴 (with 동적 사이즈 셀)

Section과 Item이 여러개인 TableView

기초

목차

1. 단일 Section 모델

2. 다중 Section 모델

3. 다중 Section, 다중 Item 모델

구현 핵심

  • SectionModel 정의 및 활용 방법
  • Section이 하나인 경우에는 SectionModel<Int, MyItem>으로, Section에는 Int형을 사용
    (가장 간단한 방법은 tableView.rx.items 방법 포스팅 글 참고)
    // Section이 하나인 경우
    
    import RxDataSources
    
    struct SingleSection {
      typealias MessageSectionModel = SectionModel<Int, MessageItem>
      
      enum MessageItem: Equatable {
        case firstItem(Message)
      }
    }​
  • Section이 여러개인 경우, 내부에 Section을 별도로 만들어서 그 타입을 Int대신 사용 SectionModel<MySection, MyItem>
    // Section이 여러개인 경우
    
    import RxDataSources
    
    struct MultiSection {
      typealias MessageSectionModel = SectionModel<MessageSection, MessageItem>
      
      enum MessageSection: Equatable {
        case main
        case sub
      }
      
      enum MessageItem: Equatable {
        case firstItem(Message)
      }
    }​

1. 단일 Section 모델

  • Section이 한개인 경우 모델
    import RxDataSources
    
    struct SingleSection {
      typealias MessageSectionModel = SectionModel<Int, MessageItem>
      
      enum MessageItem: Equatable {
        case firstItem(Message)
      }
    }​


  • ViewController쪽에서 dataSource 정의
    • dataSource의 타입은 배열이 아니라 Model하나로만 정의해놓고, bind할때 Array로 감싸서 dataSource로 방출
      func bind(reactor: SingleViewReactor) {
      
          // Action
          ...
      
          // State
          let dataSource = RxTableViewSectionedReloadDataSource<SingleSection.MessageSectionModel> { dataSource, tableView, indexPath, item in
            switch item {
            case .firstItem(let message):
              let cell = tableView.dequeueReusableCell(for: indexPath) as MessageTableViewCell
              cell.setMessage(message)
              return cell
            }
          }
        
          reactor.state.map(\.messageSection)
            .distinctUntilChanged()
            .map(Array.init(with:)) // <- extension으로 Array 초기화 시 차원을 하나 늘려주는 코드추가
            .bind(to: self.myTableView.rx.items(dataSource: dataSource))
            .disposed(by: self.disposeBag)
      }
  • ViewReactor에서 dataSource 모델을 정의하고, MessageSectionModel타입은 SectionModel<Int, Items> 타입
    • SectionModel의 초기화는 init(model:items:)이고, model자리에 Section 타입이 들어가므로 0으로 할당
      final class SingleViewReactor: Reactor {
        enum Action {
          case viewDidLoad
        }
        
        enum Mutation {
          case updateDataSource
        }
        
        struct State {
          var messageSection = SingleSection.MessageSectionModel(
            model: 0,
            items: []
          )
        }
        
        ...
        
      }​
    • 데이터가 변경될때도 model인자에 0으로 초기화
      func reduce(state: State, mutation: Mutation) -> State {
        var state = state
        switch mutation {
        case .updateDataSource:
          let messages = getMessageMock()
          let myItems = messages.map(SingleSection.MessageItem.firstItem)
          let mySectionModel = SingleSection.MessageSectionModel(model: 0, items: myItems) // <- 여기, section에는 0으로 초기화
          state.messageSection = mySectionModel
        }
        return state
      }​

2. 다중 Section 모델

  • Section이 여러개인 경우 모델
    • SectionModel에서 Section 타입 자리에 이번엔 Int대신 Section타입으로 선언 (Int인 경우 단일 section으로 사용)
      import RxDataSources
      
      struct MultiSection {
        typealias MessageSectionModel = SectionModel<MessageSection, MessageItem>
        
        enum MessageSection: Equatable {
          case main
          case sub
        }
        
        enum MessageItem: Equatable {
          case firstItem(Message)
        }
      }​
  • ViewController쪽에서 dataSource 정의
    • dataSource의 타입은 배열이 아니라 Model하나로만 정의해놓고, bind할때 Array로 감싸서 dataSource로 방출
      func bind(reactor: MultiViewReactor) {
        // Action
        ...
        
        // State
        let dataSource = RxTableViewSectionedReloadDataSource<MultiSection.MessageSectionModel> { dataSource, tableView, indexPath, item in
          switch item {
          case .firstItem(let message):
            let cell = tableView.dequeueReusableCell(for: indexPath) as MessageTableViewCell
            cell.setMessage(message)
            return cell
          }
        }
      
        reactor.state.map(\.messageSection)
          .distinctUntilChanged()
          .map(Array.init(with:)) // <- extension으로 Array 초기화 시 차원을 하나 늘려주는 코드추가
          .bind(to: self.myTableView.rx.items(dataSource: dataSource))
          .disposed(by: self.disposeBag)
      }​
  • ViewReactor에서 dataSource 모델을 정의하고, MessageSectionModel타입은 SectionModel<Section, Items> 타입
    • SectionModel의 초기화는 init(model:items:)이고, model자리에 정의한 sectino타입 주입
      (단일인 경우에는 model자리에 Section 타입이 들어가므로 0으로 할당)
      final class MultiViewReactor: Reactor {
        enum Action {
          case viewDidLoad
        }
        
        enum Mutation {
          case updateDataSource
        }
        
        struct State {
          var messageSection = MultiSection.MessageSectionModel(
            model: .main,
            items: []
          )
        }
        
        ...
        
      }​
  • 데이터가 변경될때 model인자에 정의한 타입으로 초기화
    func reduce(state: State, mutation: Mutation) -> State {
      var state = state
      switch mutation {
      case .updateDataSource:
        let messages = getMessageMock()
        let myItems = messages.map(SingleSection.MessageItem.firstItem)
        let mySectionModel = MultiSection.MessageSectionModel(model: .main, items: myItems) // <- 여기, section에는 0으로 초기화
        state.messageSection = mySectionModel
      }
      return state
    }​

3. 다중 Section, 다중 Item

  • 핵심 키워드
    • tableView.rowHeight = UITableView.automaticDimension
    • Reactor.State에 Section 모두 정의
    • ViewController에서 state 바인딩 시, reactor.state.map { [$0.messageSection, $0.imageSection] }
  • Section이 여러개인 경우 모델
    • Section이 여러개, Item이 여러개
      import RxDataSources
      
      struct ComplexSection {
        typealias Model = SectionModel<MySection, MyItem>
        
        enum MySection: Equatable {
          case message
          case image
        }
        
        enum MyItem: Equatable {
          case message(Message)
          case photo(UIImage?)
        }
      }​
  • Reactor에서 State를 Section 별로 선언
    final class ComplexViewReactor: Reactor {
      enum Action {
        case viewDidLoad
      }
      
      enum Mutation {
        case setPhotos
        case setMessages
      }
      
      struct State {
        var messageSection = ComplexSection.Model(
          model: .message,
          items: []
        )
        var imageSection = ComplexSection.Model(
          model: .image,
          items: []
        )
      }
      
      ...
    }​
  • mutate(action:)에서 concat으로 방출
      // ComplexViewReactor.swift
      
      func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .viewDidLoad:
          return .concat(
            [Observable<Mutation>.just(.setMessages),
             Observable<Mutation>.just(.setPhotos)
            ]
          )
        }
      }​
      
      func reduce(state: State, mutation: Mutation) -> State {
        var state = state
        switch mutation {
        case .setMessages:
          let messages = getMessageMock()
          let myItems = messages.map(ComplexSection.MyItem.message)
          let mySectionModel = ComplexSection.Model(model: .message, items: myItems)
          state.messageSection = mySectionModel
        case .setPhotos:
          let photo = UIImage(named: "snow")
          let myItems = ComplexSection.MyItem.photo(photo)
          let mySectionModel = ComplexSection.Model(model: .image, items: [myItems])
          state.imageSection = mySectionModel
        }
        return state
      }
  • ViewController에서 tableView.rowHeight = UITableVIew.automaticDimension
    • automaticDimension 사용 주의: Cell의 Autolayout을 완벽하게 하지 않으면 동적으로 size가 유지되지 않으므로 주의
      // ComplexViewController.swift
      
      private let myTableView = UITableView().then {
        $0.register(cellType: MessageTableViewCell.self)
        $0.register(cellType: PhotoTableViewCell.self)
        $0.rowHeight = UITableView.automaticDimension // <- 이곳
      }

  • bind(reactor:)에서 dataSource 정의
    // ComplexViewController.swift
    
    func bind(reactor: ComplexViewReactor) {
      // Action
      ...
      
      // State
      let dataSource = RxTableViewSectionedReloadDataSource<ComplexSection.Model> { dataSource, tableView, indexPath, item in
        Self.configureCollectionViewCell(
          tableView: tableView,
          indexPath: indexPath,
          item: item
        )
      }
      
      dataSource.titleForHeaderInSection = { dataSource, index in
        let section = dataSource.sectionModels[index].model
        switch section {
        case .message:
          return "Message Section"
        case .image:
          return "Image Section"
        }
      }
    }

  • 바인딩
    • section에 관한 바인딩을 한꺼번에 할 것 [$0.messageSection, $0.imageSection]
      // ComplexViewController.swift
      
      func bind(reactor: ComplexViewReactor) {
        // Action
        ...
        
        // State
        ...
        
        reactor.state.map { [$0.messageSection, $0.imageSection] }
          .distinctUntilChanged()
          .bind(to: self.myTableView.rx.items(dataSource: dataSource))
          .disposed(by: disposeBag)
      }

  • 바인딩 시 주의 - 바인딩을 따로하게되면 dataSource를 중복으로 바인딩되는걸로 인식되므로 compile error 발생
    // 이렇게 따로 바인딩하지 않는것을 주의 (compile error)
    reactor.state.map(\.messageSection)
      .distinctUntilChanged()
      .map(Array.init(with:))
      .bind(to: self.myTableView.rx.items(dataSource: dataSource))
      .disposed(by: self.disposeBag)
    
    reactor.state.map(\.imageSection)
      .distinctUntilChanged()
      .map(Array.init(with:))
      .bind(to: self.myTableView.rx.items(dataSource: dataSource))
      .disposed(by: self.disposeBag)

 

* 전체 소스 코드: https://github.com/JK0369/ExRxDataSourcePattern

Comments