Search

Clean Swift 적용해보기(1) - 메인 화면 만들어보기

하단 북마크 The Clean Swift Handbook에 나오는 Example 코드를 참고하여 Image Collection Example를 만들었습니다.
Clean Swift 개념은 The Clean Swift 에 작성해두었습니다.

 샘플 프로젝트 만들기 시작

이전에 MVP, MVVM 패턴으로 만들어 두었던 Image Example 프로젝트를 Clean Swift를 적용한 방식으로 수정해보려고 합니다.
기존 프로젝트에는 이미지를 가져와서 보여주는 메인 화면만 존재합니다. 이번에 Clean Swift를 적용하면서 상세 화면과 인덱스 변경 화면을 추가로 만들 예정입니다.
먼저, 메인 화면에서 할 수 있는 작업들을 소개해보겠습니다.
< > 버튼을 이용해서 가져오고 싶은 이미지의 갯수를 정할 수 있습니다.(최소 1장, 최대 30장)
이미지 가져오기 버튼을 누르면 Unsplash API를 통해서 이미지를 가져옵니다.
가져온 이미지는 CollectionView에서 볼 수 있습니다.
ColletionView를 스크롤하면 다음 이미지를 볼 수 있고, PageControl가 움직입니다.
각 이미지를 클릭하면 상세 페이지로 이동합니다.
*화면 UI 로직에 대한 설명은 따로 넣어두지 않았습니다. 화면 구현 방식이 궁금하시다면 레포지토리에서 코드를 참고해주세요.

 메인 화면 만들어보기

지금 만들 화면은 결론적으로 하단에 첨부한 스크린샷같은 UI를 가지게 될 겁니다.
그럼 위에서 설명한 기능을 하나씩 구현해봅시다.

⓵ < > 버튼 기능 구현

[ < > 버튼을 이용해서 가져오고 싶은 이미지의 갯수를 정할 수 있습니다.(최소 1장, 최대 30장) ]
위의 이미지에 있는 < > 버튼을 사용해서 버튼 사이에 있는 Label이 가지고 있는 숫자 Text를 바꾸어 볼 겁니다.
버튼을 누른다 라는 이벤트는 ViewController에서 나타날 겁니다.
하단 코드는 ViewController에 위치하게 되겠죠?
contentView.leftButtonTapGesture .sink { [weak self] in self?.tapLeftButton() }.store(in: &cancellables) contentView.rightButtonTapGesture .sink { [weak self] in self?.tapRightButton() }.store(in: &cancellables)
Swift
복사
해당 이벤트가 나타나면 ViewController는 Interactor로 이벤트가 발생했다는 걸 전달해줄겁니다.
이벤트가 발생했다는 걸 전달해주면서, Left를 눌렀는지, Right를 눌렀는지에 대한 정보도 Interactor로 함께 전달해주려고 합니다.
Clean Swift에서는 어떻게 다음 Step으로 데이터를 전달해줄 수 있을까요?
ViewController-Interactor-Presenter는 분리된 Model를 사용해서 다음 Step으로 값을 전달합니다.
ViewController는 Interactor로 Request 모델을 전달하고, Interactor는 Presenter로 Response를 Presenter는 ViewController로 ViewModel를 전달하게 됩니다.
따라서, 우리는 구현해둔 Request 모델을 사용해서 다음 Step(Interactor)으로 데이터를 전달해주게 될겁니다.
Request 모델을 사용하기 위해서 일단 해당 화면에서 사용할 모델들이 상주할 ImageCollection enum 타입을 하나 만들어 줍시다. 그리고 그 안에 Count 값 설정 플로우를 담당할 CountPhotoCollection enum 타입을 하나 만들어 줍니다. 그리고 그 안에 Request, Response, ViewModel를 struct 타입으로 하나씩 만들어 줍시다.
enum ImageCollection { enum CountPhotoCollection { struct Request { } struct Response { } struct ViewModel { } } }
Swift
복사
Model을 만들었다면 Request 내부에 Left, Right 정보를 전달해줄 수 있는 프로퍼티를 하나 만들어 줍니다.
저는 Direction이라는 이름으로 left, right case를 가진 enum 타입을 하나 만들었습니다.
enum ImageCollection { enum CountPhotoCollection { struct Request { let direction: Direction enum Direction { case left, right } } struct Response { } struct ViewModel { } } }
Swift
복사
이번엔 Interactor가 conform하고 있는 BusinessLogic protocol에 Request 타입을 전달해 줄 수 있는 메서드를 추가해 줍니다.
protocol ImageCollectionBusinessLogic { func changeCount(request: ImageCollection.CountPhotoCollection.Request) }
Swift
복사
그리고 LeftButton를 눌렀을 때 호출되는 메서드 내부에 changeCount를 호출하는 로직을 추가합니다.
private func tapLeftButton() { let request = ImageCollection.CountPhotoCollection.Request(direction: .left) interactor?.changeCount(request: request) }
Swift
복사
ViewController에서 해줘야 하는 설정은 끝났습니다. 이번엔 interactor 코드를 봅시다.
아마 changeCount를 BusinessLogic Protocol에 추가하면서 Interactor 클래스에 에러가 발생했을 겁니다. BusinessLogic Protocol를 conform하는 타입이지만 changeCount 메서드가 추가되지 않아서 에러가 발생했을 겁니다.
그럼 해당 메서드를 추가해줍시다.
public func changeCount(request: ImageCollection.CountPhotoCollection.Request) { }
Swift
복사
해당 메서드 내부에는 Request로 들어온 direction 값에 따라 count 값을 변경해주는 로직이 들어가야 할 겁니다.
public func changeCount(request: ImageCollection.CountPhotoCollection.Request) { switch request.direction { case .left: print("count 빼기") case .right: print("count 더하기") } }
Swift
복사
그럼, 더하거나 빼줘야 하는 count 값을 어디에 만들죠?
우리는 ImageCollectionDataStore 라는 protocol를 만들어서 해당 protocol이 count 값을 가지도록 할 겁니다. 그리고 해당 프로토콜을 Interactor가 conform하도록 합니다.
protocol ImageCollectionDataStore { var count: Int { get set } } final class ImageCollectionInteractor: ImageCollectionBusinessLogic, ImageCollectionDataStore { var count: Int = 0 ... }
Swift
복사
이제 해당 count 값을 changeCount에서 변경하도록 합시다.
public func changeCount(request: ImageCollection.CountPhotoCollection.Request) { switch request.direction { case .left: if count == 0 { return } count -= 1 case .right: count += 1 if count > maxCount { count = maxCount } } }
Swift
복사
위의 코드를 통해서 count 값은 0-30(max) 사이에서 움직이게 될 겁니다.
그럼 이제 변경된 count 값을 화면에 보여주어야 합니다. 변경된 값을 화면에 보여주기에 적합한 값으로 바꾸기 위해 Presenter로 넘겨줍시다.
Presenter는 PresentationLogic Protocol를 conform하고 있습니다.
따라서, PresentationLogic 내부에 Response 값을 전달해줄 수 있는 메서드를 하나 만듭니다.
protocol ImageCollectionPresentationLogic { func presentCount(response: ImageCollection.PhotoCollectionCount.Response) }
Swift
복사
Response 구조체 내부에는 count 값을 전달할 수 있게 Int 타입의 프로퍼티를 하나 만듭니다.
enum PhotoCollectionCount { struct Request { let direction: Direction enum Direction { case left, right } } struct Response { let count: Int } struct ViewModel { } }
Swift
복사
Interactor 내에 있는 changeCount 메서드에서 Presenter로 count 값을 보낼 수 있게 코드를 구현합니다.
public func changeCount(request: ImageCollection.CountPhotoCollection.Request) { switch request.direction { case .left: if count == 0 { return } count -= 1 case .right: count += 1 if count > maxCount { count = maxCount } } let response = ImageCollection.CountPhotoCollection.Response(count: count) presenter?.presentCount(response: response) }
Swift
복사
Presenter 내부에 presentCount 메서드를 추가하고 내부 코드를 구현해줍시다.
Presenter는 화면에 보여주기 좋은 모델 타입으로 값을 formatting 해주는 역할을 맡고 있습니다.
즉, 현재 가지고 있는 Int 타입의 count를 ViewController의 Label이 사용하기 좋은 String 타입으로 변경해줄 의무가 있습니다.
ViewController로 전달된 ViewModel 내부에 있는 count는 String 타입이 되겠군요.
enum PhotoCollectionCount { struct Request { let direction: Direction enum Direction { case left, right } } struct Response { let count: Int } struct ViewModel { let count: String } }
Swift
복사
Response를 통해서 가져온 count 값을 String 타입으로 변경해서 ViewController로 넘겨줍시다.
ViewController가 conform하고 있는 DisplayLogic 내부에 displayCount 메서드를 하나 만들어 String 타입으로 변경된 count 값을 전달해줄 수 있도록 할 겁니다.
protocol ImageCollectionDisplayLogic: AnyObject { func displayCount(viewModel: ImageCollection.PhotoCollectionCount.ViewModel) }
Swift
복사
Presenter에서 Response로 받아온 Int 타입의 count를 String 타입으로 변경해서 ViewController로 전달하도록 코드를 짜줍니다.
func presentCount(response: ImageCollection.CountPhotoCollection.Response) { let countString = String(response.count) let viewModel = ImageCollection.CountPhotoCollection.ViewModel(count: countString) viewController?.displayCount(viewModel: viewModel) }
Swift
복사
ViewController에서 전달 받은 값을 Label 텍스트에 넣어주면 되겠죠?
public func displayCount(viewModel: ImageCollection.PhotoCollectionCount.ViewModel) { contentView.setupCountLabel(count: viewModel.count) } func setupCountLabel(count: String) { photoCountLabel.text = count }
Swift
복사
위의 코드를 완성하고 빌드를 돌리면 < > 버튼을 사용해서 값을 바꿀 수 있을 겁니다.
약간 아쉬운 점이 있다면 화면이 처음 나타났을 때, Label 값이 아무런 값으로도 초기화되지 않는다는 점입니다.
< > 버튼을 눌렀을 때 비로소 숫자 Text가 나타납니다.
처음부터 Label에 0 값이 들어가 있었으면 좋겠습니다.
그렇게 만들기 위해선 화면이 처음 시작했을 때, 값을 0으로 설정해줄 트리거가 필요합니다.
화면이 처음 시작했을 때, 즉 ViewDidLoad 진입 시에 interactor에 start 메서드를 만들어서 해당 메서드를 호출할 수 있도록 코드를 구현해봅시다.
이전과 동일하게 BusinessLogic Protocol에 start 메서드를 추가해줍니다.
protocol ImageCollectionBusinessLogic { func start() func changeCount(request: ImageCollection.CountPhotoCollection.Request) }
Swift
복사
viewDidLoad에서 start 메서드를 호출하도록 코드를 작성합니다.
override func viewDidLoad() { super.viewDidLoad() start() } private func start() { interactor?.start() }
Swift
복사
interactor에서 start 메서드에서 이전에 만들어둔 PhotoCollectionCount enum 타입의 Response로 count 값을 전달할 수 있도록 합니다. 또한, 이전에 만들어둔 presentCount 메서드로 count 값이 담긴 Response를 전달합니다.
public func start() { let response = ImageCollection.PhotoCollectionCount.Response(count: count) presenter?.presentCount(response: response) }
Swift
복사
count 선언 시에 값을 0으로 설정했기 때문에, start 메서드를 통해서 presenter로 0이 전달될 겁니다.
var count: Int = 0
Swift
복사
위의 작업을 마치고 나면 이러한 화면이 완성될 겁니다.

⓶ 이미지 가져오기

[ 이미지 가져오기 버튼을 누르면 Unsplash API를 통해서 이미지를 가져옵니다. ]
[ 가져온 이미지는 CollectionView에서 볼 수 있습니다. ]
이번엔 설정해둔 Count 만큼의 이미지를 가져오는 기능을 만들어 볼 겁니다.
위의 이미지에서 이미지 가져오기 버튼을 누르면 이미지를 가져오고 상단 Collection View에 해당 이미지를 노출시킬 겁니다.
먼저, ViewController에 이미지 가져오기 버튼을 누르면 호출되는 메서드를 하나 만듭니다.
private func tapSubmitButton() { }
Swift
복사
그리고 BusinessLogic Protocol 내에 fetchPhotoCollection이라는 메서드를 하나 만듭니다.
protocol ImageCollectionBusinessLogic { func start() func changeCount(request: ImageCollection.CountPhotoCollection.Request) func fetchPhotoCollection(request: ImageCollection.PhotoCollection.Request) }
Swift
복사
ViewController에서 따로 전달할 값은 없기 때문에 비어있는 Request 모델을 Interactor로 전달하게 됩니다.
private func tapSubmitButton() { let request = ImageCollection.PhotoCollection.Request() interactor?.fetchPhotoCollection(request: request) }
Swift
복사
이미지를 가져오기 위해선 API 호출이 필요한 상황입니다.
Interactor에 API 호출 코드를 구현할 수도 있겠지만, Worker를 하나 만들어서 API 연결을 담당하게 하려고 합니다. Random Photo를 가지고 오는 PhotosWorker를 하나 만듭니다.
PhotosWorker는 count 값을 받아서 해당 숫자만큼의 이미지 URL String을 가져와서 반환해줍니다.
protocol PhotosWorker { func fetchRandomPhotoURLs(count: Int) async throws -> [String] }
Swift
복사
PhotosWorker를 conform하는 PhotosWorkerImpl 구현체에서 API 연결 코드를 구현하고 imageURL를 반환해줍니다. 서버 통신에 에러가 있거나 imageURL 값이 nil이라면 빈 배열을 반환합니다.
final actor PhotosWorkerImpl: PhotosWorker { let unsplashAPI: UnsplashProtocol init(unsplashAPI: UnsplashProtocol) { self.unsplashAPI = unsplashAPI } func fetchRandomPhotoURLs(count: Int) async throws -> [String] { do { let images = try await unsplashAPI.fetchRandomImages(count: count) let imageURLs = images?.compactMap { $0.urls?.regular } return imageURLs ?? [] } catch { return [] } } }
Swift
복사
*API 통신과 관련된 자세한 로직은 레포지토리에서 코드를 참고해주세요.
Worker 코드를 완성했다면 Interactor 내에 Worker를 추가해줍니다.
let photosWorker: PhotosWorker init(photosWorker: PhotosWorker) { self.photosWorker = photosWorker }
Swift
복사
Worker를 사용해서 PhotoURL를 가져오는 로직을 구현합니다. 가져온 URL 배열은 Response를 통해서 다음 Step(Presenter)로 넘겨줍니다.
public func fetchPhotoCollection(request: ImageCollection.PhotoCollection.Request) { Task { [weak photosWorker, presenter] in let urls = await photosWorker?.fetchRandomPhotoURLs(count: count) ?? [] let response = ImageCollection.PhotoCollection.Response(photoURLs: urls) presenter?.presentPhotoCollection(response: response) } }
Swift
복사
Presenter에서도 이전처럼 ViewController로 넘겨줍니다.
func presentPhotoCollection(response: ImageCollection.PhotoCollection.Response) { let viewModel = ImageCollection.PhotoCollection.ViewModel(photoURLs: response.photoURLs) viewController?.displayPhotoCollection(viewModel: viewModel) }
Swift
복사
Photo URL를 Collection View 세팅하는 메서드로 넘겨줍니다.
public func displayPhotoCollection(viewModel: ImageCollection.PhotoCollection.ViewModel) { contentView.setupPhotoCollectionView(photoURLs: viewModel.photoURLs) }
Swift
복사
해당 메서드에서 CollectionView를 reload 합니다.
func setupPhotoCollectionView(photoURLs: [String]) { DispatchQueue.main.async { [weak self] in self?.reloadURLList(photoURLs) } }
Swift
복사
위의 기능을 구현하고 나면 원하는 수만큼의 이미지를 가져오는 기능이 완성됩니다.

⓷ PageControl 기능 구현

[ ColletionView를 스크롤하면 다음 이미지를 볼 수 있고, PageControl가 움직입니다. ]
마지막 기능이라고 할 수 있는 PageControl 움직이는 기능을 만들어 봅시다.
일단 위의 코드를 약간 수정해서 가져온 이미지 만큼 Page Control의 숫자가 설정되도록 해봅시다.
func setupPhotoCollectionView(photoURLs: [String]) { DispatchQueue.main.async { [weak self] in self?.reloadURLList(photoURLs) self?.updatePageControl() } } private func updatePageControl() { pageControl.numberOfPages = photoCollectionView.numberOfItems(inSection: .zero) pageControl.currentPage = photoCollectionView.indexPathsForVisibleItems.first?.item ?? 0 }
Swift
복사
위의 코드를 추가하면 Image URL를 가져오는 경우에 Page Control도 함께 설정될 겁니다.
하지만, Page Control이 현재 이미지 Index로 설정되진 않을 겁니다. 그 기능을 구현해봅시다.
먼저, Collection View Scroll 시에 현재 CollectionView이 가지고 있는 width와 offset를 가지고 옵니다.
그리고 해당 값을 interactor로 전달해줍니다.
private func scrollCollectionView(width: Double, offset: Double) { let request = ImageCollection.PhotoCollectionPage.Request(width: width, offset: offset) interactor?.changeToPage(request: request) }
Swift
복사
값을 전달받은 interactor는 page 값을 계산합니다.
계산된 page 값을 화면에 전달해주기 위해서 presenter로 보냅니다.
public func changeToPage(request: ImageCollection.PhotoCollectionPage.Request) { let width = request.width let offset = request.offset let page = Int(offset / width) let response = ImageCollection.PhotoCollectionPage.Response(page: page) presenter?.presentCurrentPage(response: response) }
Swift
복사
presenter도 formatting 없이 값을 ViewController로 보냅니다.
func presentCurrentPage(response: ImageCollection.PhotoCollectionPage.Response) { let viewModel = ImageCollection.PhotoCollectionPage.ViewModel(page: response.page) viewController?.displayCurrentPage(viewModel: viewModel) }
Swift
복사
ViewController로 전달된 값을 CurrentPage 설정하는 부분에 전달해줍니다.
public func displayCurrentPage(viewModel: ImageCollection.PhotoCollectionPage.ViewModel) { contentView.setupCurrentPage(index: viewModel.page) } func setupCurrentPage(index: Int) { DispatchQueue.main.async { [weak self] in self?.pageControl.currentPage = index } }
Swift
복사
해당 기능까지 구현을 완료하고 나면, 기본적인 메인 화면 구현이 완료됩니다.

 Next Step

다음 게시물에서 메인 화면의 마지막 기능인 상세 페이지 이동 기능 및 상세 화면 기능을 구현을 해보려고 합니다.
아직 나오지 않은 Router 개념을 해당 게시물에서 심도있게 다룰 수 있을 거 같습니다.