Search

MVVM 패턴 - 양방향 바인딩 with Combine

 들어가며

MVVM 패턴 - 양방향 바인딩 with RxSwift 에서 RxSwift를 사용해서 바인딩을 구현했습니다. 이번엔 Combine를 사용해서 바인딩을 구현해보도록 하겠습니다.
이전에 MVP 패턴 공부할 때, 사용하던 레포지토리를 이용해서 RxSwift와 Combine을 사용한 바인딩을 진행했습니다. 코드를 보고 싶으시다면 해당 링크를 참고해주시면 됩니다.
저희가 만들어볼 앱은 서버로부터 이미지를 가져와서 화면에 그려주는 앱입니다.
가져올 이미지의 숫자를 화살표 버튼을 통해서 변경할 수 있고, 이미지 불러오기 버튼을 누르면 이미지를 가져오게 됩니다. 완성하고 나면 이렇게 작동할 겁니다.
UI 관련 코드, 네트워크 통신 코드에 대한 설명은 하지 않겠습니다. 레포를 참고해주세요.

 ViewModel 구성하기

MVVM에서 ViewModel은 ViewController없이도 돌아갈 수 있어야 합니다.
즉, ViewModel 내부에 있는 로직은 View와는 상관없이 실행할 수 있는 로직이어야 합니다.
그렇기 때문에, 우리는 ViewModel부터 구성을 해줍시다.
우리가 만들고자 하는 ViewModel은 3가지 책임을 질 줄 알아야 합니다.
1.
이미지 갯수 변경하기(handleCount)
2.
이미지 가져오기(fetchImage)
3.
이미지의 현재 페이지 변경하기(handleCurrentPage)
그럼 책임을 코드로 작성해봅시다.
1.
이미지 갯수 변경하기
우리는 이미지 count를 가지고 있다가 count를 올려라, 혹은 내려라 라는 명령이 들어오면 count를 올리든, 내리든 할 겁니다. 그러면 우리는 count를 올리든, 내리든을 매개변수로 받아야 겠네요. 저는 Direction이라는 enum 타입을 만들었습니다.
enum Direction { case left, right }
Swift
복사
그리고 Direction를 매개 변수로 받을 수 있는 메서드를 만들었습니다.
func handleCount(with direction: Direction) { // TODO: - Count 값 계산 // TODO: - Count 값 Stream으로 보내기 }
Swift
복사
그리고, 해당 direction를 받아서 count를 처리해줄 겁니다. count를 계산해주는 메서드를 하나 만들었습니다. 해당 메소드는 direction를 인수로 넣어주면 Int 타입을 반환해줍니다. 계산된 count 겠죠.
private func calculateCount(with direction: Direction) -> Int { var currentValue = // TODO: - currentValue 설정 switch direction { case .left: guard currentValue > 1 else { return 1 } currentValue -= 1 case .right: currentValue += 1 } return currentValue }
Swift
복사
calculateCount 메서드는 handleCount에서 TODO: - Count 값 계산에 해당되는 일을 해주는 메서드입니다. 따라서, 저 부분에 Count 계산을 하고 계산한 값을 가지고 오는 코드를 작성해줍니다.
func handleCount(with direction: Direction) { let count = self.calculateCount(with: direction) // TODO: - Count 값 Stream으로 보내기 }
Swift
복사
이제 Count 값을 Stream으로 보내는 부분과 Count를 설정해주는 부분이 필요하겠네요.
저는 count라는 변수를 하나 만들었습니다. 그리고 해당 속성 앞에 @Published를 붙여서 count를 Publisher처럼 사용할 수 있도록 했습니다.
@Published var count: Int = 1
Swift
복사
count는 변수이기 때문에 우리는 calculateCount에서 count 값을 가져와서 사용할 수 있습니다.
private func calculateCount(with direction: Direction) -> Int { var currentValue = self.count switch direction { case .left: guard currentValue > 1 else { return 1 } currentValue -= 1 case .right: currentValue += 1 } return currentValue }
Swift
복사
마지막으로 count Publisher로 Count 값을 보내줍시다.
func handleCount(with direction: Direction) { self.count = self.calculateCount(with: direction) }
Swift
복사
보내진 값은 어딘가에서 사용하겠죠. ViewModel은 자기 할 일을 했으니 더이상 신경쓰지 않아도 됩니다.
2.
이미지 가져오기
이미지를 가져오기 위해서는 service가 필요합니다. 저는 UnsplashService라는 구조체를 하나 만들어 뒀습니다. 해당 구조체에는 이미지의 URL를 가져올 수 있는 메서드가 있습니다. 해당 메서드는 String 타입의 배열을 반환합니다.
func imageURLs(count: Int) async -> [String] { do { let images = try await self.unsplashAPI.fetchRandomImages(count: count) let imageURLs = images?.compactMap { $0.urls?.regular } return imageURLs ?? [] } catch { return [] } }
Swift
복사
해당 메서드에서는 필요한 것이 있습니다. 바로, 가져올 페이지의 수 입니다. 우리가 1번에서 count 변수를 만들었습니다. count에서 image를 가져올 때 필요한 값을 가지고 옵시다.
func fetchImage() { Task { let count = self.count let urls = await self.service.imageURLs(count: count) // TODO: - image urls Stream에 보내기 } }
Swift
복사
마지막으로 service를 통해서 가져온 이미지를 올릴 Stream이 필요합니다.
이번에도 @Published를 사용해서 String 타입의 배열인 imageUrl를 만들어 줬어요.
@Published var imageUrl: [String] = []
Swift
복사
받은 URL를 Stream에 올려줍시다.
func fetchImage() { Task { let count = self.countRelay.value let urls = await self.service.imageURLs(count: count) self.imageUrl = urls } }
Swift
복사
3.
이미지의 현재 페이지 변경하기
이번에는 width와 offset를 받아서 현재 페이지를 계산해주는 메서드를 만들겁니다.
해당 메서드는 width, offset를 매개변수로 받고 이를 계산해서 그 값을 Stream으로 보내주면 됩니다.
func handleCurrentPage(with width: Double, _ offset: Double) { let currentPage = Int(offset / width) // TODO: - current page stream에 보내기 }
Swift
복사
Stream에 현재 페이지를 보내 보겠습니다. 이 부분은 PassthroughSubject를 사용할게요.
let currentPageSubject: PassthroughSubject<Int, Never> = PassthroughSubject()
Swift
복사
그리고 메서드에 적용해주겠습니다.
func handleCurrentPage(with width: Double, _ offset: Double) { let currentPage = Int(offset / width) self.currentPageSubject.send(currentPage) }
Swift
복사
ViewModel이 완성되었습니다. ٩(ˊᗜˋ*)و

 ViewController 구성하기

이번엔 ViewController 내부를 구성해볼게요. ViewController는 ViewModel를 소유하고 있습니다. ViewModel에 트리거를 보내고 ViewModel의 데이터가 변경되는지 지켜보고 있다가 데이터가 변경되면 화면을 바꾸는 것도 ViewController가 하는 일입니다. 따라서, ViewController는 ViewModel를 가지고 있어야 합니다.
private let viewModel: ViewModel = ViewModel(service: UnsplashService())
Swift
복사
우선, ViewModel에게 트리거를 보내는 부분부터 만들어 주겠습니다.
ViewController에서 받는 Input은 총 4가지 입니다.
1.
왼쪽 화살표 버튼 누르기
2.
오른쪽 화살표 버튼 누르기
3.
이미지 불러오기 버튼 누르기
4.
콜렉션 뷰 스크롤하기
4가지 Input를 위한 코드를 작성해보겠습니다.
1.
왼쪽 화살표 버튼 누르기
Combine은 Rx와 다르게 RxCocoa같은 확장 라이브러리를 제공하지 않습니다. 따라서, Tap이 일어나는걸 감지하고, Publisher로 만들어줄 tapPublisher를 만들어야 합니다.
UIControl를 확장해서 controlPublisher를 만들고 UIControl에서 발생하는 이벤트를 Publisher로 만들도록 했습니다. 이는 UIControl를 상속하고 있는 모든 UI 컴포넌트에서 사용 가능합니다.
*해당 포스트에 있는 코드를 참고했습니다.
tapPublisher를 사용해서 버튼 터치를 통해 이벤트 스트림을 만들었습니다. 터치 이벤트가 발생하면 스트림으로 이벤트가 내려갈겁니다.
self.leftButton.tapPublisher
Swift
복사
해당 이벤트가 발생하면 우리는 ViewModel에서 Count를 다루는 부분으로 이벤트가 일어났다는걸 알려줘야 합니다. 아까 우리가 만들었던 handleCount로 보내면 되겠네요. 인수로 어느 방향인지 보낼 수 있기 때문에, .left 라고 작성하면 될 것 같습니다.
self.leftButton.tapPublisher .sink(receiveValue: { [weak self] _ in self?.viewModel.handleCount(with: .left) }) .store(in: &cancelBag)
Swift
복사
2.
오른쪽 화살표 버튼 누르기
오른쪽 화살표는 왼쪽 화살표와 동일하게 작성하되, handleCount에 들어가는 인수만 변경해주면 되겠죠?
self.rightButton.tapPublisher .sink(receiveValue: { [weak self] _ in self?.viewModel.handleCount(with: .right) }) .store(in: &cancelBag)
Swift
복사
3.
이미지 불러오기 버튼 누르기
이미지 불러오기 버튼을 누르면 ViewModel에 만들어 두었던 이미지를 불러오는 메서드가 불려야 합니다. 따라서, 해당 메서드를 호출해줍시다.
self.submitButton.tapPublisher .sink(receiveValue: { [weak self] _ in self?.viewModel.fetchImage() }) .store(in: &cancelBag)
Swift
복사
4.
콜렉션 뷰 스크롤하기
우리는 사용자가 콜렉션 뷰를 스크롤하면 해당 페이지가 몇번째 페이지인지 계산해서 pageControl로 보여주려고 합니다. 따라서, 스크롤을 감지할 필요가 있습니다.
Combine은 RxCocoa가 제공해주는 CollectionViewDelegate에 있는 메서드들을 제공받을 수 없습니다. 따라서, tapPublisher 처럼 scroll 이벤트를 받는 scrollPublisher를 만들었습니다.
*해당 포스트에 있는 코드를 참고했습니다.
스크롤 이벤트가 발생하면 해당 이벤트를 스트림으로 내려줍니다. 해당 이벤트는 ViewModel에 있는 handleCurrentPage를 호출해야 합니다. 해당 메서드는 두가지 인수를 필요로 합니다. width와 offset 입니다. 현재 콜렉션 뷰에서 두 값을 빼서 인수로 넣어줍니다.
self.photoCollectionView.scrollPublisher .sink(receiveValue: { [weak self] _ in if let width = self?.photoCollectionView.frame.width, let offset = self?.photoCollectionView.contentOffset.x { self?.viewModel.handleCurrentPage(with: width, offset) } }) .store(in: &cancelBag)
Swift
복사
Input를 다 만들었으니 Output를 받는 부분을 만들어 보겠습니다.
ViewModel로부터 받을 수 있는 값은 총 3가지 입니다.
1.
count(Int)
2.
currentPageSubject(Int, Never)
3.
imageUrl([String])
각각의 Stream에서 나오는 값을 UI Component에서 받아서 사용할 수 있도록 연결시켜보겠습니다.
1.
count
count에서 나오는 값을 라벨에다가 보여주어야 합니다. 라벨에 올리기 위해서는 Int 타입이 아닌 String 타입이어야 합니다. 즉, 중간에 값을 가공해줄 필요가 있겠네요.
잘 가공한 값은 라벨의 text로 들어가면 됩니다.
self.viewModel.$count .map { "\($0)" } .assign(to: \.text, on: self.photoCountLabel) .store(in: &cancelBag)
Swift
복사
2.
currentPageSubject
currentPageSubject에서 나오는 Count값은 pageControl의 currentPage의 값으로 들어가야 합니다. currentPage는 Int 타입을 받기 때문에 별다른 가공을 거치지 않아도 됩니다.
self.viewModel.currentPageSubject .assign(to: \.currentPage, on: self.pageControl) .store(in: &cancelBag)
Swift
복사
3.
imageUrl
imageUrl에서 들어온 String 타입 배열은 한 가지 컴포넌트에서만 사용하지 않습니다. 해당 값을 사용해서 CollectionView에 이미지를 올리고, PageControl의 전체 페이지 수를 변경해주어야 합니다. 따라서, imageUrl를 share할 수 있도록 해줍시다.
let imageUrlPublisher = self.viewModel.$imageUrl .share()
Swift
복사
그러면 가져온 imageUrl 배열을 사용해봅시다.
먼저, 간단하게 pageControl의 numberOfPages 값을 설정해봅시다. numberOfPages는 Int값을 받습니다. 따라서, 배열의 Count 값이 내려가도록 가공해주어야 합니다.
imageUrlPublisher .map { $0.count } .receive(on: RunLoop.main) .assign(to: \.numberOfPages, on: self.pageControl) .store(in: &cancelBag)
Swift
복사
CollectionView를 설정해보겠습니다. Combine은 RxCocoa같은 확장 라이브러리를 제공하지 않기 때문에 CollectionView를 사용하기 위해서 DataSource를 만들었습니다.
final class PhotoCollectionViewDataSource<Cell: UICollectionViewCell, T>: NSObject, UICollectionViewDataSource { private let identifier: String private let items: [T] private let configureCell: ((Cell, T) -> Void)? init(identifier: String, items: [T], configureCell: ((Cell, T) -> Void)? = nil) { self.identifier = identifier self.items = items self.configureCell = configureCell } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.items.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.identifier, for: indexPath) as? Cell else { return UICollectionViewCell() } let item = self.items[indexPath.row] self.configureCell?(cell, item) return cell } }
Swift
복사
그리고 item 배열을 받아서 DataSource를 설정해주는 메서드도 만들었습니다.
private func photoCollectionViewDataSource<T>(items: [T]) -> PhotoCollectionViewDataSource<PhotoCollectionViewCell, T> { return PhotoCollectionViewDataSource(identifier: PhotoCollectionViewCell.identifier, items: items) { cell, item in if let item = item as? String { cell.configureCell(imageURL: item) } } }
Swift
복사
photoCollectionViewDataSource를 사용해서 CollectionView의 dataSource를 설정해줍시다.
imageUrlPublisher .map { self.photoCollectionViewDataSource(items: $0) } .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] dataSource in guard let self = self else { return } self.dataSource = dataSource self.photoCollectionView.dataSource = self.dataSource self.photoCollectionView.reloadData() }) .store(in: &cancelBag)
Swift
복사
View와 ViewModel이 모두 완성되었습니다.

 Input / Output 으로 구성하기

이번엔 ViewModel를 보기 쉽게 정리해보려고 합니다.
Input, Output이라는 구조체를 ViewModel 내부에 넣을 겁니다. ViewModel은 Input를 받아서 Output으로 반환하는 transform이라는 메서드를 가지고서 ViewController와 소통할 겁니다.
일단, Input, Output 구조체를 만들어 보겠습니다. Input, Output은 말그대로 ViewController로부터 오는 Input, 그리고 ViewController로 보내주어야 하는 Output를 말합니다.
Output으로 들어가는 값은 currentPageSubject, count, imageUrl이겠네요. count와 imageUrl도 Subject로 바꿔줄게요.
struct Output { let countSubject: CurrentValueSubject<Int, Never> = CurrentValueSubject(1) let imageUrlSubject: CurrentValueSubject<[String], Never> = CurrentValueSubject([]) let currentPageSubject: PassthroughSubject<Int, Never> = PassthroughSubject() }
Swift
복사
그럼 Input은 어떻게 구성해줘야 할까요? 위에서는 ViewController가 ViewModel의 메서드를 직접 호출해서 사용했습니다. 이번에는 ViewModel의 메서드를 직접 호출하지 않고 “버튼이 눌렸다”, “스크롤을 했다” 라는 UI 이벤트를 Stream를 통해서 전달해주려고 합니다.
호출 했던 메서드는 총 3가지 였습니다. handleCount, fetchImage, handleCurrentPage 입니다. 메서드 호출을 대신할 Stream를 만들어 보겠습니다.
struct Input { let directionArrowDidTap: PassthroughSubject<Direction, Never> let submitDidTap: AnyPublisher<Void, Never> let didScroll: PassthroughSubject<(width: Double, offset: Double), Never> }
Swift
복사
directionArrowDidTap은 handleCount를 대신하는 Stream입니다. 따라서, handleCount 대신 Direction를 받아와야 합니다. submitDidTap은 fetchImage를 대신합니다. fetchImage는 매개변수가 없기 때문에 스트림을 통해서 받아올 데이터가 없습니다. didScroll은 handleCurrentPage를 대신하는 Stream 입니다. 해당 Stream으로는 width와 offset 데이터를 가져와야 합니다.
이번엔 transform 메서드 코드를 작성해보겠습니다.
transform은 매개변수로 input를 받습니다. input를 사용해서 코드를 구성해보겠습니다.
1.
directionArrowDidTap
directionArrowDidTap으로는 Direction 데이터가 들어옵니다. 해당 데이터를 받아서 이전에 Count 계산을 해주던 메서드의 인수로 넣어줄 겁니다. 또한, 계산하는 함수 안에서 현재 Value를 직접 사용하지 말고 인수로 받아서 사용하도록 수정했습니다.
input.directionArrowDidTap .sink(receiveValue: { [weak self] direction in guard let self = self else { return } let currentValue = self.output.countSubject.value let count = self.count(with: direction, currentValue: currentValue) // TODO: - output으로 넘겨주기 }) .store(in: &cancelBag)
Swift
복사
계산이 된 count는 output를 통해서 넘겨주려고 합니다. ViewModel에 Output 인스턴스를 하나 만들고 해당 인스턴스가 가지는 countSubject로 count값을 넘겨주었습니다.
input.directionArrowDidTap .sink(receiveValue: { [weak self] direction in guard let self = self else { return } let currentValue = self.output.countSubject.value let count = self.count(with: direction, currentValue: currentValue) self.output.countSubject.send(count) }) .store(in: &cancelBag)
Swift
복사
2.
submitDidTap
submitDidTap 이벤트가 발생하면 이미지를 서버로부터 가져와야 합니다. 서버로부터 이미지를 가져오기 위해서 countSubject에 있는 value를 가져와서 fetchImage 메서드로 넣었습니다.
input.submitDidTap .sink(receiveValue: { [weak self] in guard let self = self else { return } let page = self.output.countSubject.value self.fetchImage(currentPage: page) }) .store(in: &cancelBag)
Swift
복사
이전에 만들었던 fetchImage 메서드를 수정해서 service를 통해서 imageUrls를 가져오고 이를 output에 있는 imageUrlSubject로 보내는 코드를 작성해주었습니다.
private func fetchImage(currentPage: Int) { Task { let urls = await self.service.imageURLs(count: currentPage) self.output.imageUrlSubject.send(urls) } }
Swift
복사
3.
didScroll
didScroll에는 width와 offset이 들어옵니다. 해당 데이터를 currentPage 라는 메서드에 넣어서 계산하고 해당 메서드가 반환하는 Int 값을 받아서 output에 있는 currentPageSubject로 보내줄 겁니다.
input.didScroll .map { self.currentPage(with: $0.width, $0.offset) } .sink(receiveValue: { [weak self] page in self?.output.currentPageSubject.send(page) }) .store(in: &cancelBag)
Swift
복사
currentPage 메서드는 이전에 handleCurrentPage에서 직접 계산하던 부분을 메서드로 따로 분리한 겁니다.
private func currentPage(with width: Double, _ offset: Double) -> Int { return Int(offset / width) }
Swift
복사
ViewModel 내부에 있는 Input, Output은 다 만들었네요. 이번엔 ViewController 코드를 수정해봅시다.
일단, 이전에 만들었던 이벤트들을 조금 수정해주어야 합니다. 이젠 메서드가 아니고 Stream을 Input으로 넣어줘야 합니다.
이전에 만든 Input은 총 4가지 였습니다.
1.
왼쪽 화살표 버튼 누르기
2.
오른쪽 화살표 버튼 누르기
3.
이미지 불러오기 버튼 누르기
4.
콜렉션 뷰 스크롤하기
이전에 작성해뒀던 코드를 수정해봅시다.
1.
왼쪽, 오른쪽 화살표 버튼 누르기
이전에는 왼쪽, 오른쪽 화살표 버튼을 눌렀을 때, ViewModel에 있는 메서드를 호출했습니다. 이번엔 메서드말고 Stream를 통해서 이벤트를 전달해줍시다.
일단, 왼쪽, 오른쪽 화살표 버튼을 눌렀을 때 발생하는 이벤트를 위한 통로를 만들어 줍시다. PassthroughSubject를 사용해줄게요. 그리고 ViewModel에 있는 Direction를 이벤트로 보내줄 겁니다.
private let directionArrowDidTapSubject: PassthroughSubject<ViewModel.Direction, Never> = PassthroughSubject()
Swift
복사
왼쪽 버튼을 눌렀을 때, 해당 Stream으로 Direction.left 값이 전달되어야 합니다. 따라서, 이벤트 발생 시에 map에서 이벤트를 left로 가공합니다. 그리고 가공한 값을 Stream으로 전달합니다.
self.leftButton.tapPublisher .map { ViewModel.Direction.left } .sink(receiveValue: { [weak self] in self?.directionArrowDidTapSubject.send($0) }) .store(in: &cancelBag)
Swift
복사
rightButton도 동일하게 적용하겠습니다.
self.rightButton.tapPublisher .map { ViewModel.Direction.right } .sink(receiveValue: { [weak self] in self?.directionArrowDidTapSubject.send($0) }) .store(in: &cancelBag)
Swift
복사
2.
이미지 불러오기 버튼 누르기
이미지 불러오기 버튼을 눌렀을 때는 아무것도 보낼 것이 없습니다. 그저 버튼이 눌림 이벤트만 보내주면 됩니다. 따라서, ViewModel의 Input에 버튼 탭 이벤트 Publisher를 넣어주도록 하겠습니다.
let input = ViewModel.Input( directionArrowDidTap: self.directionArrowDidTapSubject, submitDidTap: self.submitButton.tapPublisher, didScroll: // TODO: - didScroll Observable )
Swift
복사
ViewModel에 Input에 아까 만들었던 directionArrowDidTapSubject도 넣어줬습니다.
3.
콜렉션 뷰 스크롤하기
콜렉션 뷰 스크롤을 했을 때 이전에는 메서드에 width, offset를 인수로 넣어 줬습니다. 이번에는 Stream으로 보내줘야 겠네요. 해당 Tuple를 담을 Subject를 만들어줬습니다.
private let didScrollSubject: PassthroughSubject<(width: Double, offset: Double), Never> = PassthroughSubject()
Swift
복사
그리고 콜렉션 뷰에서 didScroll 이벤트가 발생했을 때, 해당 이벤트에다가 width, offset를 보냈습니다.
self.photoCollectionView.scrollPublisher .sink(receiveValue: { [weak self] _ in if let width = self?.photoCollectionView.frame.width, let offset = self?.photoCollectionView.contentOffset.x { self?.didScrollSubject.send((width, offset)) } }) .store(in: &cancelBag)
Swift
복사
Input에 해당 Subject를 적용해줘야겠네요.
let input = ViewModel.Input( directionArrowDidTap: self.directionArrowDidTapSubject, submitDidTap: self.submitButton.tapPublisher, didScroll: self.didScrollSubject )
Swift
복사
이제 ViewModel과 ViewController 사이에 Input를 보내는 부분은 연결이 된 것 같네요. ViewController에서 이벤트가 발생하면 ViewModel에서 메서드 내부 코드를 실행하고 Output에 있는 스트림들로 이벤트를 방출했을 겁니다. 하지만, 아직 ViewController에서 방출된 이벤트를 받지 않았기 때문에 화면에 방출된 이벤트가 반영되지 않습니다.
이벤트를 받기 위해서는 아까 ViewController에서 만들어둔 input를 사용해야 합니다. 우리는 ViewModel에 있는 transform 메서드를 사용해서 Input를 Output으로 변경했습니다. 이 메서드를 사용해서 output를 받을 수 있겠네요.
let output = self.viewModel.transform(input: input)
Swift
복사
output를 받았기 때문에, output 안에 있는 스트림도 같이 받게 됩니다.
이전에 있던 Output 코드들을 조금 수정해봅시다. Input과는 다르게 viewModel로 적혀있던 부분을 output으로만 변경하면 됩니다. 이미 UI를 업데이트하는 코드는 다 완성되어 있으니깐요.
output.countSubject .map { "\($0)" } .assign(to: \.text, on: self.photoCountLabel) .store(in: &cancelBag) output.currentPageSubject .assign(to: \.currentPage, on: self.pageControl) .store(in: &cancelBag) let imageUrlPublisher = output.imageUrlSubject .share() imageUrlPublisher .map { self.photoCollectionViewDataSource(items: $0) } .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] dataSource in guard let self = self else { return } self.dataSource = dataSource self.photoCollectionView.dataSource = self.dataSource self.photoCollectionView.reloadData() }) .store(in: &cancelBag) imageUrlPublisher .map { $0.count } .receive(on: RunLoop.main) .assign(to: \.numberOfPages, on: self.pageControl) .store(in: &cancelBag)
Swift
복사
ViewModel과 ViewController가 Input, Output를 사용해서 조금 더 정리된 방식으로 이벤트를 주고 받았습니다. Input, Output를 꼭 사용하지 않아도 괜찮습니다. Input, Output은 ViewModel과 바인딩하는 방식을 정리해주는 용도니깐요.

 마무리

Combine를 사용해서 바인딩을 구현해보았습니다. Combine에는 RxSwift에서 제공해주는 RxCocoa가 없어서 아쉬웠습니다. CombineCocoa가 있긴 하지만 라이브러리를 임포트하지 않고 사용해보고 싶었기에 이번 예제에서는 사용하지 않았습니다. CombineCocoa를 사용해서 코드를 구성하는 것도 나중에 구현해보겠습니다.
위에 작성한 코드는 해당 레포에서 확인하실 수 있습니다. Input, Output를 사용하기 이전 코드는 mvvm/combine 브랜치에, Input, Output를 사용하는 코드는 main 브랜치에 존재합니다.
ImageExample_MVVM_Combine 파일을 봐주시면 됩니다.