[TIL] bind() 코드 리팩토링

Eden·2025년 2월 18일
0

TIL

목록 보기
126/132
post-thumbnail
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 로직이 분산되어 있었는데, 관리가 어려워 지고 다른 곳에 영향을 미칠 가능성이 너무 컸다ㅠ

코드 리뷰를 받고 RxSwiftRxCocoa를 활용하여 온보딩 페이지의 데이터를 바인딩하고, UI 이벤트를 처리하는 bind() 메서드를 only ViewController에서만 구현하도록 리팩토링을 했다.


🔹 코드 구조

1️⃣ Input 생성

let input = OnboardingViewModel.Input(
    skipTapped: collectionView.skipButton.rx.tap.asObservable(),
    pageChanged: observePageChanged()
)
  • skipTapped: 스킵 버튼이 눌렸을 때의 이벤트 (Observable<Void>)
  • pageChanged: 현재 페이지가 변경될 때의 이벤트 (Observable<Int>)

👉 이 두 이벤트를 OnboardingViewModelInput에 전달하여 transform()을 호출한다.


2️⃣ ViewModel의 Output 바인딩

let output = viewModel.transform(input: input)
  • inputViewModel에 전달하고, transform()을 통해 output을 가져온다.
  • output은 ViewModel에서 가공된 데이터를 포함한다.

3️⃣ UIPageControl의 페이지 개수 바인딩

viewModel.pages
    .map { $0.count }
    .bind(to: collectionView.pageControl.rx.numberOfPages)
    .disposed(by: disposeBag)
  • viewModel.pages는 온보딩 페이지 리스트 (Observable<[Page]>)다.
  • map { $0.count }을 통해 페이지 개수를 가져온 후, UIPageControl의 numberOfPages에 바인딩한다.

4️⃣ 컬렉션 뷰에 데이터 바인딩

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)).

5️⃣ 스크롤 멈출 때 현재 페이지 감지

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()를 사용하여 같은 페이지로 이동할 경우 중복 이벤트를 방지한다.

6️⃣ 현재 페이지 변경 시 UI 업데이트

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와 함께 최신 값을 가져온다.
  • 현재 페이지가 존재하는 경우:
    1. 해당 페이지로 scrollToItem(at:) 이동
    2. updatePageControl(page)로 UIPageControl 업데이트
    3. 마지막 페이지인지 확인 후 updateSkipButtonText() 업데이트

7️⃣ 현재 페이지를 ViewModel에 전달

pageChangedObservable
    .bind(to: viewModel.currentPage)
    .disposed(by: disposeBag)
  • 앞에서 만든 pageChangedObservableviewModel.currentPage에 바인딩하여 ViewModel이 현재 페이지를 추적할 수 있도록 한다.

8️⃣ 스킵 버튼 클릭 시 온보딩 종료

output.skipTrigger
    .drive(with: self, onNext: { owner, _ in
        owner.navigateToStarList()
    })
    .disposed(by: disposeBag)
  • output.skipTriggerskipTapped 이벤트에서 만들어진 Driver<Void>이다.
  • 이 값이 발생하면 navigateToStarList()를 호출하여 온보딩 화면을 종료한다.

✅ 개선된 점

  • RxSwift 관련 바인딩 로직이 ViewController에 집중되어, 온보딩 화면의 흐름을 한눈에 파악할 수 있다.

  • CollectionViewCell은 단순히 데이터를 표시하는 역할에 집중하며, 바인딩 로직은 ViewController에서 책임을 가진다.

  • ViewController 한 곳에 바인딩 로직이 모여 있어, 문제 발생 시 디버깅과 수정이 훨씬 수월하다!

  • ViewController의 바인딩 로직이 명확해짐에 따라, 특정 UI 이벤트와 ViewModel의 상호작용을 단위 테스트로 검증하기 쉬워졌다.

profile
Frontend 🌐 and iOS  🫶🏻

0개의 댓글

관련 채용 정보