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
의 상호작용을 단위 테스트로 검증하기 쉬워졌다.