Skip to content

Instantly share code, notes, and snippets.

@audrl1010
Last active January 17, 2024 09:49
Show Gist options
  • Save audrl1010/111fd99102d0a77c6a22992eeee702c3 to your computer and use it in GitHub Desktop.
Save audrl1010/111fd99102d0a77c6a22992eeee702c3 to your computer and use it in GitHub Desktop.
ReactorKit+Ribs
import RIBs
import RxSwift
import RxRelay
import ReactorKit

protocol FAQsRouting: ViewableRouting {
  func routeToFAQ(_ faq: FAQ)
  func detachFAQ()
}

protocol FAQsPresentable: Presentable {
  var reactor: FAQsReactor? { get set }
  func bind(reactor: FAQsReactor)
}

protocol FAQsReactor: class {
  var state: Observable<FAQsInteractor.State> { get }
  var action: ActionSubject<FAQsInteractor.Action> { get }
  var currentState: FAQsInteractor.State { get }
}

protocol FAQsListener: class {
  func faqsViewDidDisappear()
}

extension FAQsInteractor {
  func faqViewDidDisappear() {
    self.router?.detachFAQ()
  }
}

final class FAQsInteractor
  : PresentableInteractor<FAQsPresentable>
  , FAQsInteractable
  , Reactor
  , FAQsReactor {
  
  typealias NextID = String
  
  enum Action {
    case refresh
    case viewDidDisappear
    case select(IndexPath)
  }
  
  enum Mutation {
    case setRefreshing(Bool)
    case setFAQs([FAQ])
    case updateFAQ(FAQ, Int)
    case updateSections
  }
  
  struct State {
    var isRefreshing: Bool = false
    var faqs: [FAQ] = []
    var sections: [FAQsViewSection] = [FAQsViewSection(identity: .faqs, items: [])]
  }
  
  let initialState: State = State()
  
  weak var router: FAQsRouting?
  weak var listener: FAQsListener?
  
  private let faqService: FAQServiceProtocol
  
  init(presenter: FAQsPresentable, faqService: FAQServiceProtocol) {
    defer  { _ = self.state }
    self.faqService = faqService
    super.init(presenter: presenter)
    self.presenter.reactor = self
  }
  
  override func didBecomeActive() {
    super.didBecomeActive()
    self.presenter.bind(reactor: self)
  }
  
  override func willResignActive() {
    super.willResignActive()
  }
  
  func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
    return Observable.merge(mutation, self.faqServiceMutation())
  }
  
  private func faqServiceMutation() -> Observable<Mutation> {
    return self.faqService.event.flatMap { [weak self] event -> Observable<Mutation> in
      guard let self = self else { return .empty() }
      switch event {
      case let .viewed(faq):
        guard let index = self.index(faqId: faq.id, from: self.currentState) else { return .empty() }
        return Observable.concat([
          Observable.just(.updateFAQ(faq, index)),
          Observable.just(.updateSections)
        ])
      }
    }
  }
  
  private func index(faqId: String, from state: State) -> Int? {
    let index = state.faqs.firstIndex { faq in faq.id == faqId }
    if let index = index {
      return index
    } else {
      return nil
    }
  }
  
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case let .select(indexPath):
      let faq = self.currentState.faqs[indexPath.item]
      self.router?.routeToFAQ(faq)
      return Observable.empty()
      
    case .viewDidDisappear:
      self.listener?.faqsViewDidDisappear()
      return Observable.empty()
      
    case .refresh:
      guard !self.currentState.isRefreshing else { return .empty() }
      return Observable.concat([
        Observable.just(.setRefreshing(true)),
        self.refreshMutation(),
        Observable.just(.updateSections),
        Observable.just(.setRefreshing(false))
      ])
    }
  }
  
  private func refreshMutation() -> Observable<Mutation> {
    return self.faqService.faqs()
      .map(Mutation.setFAQs)
      .asObservable()
      .catchError { _ in .empty() }
  }
  
  func reduce(state: State, mutation: Mutation) -> State {
    var newState = state
    switch mutation {
    case let .setRefreshing(isRefreshing):
      newState.isRefreshing = isRefreshing
      
    case let .setFAQs(faqs):
      newState.faqs = faqs
      
    case .updateSections:
      newState.sections.removeAll()
      defer { newState.sections.removeDuplicates() }
      if !newState.faqs.isEmpty {
        let items = newState.faqs.map(FAQsViewSection.Item.faq)
        let section = FAQsViewSection(identity: .faqs, items: items)
        newState.sections.append(section)
      }
    case let .updateFAQ(faq, index):
      newState.faqs[index] = faq
    }
    return newState
  }
}
import RIBs
import RxSwift
import AsyncDisplayKit
import RxCocoa
import RxCocoa_Texture
import RxDataSources_Texture

final class FAQsViewController: BaseViewController, FAQsPresentable {
  
  // MARK: UI
  
  private let refreshControl = RefreshControl()
  private let collectionNode = ASCollectionNode(
    collectionViewLayout: UICollectionViewFlowLayout()
  ).then {
    $0.backgroundColor = .clear
    $0.alwaysBounceVertical = true
    $0.showsVerticalScrollIndicator = false
  }
  
  weak var reactor: FAQsReactor?
  
  private lazy var dataSource = self.createDataSource()
  
  private func createDataSource() -> RxASCollectionSectionedAnimatedDataSource<FAQsViewSection> {
    return .init(
      animationConfiguration: AnimationConfiguration(animated: false),
      configureCellBlock: { dataSource, collectionNode, indexPath, sectionItem in
        switch sectionItem {
        case let .faq(faq):
          return { FAQCellNode(faq: faq) }
        }
      }
    )
  }
  
  // MARK: Lifecycle
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.navigationItem.titleView = NavigationBarTitleLabel(title: "FAQ")
    self.collectionNode.view.addSubview(self.refreshControl)
  }
  
  func bind(reactor: FAQsReactor) {
    self.bindRefresh(reactor: reactor)
    self.bindDataSource(reactor: reactor)
    self.bindDelegate(reactor: reactor)
    self.bindDisappear(reactor: reactor)
    self.bindSelectCell(reactor: reactor)
  }
  
  private func bindRefresh(reactor: FAQsReactor) {
    self.rx.viewDidLoad
      .map { .refresh }
      .bind(to: reactor.action)
      .disposed(by: self.disposeBag)
    
    self.refreshControl.rx.controlEvent(.valueChanged)
      .map { .refresh }
      .bind(to: reactor.action)
      .disposed(by: self.disposeBag)
    
    reactor.state.map { $0.isRefreshing }
      .distinctUntilChanged()
      .bind(to: self.refreshControl.rx.isRefreshing)
      .disposed(by: self.disposeBag)
  }
  
  func bindDataSource(reactor: FAQsReactor) {
    reactor.state.map { $0.sections }
      .distinctUntilChanged()
      .bind(to: self.collectionNode.rx.items(dataSource: self.dataSource))
      .disposed(by: self.disposeBag)
  }
  
  func bindDelegate(reactor: FAQsReactor) {
    self.collectionNode.rx.setDelegate(self)
      .disposed(by: self.disposeBag)
  }
  
  func bindSelectCell(reactor: FAQsReactor) {
    self.collectionNode.rx.itemSelected
      .map { .select($0) }
      .bind(to: reactor.action)
      .disposed(by: self.disposeBag)
  }
  
  private func bindDisappear(reactor: FAQsReactor) {
    self.rx.viewDidDisappear
      .map { [weak self] _ in self?.isMovingFromParent ?? false } // in view controller that is being popped
      .filter { $0 }
      .map { _ in .viewDidDisappear }
      .bind(to: reactor.action)
      .disposed(by: self.disposeBag)
  }
  
  // MARK: Layout
  
  override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
    return ASWrapperLayoutSpec(layoutElement: self.collectionNode)
  }
}

// MARK: FAQsViewControllable

extension FAQsViewController: FAQsViewControllable {
  func push(viewController: ViewControllable?) {
    if let viewController = viewController?.uiviewController {
      self.navigationController?.pushViewController(viewController, animated: true)
    }
  }
}

extension FAQsViewController
  : ASCollectionDelegateFlowLayout
  , UICollectionViewDelegateFlowLayout {
  
  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
    return .margins(top: 25)
  }
  
  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
    return 15
  }
  
  func collectionNode(_ collectionNode: ASCollectionNode, constrainedSizeForItemAt indexPath: IndexPath) -> ASSizeRange {
    let width = UIScreen.main.bounds.width
    return ASSizeRange(
      min: CGSize(width: width, height: 0),
      max: CGSize(width: width, height: CGFloat.infinity)
    )
  }
}
//#if canImport(SwiftUI) && DEBUG
//import SwiftUI
//
//struct FAQsViewController_Preview
//  : UIViewControllerRepresentable
//  , PreviewProvider {
//  
//  func makeUIViewController(context: Context) -> FAQsViewController {
//    FAQsViewController()
//  }
//  
//  func updateUIViewController(
//    _ uiViewController: FAQsViewController,
//    context: Context
//  ) {}
//  
//  static var previews: some SwiftUI.View {
//    let supportedLocales: [Locale] = [
//      "en_US",
//      "ko_KR"
//    ].map(Locale.init(identifier:))
//    return ForEach(supportedLocales, id: \.identifier) { locale in
//      FAQsViewController_Preview()
//      .environment(\.locale, locale)
//    }
//  }
//}
//#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment