Combine framework tutorial - Part 2 - nested publisher streams with switchToLatest and flatMap
Nested Publisher Stream
사용하기 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
.compactMap { urlString in
return URL(string: urlString)
}
.map { url in
URLSession
.shared
.dataTaskPublisher(for: url)
.map(\.data)
.compactMap { data in
return UIImage(data: data)
}
}
.switchToLatest()
.sink { completion in
switch completion {
case .failure(let _): break
case .finished: print("Successfully get Image URL")
}
} receiveValue: { [weak self] image in
guard let self = self else { return }
self.imageSubject.send(image)
}
.store(in: &cancellables)
}
albumSubject
를 URLSession을 통해 구독imageUrlSubject
는 유저에 의해 선택되는 테이블 뷰 셀 내의 URL 문자열 데이터로 데이터 변경 시 곧바로 URLSession을 통해 데이터 패치switchToLatest()
를 통해 변경 가능 private func addSubscriber() {
viewModel
.albumSubject
.sink { [weak self] albums in
guard let self = self else { return }
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
.store(in: &cancellabels)
viewModel
.imageSubject
.compactMap{$0}
.sink { [weak self] image in
guard let self = self else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
}
.store(in: &cancellabels)
}
albumSubject
구독imageSubject
는 테이블 뷰에서 선택한 imageUrlSubject
변경 뒤 패치된 데이터로 sink
단에 패치, 값 변경 뒤 UI를 그리기 위해 imageView
의 이미지 데이터에 곧바로 넣기 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let imageUrlString = viewModel.albumSubject.value[indexPath.row].url
viewModel.imageUrlSubject.send(imageUrlString)
}
imageUrlSubject
로 값을 보내기 위한 테이블 뷰 선택 함수import Foundation
struct AlbumModel: Codable {
let albumId: Int
let id: Int
let title: String
let url: String
let thumbnailUrl: String
}
Codable
프로토콜 준수하는 구조체import Foundation
import Combine
import UIKit
final class AlbumViewModel {
let albumSubject = CurrentValueSubject<[AlbumModel], Never>([AlbumModel]())
let imageUrlSubject = CurrentValueSubject<String, Never>("")
let imageSubject = CurrentValueSubject<UIImage?, Never>(nil)
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
.compactMap { urlString in
return URL(string: urlString)
}
.map { url in
URLSession
.shared
.dataTaskPublisher(for: url)
.map(\.data)
.compactMap { data in
return UIImage(data: data)
}
}
.switchToLatest()
.sink { completion in
switch completion {
case .failure(let _): break
case .finished: print("Successfully get Image URL")
}
} receiveValue: { [weak self] image in
guard let self = self else { return }
self.imageSubject.send(image)
}
.store(in: &cancellables)
}
}
import UIKit
import Combine
class AlbumViewController: UIViewController {
private let tableView: UITableView = {
let tableView = UITableView()
tableView.register(AlbumCell.self, forCellReuseIdentifier: AlbumCell.identifier)
return tableView
}()
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}()
private var cancellabels = Set<AnyCancellable>()
private var viewModel = AlbumViewModel()
override func viewDidLoad() {
super.viewDidLoad()
setAlbumViewUI()
addSubscriber()
}
private func setAlbumViewUI() {
view.backgroundColor = .systemBackground
tableView.dataSource = self
tableView.delegate = self
tableView.translatesAutoresizingMaskIntoConstraints = false
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
view.addSubview(imageView)
tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -400).isActive = true
imageView.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 20).isActive = true
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
}
private func addSubscriber() {
viewModel
.albumSubject
.sink { [weak self] albums in
guard let self = self else { return }
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
.store(in: &cancellabels)
viewModel
.imageSubject
.compactMap{$0}
.sink { [weak self] image in
guard let self = self else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
}
.store(in: &cancellabels)
}
}
extension AlbumViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let imageUrlString = viewModel.albumSubject.value[indexPath.row].url
viewModel.imageUrlSubject.send(imageUrlString)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
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
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.albumSubject.value.count
}
}
imageUrlSubject
로 값 전송imageSubject
값 변화 시 곧바로 이미지 패치를 위한 구독compactMap
등 고차 함수 활용import UIKit
class AlbumCell: UITableViewCell {
static let identifier = "AlbumCell"
private let titleLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .title1)
label.textColor = .black
label.numberOfLines = 0
return label
}()
private let urlLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .darkGray
label.numberOfLines = 0
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setAlbumCellLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setAlbumCellLayout() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
urlLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(titleLabel)
contentView.addSubview(urlLabel)
separatorInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor).isActive = true
titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
urlLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10).isActive = true
urlLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
urlLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
urlLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor).isActive = true
}
func configure(with model: AlbumModel) {
titleLabel.text = model.title
urlLabel.text = model.url
}
}