private func bind() {
let input = OnboardingViewModel.Input(
skipTapped: collectionView.skipButton.rx.tap.asObservable(),
pageChanged: observePageChanged()
)
let output = viewModel.transform(input: input)
/// 페이지 개수를 UIPageControl에 바인딩
viewModel.pages
.map { $0.count }
.bind(to: collectionView.pageControl.rx.numberOfPages)
.disposed(by: disposeBag)
/// 컬렉션 뷰에 데이터 바인딩
viewModel.pages
.bind(to: collectionView.collectionView.rx.items(
cellIdentifier: OnboardingCell.identifier,
cellType: OnboardingCell.self
)) { _, page, cell in
cell.configure(with: page)
}
.disposed(by: disposeBag)
/// 스크롤을 멈추면 현재 페이지를 감지하여 업데이트
let pageChangedObservable = collectionView.collectionView.rx.didEndDecelerating
.map { [weak self] in
guard let self = self else { return 0 }
return Int(round(self.collectionView.collectionView.contentOffset.x / self.collectionView.collectionView.frame.width))
}
.distinctUntilChanged()
/// 현재 페이지가 변경되면 컬렉션 뷰를 해당 페이지로 스크롤
output.currentPage
.withLatestFrom(viewModel.pages.asDriver(onErrorDriveWith: .empty())) { ($0, $1) }
.drive(with: self, onNext: { owner, values in
let (page, pages) = values
guard pages.count > 0, page < pages.count else { return }
owner.collectionView.scrollToItem(at: page)
owner.collectionView.updatePageControl(page)
owner.collectionView.updateSkipButtonText(isLastPage: page == pages.count - 1)
})
.disposed(by: disposeBag)
/// 스크롤하면 현재 페이지를 감지하여 ViewModel에 전달
pageChangedObservable
.bind(to: viewModel.currentPage)
.disposed(by: disposeBag)
/// 스킵 버튼이 눌렸을 때 온보딩 종료
output.skipTrigger
.drive(with: self, onNext: { owner, _ in
owner.navigateToStarList()
})
.disposed(by: disposeBag)
}
리팩토링하다가 죽을 뻔 했다.
첫 설계가 잘못돼서 ViewController와 CollectionView, Cell에 각각 bind 로직이 분산되어 있었는데, 관리가 어려워 지고 다른 곳에 영향을 미칠 가능성이 너무 컸다ㅠ
코드 리뷰를 받고 RxSwift와 RxCocoa를 활용하여 온보딩 페이지의 데이터를 바인딩하고, UI 이벤트를 처리하는 bind() 메서드를 only ViewController에서만 구현하도록 리팩토링을 했다.
let input = OnboardingViewModel.Input(
skipTapped: collectionView.skipButton.rx.tap.asObservable(),
pageChanged: observePageChanged()
)
skipTapped: 스킵 버튼이 눌렸을 때의 이벤트 (Observable<Void>)pageChanged: 현재 페이지가 변경될 때의 이벤트 (Observable<Int>)👉 이 두 이벤트를 OnboardingViewModel의 Input에 전달하여 transform()을 호출한다.
let output = viewModel.transform(input: input)
input을 ViewModel에 전달하고, transform()을 통해 output을 가져온다.output은 ViewModel에서 가공된 데이터를 포함한다.viewModel.pages
.map { $0.count }
.bind(to: collectionView.pageControl.rx.numberOfPages)
.disposed(by: disposeBag)
viewModel.pages는 온보딩 페이지 리스트 (Observable<[Page]>)다.map { $0.count }을 통해 페이지 개수를 가져온 후, UIPageControl의 numberOfPages에 바인딩한다.viewModel.pages
.bind(to: collectionView.collectionView.rx.items(
cellIdentifier: OnboardingCell.identifier,
cellType: OnboardingCell.self
)) { _, page, cell in
cell.configure(with: page)
}
.disposed(by: disposeBag)
viewModel.pages를 컬렉션 뷰의 items에 바인딩한다.OnboardingCell을 사용하여 각 페이지를 설정 (cell.configure(with: page)).let pageChangedObservable = collectionView.collectionView.rx.didEndDecelerating
.map { [weak self] in
guard let self = self else { return 0 }
return Int(round(self.collectionView.collectionView.contentOffset.x / self.collectionView.collectionView.frame.width))
}
.distinctUntilChanged()
didEndDecelerating(스크롤 멈출 때) 이벤트를 감지한다.contentOffset.x을 사용하여 현재 페이지를 계산한다.distinctUntilChanged()를 사용하여 같은 페이지로 이동할 경우 중복 이벤트를 방지한다.output.currentPage
.withLatestFrom(viewModel.pages.asDriver(onErrorDriveWith: .empty())) { ($0, $1) }
.drive(with: self, onNext: { owner, values in
let (page, pages) = values
guard pages.count > 0, page < pages.count else { return }
owner.collectionView.scrollToItem(at: page)
owner.collectionView.updatePageControl(page)
owner.collectionView.updateSkipButtonText(isLastPage: page == pages.count - 1)
})
.disposed(by: disposeBag)
output.currentPage가 변경되면 viewModel.pages와 함께 최신 값을 가져온다.scrollToItem(at:) 이동updatePageControl(page)로 UIPageControl 업데이트updateSkipButtonText() 업데이트pageChangedObservable
.bind(to: viewModel.currentPage)
.disposed(by: disposeBag)
pageChangedObservable을 viewModel.currentPage에 바인딩하여 ViewModel이 현재 페이지를 추적할 수 있도록 한다.output.skipTrigger
.drive(with: self, onNext: { owner, _ in
owner.navigateToStarList()
})
.disposed(by: disposeBag)
output.skipTrigger는 skipTapped 이벤트에서 만들어진 Driver<Void>이다.navigateToStarList()를 호출하여 온보딩 화면을 종료한다.RxSwift 관련 바인딩 로직이 ViewController에 집중되어, 온보딩 화면의 흐름을 한눈에 파악할 수 있다.
CollectionView와 Cell은 단순히 데이터를 표시하는 역할에 집중하며, 바인딩 로직은 ViewController에서 책임을 가진다.
ViewController 한 곳에 바인딩 로직이 모여 있어, 문제 발생 시 디버깅과 수정이 훨씬 수월하다!
ViewController의 바인딩 로직이 명확해짐에 따라, 특정 UI 이벤트와 ViewModel의 상호작용을 단위 테스트로 검증하기 쉬워졌다.