[UIKit] Combine: Map Operations 3

Junyoung Park·2022년 10월 5일
0

UIKit

목록 보기
53/142
post-thumbnail

Combine framework tutorial - Part 2 - nested publisher streams with switchToLatest and flatMap

Combine: Map Operations 3

구현 목표

구현 태스크

핵심 코드

private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background))
            .tryMap { output in
                return output.data
            }
            .decode(type: [AlbumModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished: print("Successfully get album info")
                case .failure(let error): print(error.localizedDescription)
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.albumSubject.send(returnedValue)
            }
            .store(in: &cancellables)
        imageUrlSubject
            .removeDuplicates()
            .compactMap { url in
                return URL(string: url)
            }
//            .buffer(size: 10, prefetch: .byRequest, whenFull: .dropOldest)
            .flatMap(maxPublishers: .max(4)) { url in
                URLSession
                    .shared
                    .dataTaskPublisher(for: url)
                    .map(\.data)
                    .compactMap { data in
                        return UIImage(data: data)
                    }
                    .catch { _ in
                        return Empty()
                    }
            }
            .scan([UIImage]()) { all, next in
                return all + [next]
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] images in
                guard let self = self else { return }
                self.imagesSubject.send(images)
            }
            .store(in: &cancellables)
    }
  • 뷰 모델의 이니셜라이저 단에서 실행되는 구독 함수
  • 먼저 앨범 정보를 한 번에 불러오는 API 호출, albumSubject 데이터 퍼블리셔를 통해 테이블 뷰 데이터 그리기
  • albumSubject를 통해 그린 테이블 뷰 선택 → 해당 URL을 현재까지 가지고 있는 URL 정보에 더해서 이미지 호출하기
  • 이미지 → imageURLSubject에서 계속해서 다운로드한 이미지를 가지고 있는 UIImage 배열 데이터 퍼블리셔
private func addSubscriber() {
        viewModel
            .albumSubject
            .sink { [weak self] albums in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.urlTableView.reloadData()
                }
            }
            .store(in: &cancellabels)
        viewModel
            .imagesSubject
            .compactMap{$0}
            .sink { [weak self] images in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.imageTableView.reloadData()
                }
            }
            .store(in: &cancellabels)
    }
  • 뷰 컨트롤러 이니셜라이저 단에서 실행되는 구독 함수
  • 테이블 뷰의 데이터소스인 뷰 모델 내의 albumSubject
  • 이미지 테이블 뷰 또한 뷰 모델 내의 imagesSubject를 데이터소스로 활용

소스 코드

import Foundation
import Combine
import UIKit

class AlbumCollectionViewModel {
    let albumSubject = CurrentValueSubject<[AlbumModel], Never>([AlbumModel]())
    let imageUrlSubject = CurrentValueSubject<String, Never>("")
    let imagesSubject = CurrentValueSubject<[UIImage], Never>([UIImage]())
    private let urlString: String
    private var cancellables = Set<AnyCancellable>()
    
    init(urlString: String = "https://jsonplaceholder.typicode.com/photos") {
        self.urlString = urlString
        addSubscriber()
    }
    
    private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background))
            .tryMap { output in
                return output.data
            }
            .decode(type: [AlbumModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished: print("Successfully get album info")
                case .failure(let error): print(error.localizedDescription)
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.albumSubject.send(returnedValue)
            }
            .store(in: &cancellables)
        imageUrlSubject
            .removeDuplicates()
            .compactMap { url in
                return URL(string: url)
            }
//            .buffer(size: 10, prefetch: .byRequest, whenFull: .dropOldest)
            .flatMap(maxPublishers: .max(4)) { url in
                URLSession
                    .shared
                    .dataTaskPublisher(for: url)
                    .map(\.data)
                    .compactMap { data in
                        return UIImage(data: data)
                    }
                    .catch { _ in
                        return Empty()
                    }
            }
            .scan([UIImage]()) { all, next in
                return all + [next]
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] images in
                guard let self = self else { return }
                self.imagesSubject.send(images)
            }
            .store(in: &cancellables)
    }
}
  • 뷰 모델 소스 코드
import UIKit
import Combine

class AlbumCollectionViewController: UIViewController {
    private let urlTableView: UITableView = {
        let tableView = UITableView()
        tableView.register(AlbumCell.self, forCellReuseIdentifier: AlbumCell.identifier)
        return tableView
    }()
    private let imageTableView: UITableView = {
        let tableView = UITableView()
        tableView.register(ImageCell.self, forCellReuseIdentifier: ImageCell.identifier)
        return tableView
    }()

    private var cancellabels = Set<AnyCancellable>()
    private let viewModel = AlbumCollectionViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setAlbumViewUI()
        addSubscriber()
    }
    
    private func setAlbumViewUI() {
        view.backgroundColor = .systemBackground
        urlTableView.dataSource = self
        urlTableView.delegate = self
        imageTableView.dataSource = self
        urlTableView.translatesAutoresizingMaskIntoConstraints = false
        imageTableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(urlTableView)
        view.addSubview(imageTableView)
        urlTableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        urlTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        urlTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        urlTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -400).isActive = true
        imageTableView.topAnchor.constraint(equalTo: urlTableView.bottomAnchor, constant: 5).isActive = true
        imageTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        imageTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        imageTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
    }
    
    private func addSubscriber() {
        viewModel
            .albumSubject
            .sink { [weak self] albums in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.urlTableView.reloadData()
                }
            }
            .store(in: &cancellabels)
        viewModel
            .imagesSubject
            .compactMap{$0}
            .sink { [weak self] images in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.imageTableView.reloadData()
                }
            }
            .store(in: &cancellabels)
    }
}

extension AlbumCollectionViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        if tableView == urlTableView {
            let imageUrlString = viewModel.albumSubject.value[indexPath.row].url
            viewModel.imageUrlSubject.send(imageUrlString)
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if tableView == urlTableView {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: AlbumCell.identifier, for: indexPath) as? AlbumCell else {
                return UITableViewCell()
            }
            let model = viewModel.albumSubject.value[indexPath.row]
            cell.configure(with: model)
            return cell
        } else {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageCell.identifier, for: indexPath) as? ImageCell else {
                return UITableViewCell()
            }
            let model = viewModel.imagesSubject.value[indexPath.row]
            cell.configure(with: model)
            return cell
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if tableView == urlTableView {
            return viewModel.albumSubject.value.count
        } else {
            return viewModel.imagesSubject.value.count
        }
    }
}
  • 뷰 컨트롤러 코드
  • 테이블 뷰 델리게이트, 데이터소스 함수에서 URL 테이블, 이미지 테이블 모두 핸들링
import UIKit

class ImageCell: UITableViewCell {
    static let identifier = "ImageCell"
    
    private let imageLabel: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setImageCellLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setImageCellLayout() {
        imageLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(imageLabel)
        imageLabel.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        imageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
        imageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
        imageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
    }
    
    func configure(with model: UIImage) {
        imageLabel.image = model
    }
}
  • 이미지 셀
  • 동적으로 구현을 위한 컨텐츠 뷰 내의 오토 레이아웃

구현 화면

profile
JUST DO IT

0개의 댓글